From fc9eff865e3c3dcb897229136c437c3fe75891a6 Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Fri, 13 Mar 2026 11:32:16 -0600 Subject: [PATCH] v0.9.0: in-app auto-updater, ship toggle split, stable entity IDs, performance fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - In-app auto-updater with confirmation dialog, manual download fallback, restart polling, and protected file safety net - Ship layers split into 4 independent toggles (Military/Carriers, Cargo/Tankers, Civilian, Cruise/Passenger) with per-category counts - Stable entity IDs using MMSI/callsign instead of volatile array indices - Dismissible threat alert bubbles (session-scoped, survives data refresh) Performance: - GDELT title fetching is now non-blocking (background enrichment) - Removed duplicate startup fetch jobs - Docker healthcheck start_period 15s → 90s Bug fixes: - Removed fake intelligence assessment generator (OSINT-only policy) - Fixed carrier tracker GDELT 429/TypeError crash - Fixed ETag collision (full payload hash) - Added concurrent /api/refresh guard Contributors: @imqdcr (ship split + stable IDs), @csysp (dismissible alerts, PR #48) Co-Authored-By: Claude Opus 4.6 Former-commit-id: a2c4c67da54345393f70a9b33b52e7e4fd6c049f --- .gitignore | 6 + DOCKER_SECRETS.md | 60 + ROADMAP.md | 853 +++++++ UPDATEPROTOCOL.md | 257 ++ backend/.dockerignore | 18 +- backend/Dockerfile | 5 +- backend/check_regions.py | 17 - backend/clean_osm_cctvs.py | 10 - backend/data/sat_gp_cache.json | 2 +- backend/extract_ovens.py | 25 - backend/geocode_datacenters.py | 166 -- backend/main.py | 107 +- backend/pytest.ini | 4 + backend/requirements-dev.txt | 3 + backend/requirements.txt | 36 +- backend/services/ais_stream.py | 6 +- backend/services/carrier_tracker.py | 47 +- backend/services/cctv_pipeline.py | 8 +- backend/services/data_fetcher.py | 2169 ++--------------- backend/services/fetchers/__init__.py | 0 backend/services/fetchers/_store.py | 46 + backend/services/fetchers/flights.py | 721 ++++++ backend/services/fetchers/military.py | 218 ++ backend/services/fetchers/news.py | 220 ++ backend/services/fetchers/plane_alert.py | 205 ++ backend/services/fetchers/satellites.py | 354 +++ backend/services/geopolitics.py | 119 +- backend/services/kiwisdr_fetcher.py | 3 +- backend/services/liveuamap_scraper.py | 10 +- backend/services/network_utils.py | 16 +- backend/services/news_feed_config.py | 4 +- backend/services/radio_intercept.py | 6 +- backend/services/region_dossier.py | 16 +- backend/services/sentinel_search.py | 5 +- backend/services/updater.py | 257 ++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 50 + backend/tests/test_api_smoke.py | 114 + docker-compose.yml | 29 +- frontend/next.config.ts | 6 - frontend/package-lock.json | 5 +- frontend/package.json | 3 +- frontend/src/app/globals.css | 30 + frontend/src/app/layout.tsx | 5 +- frontend/src/app/page.tsx | 52 +- frontend/src/components/CesiumViewer.tsx | 1813 -------------- frontend/src/components/ChangelogModal.tsx | 73 +- frontend/src/components/MaplibreViewer.tsx | 754 ++---- frontend/src/components/MarketsPanel.tsx | 8 +- frontend/src/components/TopRightControls.tsx | 224 +- .../src/components/WorldviewLeftPanel.tsx | 19 +- frontend/src/components/map/MapMarkers.tsx | 283 +++ .../map/hooks/useImperativeSource.ts | 25 + .../src/components/map/icons/AircraftIcons.ts | 146 ++ .../components/map/icons/SatelliteIcons.ts | 28 + frontend/src/components/map/mapConstants.ts | 5 + .../src/components/map/styles/mapStyles.ts | 41 + frontend/src/lib/DashboardDataContext.tsx | 30 + frontend/src/utils/aircraftClassification.ts | 12 + frontend/src/utils/positioning.ts | 23 + frontend/tsconfig.json | 1 + start.sh | 4 + 62 files changed, 4930 insertions(+), 4852 deletions(-) create mode 100644 DOCKER_SECRETS.md create mode 100644 ROADMAP.md create mode 100644 UPDATEPROTOCOL.md delete mode 100644 backend/check_regions.py delete mode 100644 backend/clean_osm_cctvs.py delete mode 100644 backend/extract_ovens.py delete mode 100644 backend/geocode_datacenters.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/services/fetchers/__init__.py create mode 100644 backend/services/fetchers/_store.py create mode 100644 backend/services/fetchers/flights.py create mode 100644 backend/services/fetchers/military.py create mode 100644 backend/services/fetchers/news.py create mode 100644 backend/services/fetchers/plane_alert.py create mode 100644 backend/services/fetchers/satellites.py create mode 100644 backend/services/updater.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api_smoke.py delete mode 100644 frontend/src/components/CesiumViewer.tsx create mode 100644 frontend/src/components/map/MapMarkers.tsx create mode 100644 frontend/src/components/map/hooks/useImperativeSource.ts create mode 100644 frontend/src/components/map/icons/AircraftIcons.ts create mode 100644 frontend/src/components/map/icons/SatelliteIcons.ts create mode 100644 frontend/src/components/map/mapConstants.ts create mode 100644 frontend/src/components/map/styles/mapStyles.ts create mode 100644 frontend/src/lib/DashboardDataContext.tsx create mode 100644 frontend/src/utils/aircraftClassification.ts create mode 100644 frontend/src/utils/positioning.ts diff --git a/.gitignore b/.gitignore index 87dc843..8f100a2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ tmp/ *.log *.tmp *.bak +*.swp +*.swo out.txt out_sys.txt rss_output.txt @@ -75,7 +77,11 @@ backend/out_liveua.json frontend/server_logs*.txt frontend/cctv.db *.zip +*.tar.gz .git_backup/ +coverage/ +.coverage +dist/ # Test files (may contain hardcoded keys) backend/test_*.py diff --git a/DOCKER_SECRETS.md b/DOCKER_SECRETS.md new file mode 100644 index 0000000..c6e71d9 --- /dev/null +++ b/DOCKER_SECRETS.md @@ -0,0 +1,60 @@ +# Docker Secrets + +The backend supports [Docker Swarm secrets](https://docs.docker.com/engine/swarm/secrets/) +so you never have to put API keys in environment variables or `.env` files. + +## How it works + +At startup (before any service modules are imported), `main.py` checks a +list of secret-capable variables. For each variable `VAR`, if the +environment variable `VAR_FILE` is set (typically `/run/secrets/VAR`), +the file is read, its content is trimmed, and the result is injected into +`os.environ[VAR]`. All downstream code sees a normal environment variable. + +## Supported variables + +| Variable | Purpose | +|---|---| +| `AIS_API_KEY` | AISStream.io WebSocket key | +| `OPENSKY_CLIENT_ID` | OpenSky Network client ID | +| `OPENSKY_CLIENT_SECRET` | OpenSky Network client secret | +| `LTA_ACCOUNT_KEY` | Singapore LTA DataMall key | +| `CORS_ORIGINS` | Allowed CORS origins (comma-separated) | + +## docker-compose.yml example + +```yaml +services: + backend: + build: + context: ./backend + environment: + - AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY + - OPENSKY_CLIENT_ID_FILE=/run/secrets/OPENSKY_CLIENT_ID + - OPENSKY_CLIENT_SECRET_FILE=/run/secrets/OPENSKY_CLIENT_SECRET + - LTA_ACCOUNT_KEY_FILE=/run/secrets/LTA_ACCOUNT_KEY + secrets: + - AIS_API_KEY + - OPENSKY_CLIENT_ID + - OPENSKY_CLIENT_SECRET + - LTA_ACCOUNT_KEY + +secrets: + AIS_API_KEY: + file: ./secrets/ais_api_key.txt + OPENSKY_CLIENT_ID: + file: ./secrets/opensky_client_id.txt + OPENSKY_CLIENT_SECRET: + file: ./secrets/opensky_client_secret.txt + LTA_ACCOUNT_KEY: + file: ./secrets/lta_account_key.txt +``` + +Each secret file should contain only the raw key value (whitespace is trimmed). + +## Notes + +- The secrets loop runs **before** any FastAPI service imports, so modules + that read `os.environ` at import time see the injected values. +- Missing or empty secret files log a warning; the backend still starts. +- You can mix approaches: use `_FILE` for some keys and plain env vars for others. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a642bca --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,853 @@ +# ShadowBroker Engineering Roadmap + +> **Version**: 1.0 | **Created**: 2026-03-12 | **Codebase**: v0.8.0 +> **Purpose**: Structured, agent-executable roadmap to bring ShadowBroker to production-grade quality. +> **How to use**: Each task is an atomic unit of work. An AI agent or developer can pick any task whose dependencies are met and execute it independently. Mark tasks `[x]` when complete. + +--- + +## Architecture Overview + +``` +live-risk-dashboard/ + frontend/ # Next.js 16 + React 19 + MapLibre GL + src/app/page.tsx # 621 LOC — dashboard orchestrator (19 state vars, 33 hooks) + src/components/ + MaplibreViewer.tsx # 3,065 LOC — GOD COMPONENT (map + all layers + icons + popups) + CesiumViewer.tsx # 1,813 LOC — DEAD CODE (never imported) + NewsFeed.tsx # 1,088 LOC — news + entity detail panels + + 15 more components + next.config.ts # ignoreBuildErrors: true, ignoreDuringBuilds: true (!!!) + backend/ # Python FastAPI + Node.js AIS proxy + main.py # 315 LOC — FastAPI app entry + services/ + data_fetcher.py # 2,417 LOC — GOD MODULE (15+ data sources in one file) + ais_stream.py # 367 LOC — WebSocket AIS client + + 10 more service modules + test_*.py (26 files) # ALL manual print-based, zero assertions, zero pytest + docker-compose.yml # No health checks, no resource limits + .github/workflows/docker-publish.yml # No test step, no image scanning +``` + +--- + +## Scoring Baseline (Pre-Roadmap) + +| Category | Score | Key Issue | +|----------|-------|-----------| +| Thread Safety | 3/10 | Race conditions on `routes_fetch_in_progress`, unguarded `latest_data` writes | +| Type Safety | 2/10 | 50+ `any` types, TS/ESLint errors hidden by config flags | +| Testing | 0/10 | Zero automated tests, 26 manual print scripts | +| Error Handling | 4/10 | Bare `except: pass` clauses, no error boundaries on panels | +| Architecture | 3/10 | Two god files (3065 + 2417 LOC), massive prop drilling | +| DevOps | 5/10 | Good Docker multi-arch, but no health checks/limits/scanning | +| Security | 4/10 | No rate limiting, no input validation, no HTTPS docs | +| Accessibility | 1/10 | No ARIA labels, no keyboard nav, no semantic HTML | +| **Overall** | **3.5/10** | Production-adjacent, not production-ready | + +--- + +## Phase 1: Stabilization & Safety + +**Goal**: Fix things that silently corrupt data, hide bugs, or could cause production incidents. Every task here has outsized impact relative to effort. + +**All Phase 1 tasks are independent and can be executed in parallel.** + +--- + +### Task 1.1: Fix thread safety bugs in data_fetcher.py + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P0 — data corruption risk | +| **Dependencies** | None | + +**File**: `backend/services/data_fetcher.py` + +**Problem**: `routes_fetch_in_progress` (~line 645) is a bare global boolean read/written from multiple threads with no lock. `latest_data` is written at ~lines 599, 627, 639 without `_data_lock`. These are TOCTOU race conditions. + +**Scope**: +1. Add a `_routes_lock = threading.Lock()` and wrap all reads/writes of `routes_fetch_in_progress` and `dynamic_routes_cache` with it. The current pattern (`if routes_fetch_in_progress: return; routes_fetch_in_progress = True`) is a classic TOCTOU race. +2. Find every `latest_data[...] = ...` assignment NOT already under `_data_lock` and wrap it. Search pattern: `latest_data\[`. +3. Audit `_trails_lock` usage — ensure `flight_trails` dict is never accessed outside the lock. Check all references beyond the lock at ~line 1187. + +**Verification**: +```bash +# Every latest_data write should be inside a lock +grep -n "latest_data\[" backend/services/data_fetcher.py +# Confirm routes_fetch_in_progress is no longer a bare boolean check +grep -n "routes_fetch_in_progress" backend/services/data_fetcher.py +``` +All writes should be inside `with _data_lock:` or `with _routes_lock:` blocks. + +--- + +### Task 1.2: Replace bare except clauses with specific exceptions + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P0 — swallows KeyboardInterrupt, SystemExit | +| **Dependencies** | None | + +**Files**: +- `backend/services/cctv_pipeline.py` ~line 223: `except:` → `except (ValueError, TypeError) as e:` + `logger.debug()` +- `backend/services/liveuamap_scraper.py` ~lines 43, 59: `except:` → `except Exception as e:` + `logger.debug()` +- `backend/services/data_fetcher.py` ~lines 705-706: `except Exception: pass` → add `logger.warning()` + +**Verification**: +```bash +# Must return ZERO matches +grep -rn "except:" backend/ --include="*.py" | grep -v "except Exception" | grep -v "except (" +# Also check for silent swallows +grep -rn "except.*: pass" backend/ --include="*.py" +``` + +--- + +### Task 1.3: Re-enable TypeScript and ESLint checking + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P0 — currently hiding ALL type errors and lint violations | +| **Dependencies** | None (but pairs well with Phase 2 decomposition) | + +**Files**: +- `frontend/next.config.ts` — remove `typescript: { ignoreBuildErrors: true }` and `eslint: { ignoreDuringBuilds: true }` +- `frontend/package.json` — fix lint script from `"lint": "eslint"` to `"lint": "next lint"` or `"lint": "eslint src/"` + +**Scope**: +1. Run `npx tsc --noEmit` in `frontend/` and record all errors. +2. Fix type errors file by file. The heaviest offenders: + - `MaplibreViewer.tsx`: ~55 occurrences of `: any` — create proper interfaces for props, GeoJSON features, events. + - `page.tsx`: state types need explicit interfaces. +3. Replace `any` with proper interfaces. Key types needed: + ```typescript + interface DataPayload { commercial_flights: Flight[]; military_flights: Flight[]; satellites: Satellite[]; ... } + interface Flight { hex: string; lat: number; lon: number; alt_baro: number; ... } + interface MaplibreViewerProps { data: DataPayload; activeLayers: ActiveLayers; ... } + ``` +4. Only after ALL errors are fixed, remove the two `ignore*` flags from `next.config.ts`. +5. Fix the lint script and run `npm run lint` clean. + +**Verification**: +```bash +cd frontend && npx tsc --noEmit # Must exit 0 +cd frontend && npm run lint # Must exit 0 +cd frontend && npm run build # Must succeed WITHOUT ignoreBuildErrors +``` + +--- + +### Task 1.4: Add transaction safety to cctv_pipeline.py + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P1 | +| **Dependencies** | None | + +**File**: `backend/services/cctv_pipeline.py` + +**Scope**: Wrap all SQLite write operations in try/except with explicit `conn.rollback()` on failure. Currently if an insert fails midway, the connection may be left dirty. + +**Verification**: Search for all `conn.execute` / `cursor.execute` calls and confirm each write path has rollback handling. + +--- + +### Task 1.5: Add rate limiting and input validation to backend API + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P1 — security exposure | +| **Dependencies** | None | + +**File**: `backend/main.py` + +**Scope**: +1. Add a simple in-memory rate limiter (e.g., `slowapi` or custom middleware). Target: 60 req/min per IP for data endpoints. +2. Add Pydantic validation for coordinate parameters on all endpoints that accept lat/lng: + ```python + from pydantic import Field, confloat + lat: confloat(ge=-90, le=90) + lng: confloat(ge=-180, le=180) + ``` +3. Add `slowapi` to `requirements.txt` if used. + +**Verification**: +```bash +# Rate limit test: 100 rapid requests should get 429 after ~60 +for i in $(seq 1 100); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/live-data/fast; done | sort | uniq -c +# Validation test: invalid coords should return 422 +curl -s http://localhost:8000/api/region-dossier?lat=999&lng=999 | grep -c "error" +``` + +--- + +### Task 1.6: Delete dead code + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P1 | +| **Dependencies** | None | + +**Files to delete**: +- `frontend/src/components/CesiumViewer.tsx` — 1,813 LOC, never imported anywhere +- Root one-off scripts: `refactor_cesium.py`, `zip_repo.py`, `jobs.json` (if tracked) +- Backend one-off scripts: `check_regions.py`, `analyze_xlsx.py`, `clean_osm_cctvs.py`, `extract_ovens.py`, `geocode_datacenters.py` (if tracked and not gitignored) + +**Also**: +- Remove `fetch_bikeshare()` function from `data_fetcher.py` and its scheduler entry (if bikeshare layer no longer exists in the UI) + +**Verification**: +```bash +grep -rn "CesiumViewer" frontend/src/ # Must return 0 matches +grep -rn "fetch_bikeshare" backend/ # Must return 0 matches +cd frontend && npm run build # Must succeed +``` + +--- + +## Phase 2: Frontend Architecture — God Component Decomposition + +**Goal**: Break `MaplibreViewer.tsx` (3,065 LOC) and `page.tsx` (621 LOC) into maintainable, testable units. This is the highest-impact refactor in the entire codebase. + +**Dependency chain**: `2.1 + 2.2` (parallel) → `2.3` → `2.4` → `2.5` + +--- + +### Task 2.1: Extract SVG icons and aircraft classification + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P1 | +| **Dependencies** | None | + +**Source**: `frontend/src/components/MaplibreViewer.tsx` + +**New files to create**: +| File | Content | Source Lines | +|------|---------|-------------| +| `frontend/src/components/map/icons/AircraftIcons.ts` | All SVG path data constants (plane, heli, turboprop silhouettes) | ~1-150 | +| `frontend/src/components/map/icons/SvgMarkers.ts` | SVG factory functions (`makeFireSvg`, `makeAircraftSvg`, etc.) | ~60-91 | +| `frontend/src/utils/aircraftClassification.ts` | Military/private/commercial classifier function | ~163-169 | + +**Scope**: Pure extraction — move constants and pure functions out. No logic changes. Update imports in MaplibreViewer. + +**Verification**: `wc -l frontend/src/components/MaplibreViewer.tsx` decreases by ~200. `npm run build` succeeds. + +--- + +### Task 2.2: Extract map utilities and style definitions + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P1 | +| **Dependencies** | None (parallel with 2.1) | + +**Source**: `frontend/src/components/MaplibreViewer.tsx` + +**New files to create**: +| File | Content | Source Lines | +|------|---------|-------------| +| `frontend/src/utils/positioning.ts` | Interpolation helpers (lerp, bearing calc) | ~171-193 | +| `frontend/src/components/map/styles/mapStyles.ts` | Dark/light/satellite/FLIR/NVG/CRT style URL definitions | ~195-235 | + +**Scope**: Pure extraction of stateless helpers. + +**Verification**: Build succeeds. Grep confirms moved functions are only defined in the new files. + +--- + +### Task 2.3: Extract custom hooks from MaplibreViewer + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P1 | +| **Dependencies** | Tasks 2.1, 2.2 | + +**Source**: `frontend/src/components/MaplibreViewer.tsx` + +**New files to create**: +| File | Content | Source Lines | +|------|---------|-------------| +| `frontend/src/hooks/useImperativeSource.ts` | The `useImperativeSource` hook for direct MapLibre source updates | ~268-285 | +| `frontend/src/hooks/useMapDataLayers.ts` | GeoJSON builder `useMemo` hooks (earthquakes, jamming, CCTV, data centers, fires, outages, KiwiSDR) | ~405-582 | +| `frontend/src/hooks/useMapImages.ts` | Image loading system for `onMapLoad` callback | ~585-720 | +| `frontend/src/hooks/useTrafficGeoJSON.ts` | Flight/ship/satellite GeoJSON construction with interpolation | ~784-900 | + +**Scope**: Each hook accepts the map instance ref and relevant data as parameters and returns GeoJSON/state. Must handle the `map.getSource()` / `src.setData()` imperative pattern cleanly. + +**Verification**: `wc -l frontend/src/components/MaplibreViewer.tsx` is under 1,500 LOC. All map layers still render correctly (manual visual check required). + +--- + +### Task 2.4: Extract HTML label rendering into MapMarkers component + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P2 | +| **Dependencies** | Task 2.3 | + +**Source**: `frontend/src/components/MaplibreViewer.tsx` ~lines 1800-1910 + +**New file**: `frontend/src/components/map/MapMarkers.tsx` + +**Scope**: Move the HTML overlay rendering (flight labels, carrier labels, tracked aircraft labels, cluster count badges) into a dedicated component. Receives position arrays via props. + +**Verification**: Labels still appear on map. `MaplibreViewer.tsx` drops below 1,200 LOC. + +--- + +### Task 2.5: Introduce React Context for shared dashboard state + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P1 | +| **Dependencies** | Tasks 2.1-2.4 (reduces merge conflicts) | + +**Source**: `frontend/src/app/page.tsx` (621 LOC, 19 state variables, 33 hooks) + +**New files to create**: +| File | Content | +|------|---------| +| `frontend/src/contexts/DashboardContext.tsx` | Context provider: `activeLayers`, `activeFilters`, `selectedEntity`, `eavesdrop` state, `effects`, `activeStyle`, `measureMode` | +| `frontend/src/hooks/useDataPolling.ts` | Data fetch interval logic (fast/slow ETag polling, currently inline in page.tsx) | +| `frontend/src/hooks/useGeocoding.ts` | LocateBar geocoding logic (Nominatim reverse geocoding on mouse move, currently inline in page.tsx) | + +**Scope**: +1. Create `DashboardContext` wrapping the 19+ state variables. +2. Move the `LocateBar` inline component (defined inside page.tsx at ~line 26) into its own file. +3. Replace prop drilling to 9 child components with context consumption. +4. `page.tsx` becomes a thin layout shell under 150 LOC. + +**Verification**: `wc -l frontend/src/app/page.tsx` is under 150. All panels still receive their data. No prop names in JSX return that were previously drilled. + +--- + +## Phase 3: Backend Architecture — God Module Decomposition + +**Goal**: Break `data_fetcher.py` (2,417 LOC) into per-source modules with proper error handling and bounded caches. + +**Dependency**: Task 3.1 depends on Task 1.1 (thread safety fixes first). Tasks 3.2-3.4 can start after 3.1 or independently. + +--- + +### Task 3.1: Split data_fetcher.py into per-source fetcher modules + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | L (6-12h) | +| **Priority** | P1 | +| **Dependencies** | Task 1.1 (lock pattern must be correct before splitting) | + +**Source**: `backend/services/data_fetcher.py` (2,417 LOC) + +**New directory structure**: +``` +backend/services/fetchers/ + __init__.py # Re-exports for backward compat + store.py # latest_data, _data_lock, source_timestamps, get_latest_data() + scheduler.py # start_scheduler(), stop_scheduler(), APScheduler wiring + flights.py # OpenSky client, ADS-B fetch, route lookup, military classification, POTUS fleet + ships.py # AIS data processing, vessel categorization + satellites.py # TLE parsing, SGP4 propagation + news.py # RSS feeds, risk scoring, clustering + markets.py # yfinance stocks, oil prices + weather.py # RainViewer, space weather (NOAA SWPC) + infrastructure.py # CCTV, KiwiSDR, internet outages (IODA), data centers + geospatial.py # Earthquakes (USGS), FIRMS fires, GPS jamming +``` + +**Scope**: +1. Each fetcher module exports a `fetch_*()` function. +2. `store.py` holds `latest_data`, `_data_lock`, `source_timestamps`, and `get_latest_data()`. +3. `scheduler.py` imports all fetchers and wires them to APScheduler jobs. +4. The original `data_fetcher.py` becomes a thin re-export shim so `main.py` imports remain unchanged: + ```python + from .fetchers.scheduler import start_scheduler, stop_scheduler + from .fetchers.store import get_latest_data, latest_data + ``` + +**Verification**: +```bash +wc -l backend/services/data_fetcher.py # Should be under 50 (shim only) +python -c "from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data" # Must succeed +# Start backend and confirm data flows through all endpoints +``` + +--- + +### Task 3.2: Add TTL and max-size bounds to all caches + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P1 | +| **Dependencies** | Task 3.1 (cleaner after split, but can be done before) | + +**Files**: `backend/services/data_fetcher.py` (or the new fetcher modules after 3.1) + +**Problem caches**: +- `_region_geocode_cache` (~line 1600): unbounded dict, no TTL, grows forever +- `dynamic_routes_cache` (~line 644): has manual pruning but should use `cachetools` + +**Scope**: Replace unbounded dicts with `cachetools.TTLCache`: +```python +from cachetools import TTLCache +_region_geocode_cache = TTLCache(maxsize=2000, ttl=86400) # 24h +dynamic_routes_cache = TTLCache(maxsize=5000, ttl=7200) # 2h +``` +`cachetools` is already in `requirements.txt`. + +**Verification**: After running for 1 hour, `len(cache)` stays bounded. + +--- + +### Task 3.3: Replace bare Exception catches with specific types and structured logging + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P2 | +| **Dependencies** | Task 1.2, Task 3.1 | + +**Files**: All `backend/services/*.py` + +**Scope**: +1. Replace `except Exception as e: logger.error(...)` with specific exceptions where possible: `requests.RequestException`, `json.JSONDecodeError`, `ValueError`, `KeyError`. +2. Add structured context to log messages: data source name, URL, HTTP status code. +3. Ensure zero `except Exception: pass` patterns remain. + +**Verification**: +```bash +grep -rn "except Exception: pass" backend/ # Must return 0 +grep -rn "except:" backend/ --include="*.py" | grep -v "except Exception" | grep -v "except (" # Must return 0 +``` + +--- + +### Task 3.4: Pin all Python dependencies and audit fragile ones + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P2 | +| **Dependencies** | None | + +**File**: `backend/requirements.txt` + +**Scope**: +1. Pin all dependencies to exact versions (run `pip freeze` from working venv). +2. Evaluate `cloudscraper` — if only used in one fetcher, document clearly or consider removal. +3. Evaluate `playwright` — if only used by `liveuamap_scraper.py`, document and consider making it optional (it pulls ~150MB of browsers). +4. Create `backend/requirements-dev.txt` for test dependencies: `pytest`, `httpx`, `pytest-asyncio`. + +**Verification**: +```bash +pip install -r requirements.txt # In fresh venv, must succeed deterministically +pip check # Must report no conflicts +``` + +--- + +## Phase 4: Testing Infrastructure + +**Goal**: Go from zero automated tests to a meaningful suite that catches regressions. + +**Dependency**: Task 4.2 depends on Phase 2 (extracted hooks are what make frontend testing feasible). Task 4.3 depends on 4.1 and 4.2. + +--- + +### Task 4.1: Set up pytest for backend and write smoke tests + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P1 | +| **Dependencies** | None (but benefits from Task 3.1) | + +**New files**: +- `backend/tests/__init__.py` +- `backend/tests/conftest.py` — FastAPI test client fixture using `httpx.AsyncClient` +- `backend/tests/test_api_smoke.py` — smoke tests for every endpoint in `main.py` +- `backend/pytest.ini` or `pyproject.toml` pytest section +- `backend/requirements-dev.txt` — `pytest`, `httpx`, `pytest-asyncio` + +**Scope**: +1. Create proper test infrastructure with fixtures. +2. Write smoke tests: assert 200 status, valid JSON, expected top-level keys for every endpoint. +3. Archive or delete the 26 manual `test_*.py` files (move to `backend/tests/_archived/` if keeping for reference). + +**Verification**: +```bash +cd backend && pip install -r requirements-dev.txt && pytest tests/ -v +# At least 10 tests green +``` + +--- + +### Task 4.2: Set up Vitest for frontend and write component tests + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P2 | +| **Dependencies** | Phase 2 (extracted hooks/utils are what make testing feasible) | + +**New files**: +- `frontend/vitest.config.ts` +- `frontend/src/__tests__/` directory +- Tests for: utility functions (aircraftClassification, positioning), ErrorBoundary, FilterPanel, MarketsPanel + +**Scope**: +1. Install `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` as devDeps. +2. Add `"test": "vitest run"` script to `package.json`. +3. Write tests for pure utility functions first (from Phase 2 extractions). +4. Write render tests for at least 3 components. +5. Do NOT test MaplibreViewer directly (needs GL context mock). + +**Verification**: +```bash +cd frontend && npx vitest run # At least 8 tests green +``` + +--- + +### Task 4.3: Add test steps to CI pipeline + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P1 | +| **Dependencies** | Tasks 4.1, 4.2 | + +**File**: `.github/workflows/docker-publish.yml` + +**Scope**: +1. Add a `test` job that runs before build jobs. +2. Backend: `pip install -r requirements.txt -r requirements-dev.txt && pytest tests/ -v` +3. Frontend: `npm ci && npm run lint && npm run build && npx vitest run` +4. Make `build-frontend` and `build-backend` depend on `test` job. + +**Verification**: Push a branch with a failing test → CI fails and blocks Docker build. + +--- + +## Phase 5: DevOps Hardening + +**Goal**: Production-grade container config, proper `.dockerignore`, health checks, graceful shutdown. + +**All Phase 5 tasks are independent and can be executed in parallel.** + +--- + +### Task 5.1: Add Docker health checks and resource limits + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P2 | +| **Dependencies** | None | + +**File**: `docker-compose.yml` + +**Scope**: +1. Backend healthcheck: `test: ["CMD", "curl", "-f", "http://localhost:8000/api/live-data/fast"]`, interval 30s, timeout 10s, retries 3, start_period 15s. +2. Frontend healthcheck: `test: ["CMD", "curl", "-f", "http://localhost:3000/"]`, interval 30s, timeout 10s, retries 3, start_period 20s. +3. Resource limits: backend 2GB memory / 2 CPUs, frontend 512MB memory / 1 CPU. +4. Frontend `depends_on: backend: condition: service_healthy`. + +**Verification**: +```bash +docker compose up -d +docker ps # Shows health status column +# Kill backend process inside container, confirm Docker restarts it +``` + +--- + +### Task 5.2: Create .dockerignore and fix backend Dockerfile + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P2 | +| **Dependencies** | None | + +**Files**: +- New: `backend/.dockerignore` — exclude `test_*.py`, `*.json` (except `package*.json`, `news_feeds.json`), `*.html`, `*.xlsx`, debug outputs +- New: `.dockerignore` (root) — exclude `node_modules`, `.next`, `venv`, `.git`, `*.db`, `*.xlsx`, debug JSONs +- Modify: `backend/Dockerfile` — change `npm install` to `npm ci` (~line 19) + +**Verification**: +```bash +docker build ./backend # Image under 500MB +docker run --rm ls /app/ # No debug files visible +``` + +--- + +### Task 5.3: Add signal trapping for graceful shutdown in start scripts + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P2 | +| **Dependencies** | None | + +**Files**: +- `start.sh` — add `trap 'kill 0' EXIT SIGINT SIGTERM` near the top +- `start.bat` — add error checking after `call npm run dev` + +**Verification**: Start app → Ctrl+C → confirm no orphan node/python processes remain (`ps aux | grep -E "node|python"` on Unix, Task Manager on Windows). + +--- + +### Task 5.4: Clean root directory clutter and update .gitignore + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P3 | +| **Dependencies** | None | + +**Files**: `.gitignore` + root directory + +**Scope**: +1. Run `git rm --cached` on any tracked files that should be ignored: `TheAirTraffic Database.xlsx`, `zip_repo.py`, etc. +2. Add missing patterns to `.gitignore`: `*.swp`, `*.swo`, `coverage/`, `.coverage`, `dist/`, `build/`, `*.tar.gz` +3. Confirm all backend debug files (`tmp_fast.json`, `dump.json`, `debug_fast.json`, `merged.txt`) are gitignored. + +**Verification**: +```bash +git status # No large untracked files +git ls-files | xargs wc -c | sort -rn | head -20 # No file over 500KB tracked +``` + +--- + +### Task 5.5: Document Docker secrets configuration + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | XS (30min) | +| **Priority** | P3 | +| **Dependencies** | None | + +**File**: `README.md` + +**Scope**: Add a section documenting the Docker Swarm secrets support already implemented in `main.py` (lines 8-36). The `_SECRET_VARS` list supports `_FILE` suffix convention for: `AIS_API_KEY`, `OPENSKY_CLIENT_ID`, `OPENSKY_CLIENT_SECRET`, `LTA_ACCOUNT_KEY`, `CORS_ORIGINS`. Include a `docker-compose.yml` secrets example. + +**Verification**: The README section exists and matches the `_SECRET_VARS` list in `main.py`. + +--- + +## Phase 6: Long-term Quality & Accessibility + +**Goal**: Address code quality, accessibility, and developer experience improvements that compound over time. + +**Dependencies**: 6.1 depends on Phase 2. Others are independent. + +--- + +### Task 6.1: Replace inline styles with Tailwind classes + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | L (6-12h) | +| **Priority** | P3 | +| **Dependencies** | Phase 2 (much easier after component decomposition) | + +**Files**: All components in `frontend/src/components/` + +**Scope**: +1. Audit all `style={{...}}` occurrences. Heaviest offenders: MaplibreViewer.tsx, NewsFeed.tsx, FilterPanel.tsx. +2. Convert inline styles to Tailwind utility classes. +3. For dynamic values (e.g., `style={{ left: x + 'px' }}`), keep as inline but extract repeated patterns to `globals.css`: + ```css + .marker-label { @apply text-xs font-mono font-bold text-white pointer-events-none; text-shadow: 0 0 3px #000; } + .carrier-label { @apply text-xs font-mono font-bold text-amber-400 pointer-events-none; text-shadow: 0 0 3px #000; } + ``` +4. CSS variables (`var(--...)`) can stay as-is for theme integration. + +**Verification**: +```bash +grep -rn "style={{" frontend/src/components/ | wc -l # Count should decrease by 70%+ +npm run build # Must succeed +``` + +--- + +### Task 6.2: Add error boundaries to all child panels + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P2 | +| **Dependencies** | None (but cleaner after Task 2.5) | + +**Files**: +- `frontend/src/components/ErrorBoundary.tsx` (already exists, reuse it) +- `frontend/src/app/page.tsx` (or post-refactor layout component) + +**Scope**: Wrap every child panel with ``: +- FilterPanel, NewsFeed, RadioInterceptPanel, MarketsPanel +- WorldviewLeftPanel, WorldviewRightPanel +- SettingsPanel, MapLegend + +**Verification**: Add `throw new Error("test")` to MarketsPanel render → confirm error boundary catches it, other panels remain functional. Remove the throw after testing. + +--- + +### Task 6.3: Add basic accessibility (ARIA labels, keyboard navigation) + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | M (3-6h) | +| **Priority** | P3 | +| **Dependencies** | None (easier after Phase 2) | + +**Files**: All components in `frontend/src/components/` + +**Scope**: +1. `aria-label` on all buttons, toggles, inputs. +2. `role` attributes on panel containers (`role="complementary"`, `role="navigation"`). +3. `aria-pressed` on toggle buttons, `aria-expanded` on collapsible panels. +4. Keyboard handlers: Escape to close modals/panels, Enter to confirm. +5. `tabIndex` on custom interactive elements. +6. Focus management: modal open → focus modal, close → focus trigger. + +**Verification**: Run Axe accessibility browser extension on running dashboard → zero critical violations. Tab through UI → all interactive elements reachable. + +--- + +### Task 6.4: Add image scanning and SBOM generation to CI + +- [ ] **Complete** + +| Field | Value | +|-------|-------| +| **Effort** | S (1-3h) | +| **Priority** | P3 | +| **Dependencies** | Task 4.3 | + +**File**: `.github/workflows/docker-publish.yml` + +**Scope**: +1. Add Trivy scan step after Docker build: `uses: aquasecurity/trivy-action@master` with `severity: CRITICAL,HIGH`. +2. Add SBOM generation using `anchore/sbom-action`, upload as build artifact. +3. PRs: scan but don't fail. Pushes to main: scan and fail on critical. + +**Verification**: CI shows Trivy results in PR checks. Image with known CVE fails the build. + +--- + +## Dependency Graph + +``` +PHASE 1 (all parallel) + 1.1 1.2 1.3 1.4 1.5 1.6 + | + v +PHASE 2: 2.1 + 2.2 (parallel) ──> 2.3 ──> 2.4 ──> 2.5 + | +PHASE 3: 3.1 (needs 1.1) ──> 3.2 + 3.3 (parallel) + 3.4 (independent) + | +PHASE 4: 4.1 (independent) + 4.2 (needs Phase 2) ──> 4.3 + | +PHASE 5 (all parallel) + 5.1 5.2 5.3 5.4 5.5 + | +PHASE 6: 6.1 (needs Phase 2) 6.2 6.3 6.4 (needs 4.3) +``` + +--- + +## Effort Summary + +| Size | Count | Hours Each | Total Hours | +|------|-------|-----------|-------------| +| XS | 6 | 0.5-1h | 3-6h | +| S | 10 | 1-3h | 10-30h | +| M | 5 | 3-6h | 15-30h | +| L | 2 | 6-12h | 12-24h | +| **Total** | **23 tasks** | | **~40-90h** | + +--- + +## Target Scores (Post-Roadmap) + +| Category | Before | After | Delta | +|----------|--------|-------|-------| +| Thread Safety | 3/10 | 9/10 | +6 | +| Type Safety | 2/10 | 8/10 | +6 | +| Testing | 0/10 | 7/10 | +7 | +| Error Handling | 4/10 | 8/10 | +4 | +| Architecture | 3/10 | 8/10 | +5 | +| DevOps | 5/10 | 9/10 | +4 | +| Security | 4/10 | 7/10 | +3 | +| Accessibility | 1/10 | 6/10 | +5 | +| **Overall** | **3.5/10** | **8/10** | **+4.5** | diff --git a/UPDATEPROTOCOL.md b/UPDATEPROTOCOL.md new file mode 100644 index 0000000..4f1c1b6 --- /dev/null +++ b/UPDATEPROTOCOL.md @@ -0,0 +1,257 @@ +# ShadowBroker Release Protocol + +> This document exists because API keys were leaked in release zips v0.5.0, v0.6.0, and briefly v0.8.0. +> Follow this exactly. No shortcuts. + +--- + +## Pre-Release Checklist + +### 1. Bump the Version + +- **`frontend/package.json`** — update `"version"` field +- **`frontend/src/components/ChangelogModal.tsx`** — update `CURRENT_VERSION` and `STORAGE_KEY` +- **Update `NEW_FEATURES`, `BUG_FIXES`, and `CONTRIBUTORS` arrays** in the changelog modal + +### 2. Pull Remote Changes First + +```bash +git pull --rebase origin main +``` + +If there are merge conflicts, resolve them carefully. **Do not blindly delete files during rebase** — this is how the API proxy route (`frontend/src/app/api/[...path]/route.ts`) was accidentally deleted and broke the entire app. + +After resolving conflicts, verify critical files still exist: +```bash +ls frontend/src/app/api/\[...path\]/route.ts # API proxy — app is dead without this +ls backend/main.py +ls frontend/src/app/page.tsx +``` + +### 3. Test Before Committing + +```bash +# Backend +cd backend && python -c "import main; print('Backend OK')" + +# Frontend +cd frontend && npm run build +``` + +If the backend fails with a missing module, install it: +```bash +pip install -r requirements.txt +``` + +--- + +## Building the Release Zip + +### The Command + +Run from the project root (`live-risk-dashboard/`): + +```bash +7z a -tzip ../ShadowBroker_vX.Y.Z.zip \ + -xr!node_modules -xr!.next -xr!__pycache__ -xr!venv -xr!.git -xr!.git_backup \ + -xr!*.pyc -xr!*.db -xr!*.sqlite -xr!*.xlsx \ + -xr!.env -xr!.env.local -xr!.env.production -xr!.env.development \ + -xr!carrier_cache.json -xr!ais_cache.json \ + -xr!tmp_fast.json -xr!dump.json -xr!debug_fast.json \ + -xr!nyc_sample.json -xr!nyc_full.json \ + -xr!server_logs.txt -xr!server_logs2.txt -xr!xlsx_analysis.txt -xr!liveua_test.html \ + -xr!merged.txt -xr!recent_commits.txt \ + -xr!build_error.txt -xr!build_logs*.txt -xr!build_output.txt -xr!errors.txt \ + -xr!geocode_log.txt -xr!tsconfig.tsbuildinfo \ + -xr!ShadowBroker_v*.zip \ + . +``` + +### Critical Exclusions (NEVER ship these) + +| Pattern | Why | +|---------|-----| +| `.env` | **Contains real API keys** (OpenSky, AIS Stream) | +| `.env.local` | **Contains real API keys** (TomTom, etc.) | +| `.env.production` / `.env.development` | May contain secrets | +| `carrier_cache.json` / `ais_cache.json` | Runtime cache, not source | +| `node_modules/` / `__pycache__/` / `.next/` | Build artifacts | +| `*.db` / `*.sqlite` / `*.xlsx` | Data files, not source | +| `ShadowBroker_v*.zip` | Previous release zips sitting in the project dir | + +### What SHOULD Be in the Zip + +| File | Required | +|------|----------| +| `frontend/src/app/api/[...path]/route.ts` | **YES** — API proxy, app is dead without it | +| `backend/.env.example` | YES — template for users | +| `.env.example` | YES — template for users | +| `backend/data/plane_alert_db.json` | YES — aircraft database | +| `backend/data/datacenters*.json` | YES — data center layer | +| `backend/data/tracked_names.json` | YES — tracked aircraft names | +| `frontend/src/lib/airlines.json` | YES — airline codes | +| `start.bat` / `start.sh` | YES — launcher scripts | + +### Do NOT Use + +- **`git archive`** — includes tracked junk, misses untracked essential files +- **`Compress-Archive` (PowerShell)** — has lock file issues, no exclusion control +- **Gemini's zip script** — included test files, debug outputs, `.env` with real keys, and 30+ unnecessary files + +--- + +## Post-Build Audit (MANDATORY) + +**Before uploading, always scan the zip for leaks:** + +```bash +# Check for .env files (should only show .env.example files) +7z l ShadowBroker_vX.Y.Z.zip | grep -i "\.env" | grep "....A" + +# Check for anything with "secret", "key", "token", "credential" in the filename +7z l ShadowBroker_vX.Y.Z.zip | grep -iE "secret|api.key|credential|token" | grep "....A" + +# Check the largest files (look for unexpected blobs) +7z l ShadowBroker_vX.Y.Z.zip | grep "....A" | awk '{print $4, $NF}' | sort -rn | head -15 + +# Verify the API proxy route exists +7z l ShadowBroker_vX.Y.Z.zip | grep "route.ts" +``` + +**Expected results:** +- `.env` files: ONLY `.env.example` and `next-env.d.ts` +- No files with "secret"/"credential" in the name +- Largest files: `plane_alert_db.json` (~4.6MB), `datacenters_geocoded.json` (~1.2MB), `airlines.json` (~800KB) +- `route.ts` exists under `frontend/src/app/api/[...path]/` +- **Total zip size: ~1.7MB** (as of v0.8.0). If it's 5MB+ something leaked. + +--- + +## Commit, Tag, and Push + +```bash +# Stage specific files (NEVER use git add -A) +git add + +# Commit +git commit -m "v0.X.0: brief description of release" + +# Tag +git tag v0.X.0 + +# Push (pull first if remote has new commits) +git pull --rebase origin main +git push origin main --tags + +# If the tag was created before rebase, re-tag on the new HEAD: +git tag -f v0.X.0 +git push origin v0.X.0 --force +``` + +--- + +## Creating the GitHub Release + +### Via GitHub API (when `gh` CLI is unavailable) + +```python +# 1. Create the release +import urllib.request, json + +body = { + "tag_name": "v0.X.0", + "name": "v0.X.0 — Title Here", + "body": "Release notes here...", + "draft": False, + "prerelease": False +} + +# Write to a temp file to avoid JSON escaping hell in bash +with open("release_body.json", "w") as f: + json.dump(body, f) + +# POST to GitHub API... + +# 2. Upload the zip asset to the release +# Use the upload_url from the release response +``` + +### Via `gh` CLI (if installed) + +```bash +gh release create v0.X.0 ../ShadowBroker_v0.X.0.zip \ + --title "v0.X.0 — Title" \ + --notes-file RELEASE_NOTES.md +``` + +--- + +## Post-Release Verification + +After uploading, download the release zip from GitHub and verify it: + +```bash +# Download what GitHub is actually serving +curl -L -o /tmp/verify.zip "https://github.com/BigBodyCobain/Shadowbroker/releases/download/v0.X.0/ShadowBroker_v0.X.0.zip" + +# Scan for leaks (same audit as above) +7z l /tmp/verify.zip | grep -i "\.env" | grep "....A" + +# Compare hash to your local copy +md5sum /tmp/verify.zip ../ShadowBroker_v0.X.0.zip +``` + +--- + +## If You Discover a Leak + +### Immediate Actions + +1. **Rebuild the zip** without the leaked file +2. **Delete the old asset** from the GitHub release via API +3. **Upload the clean zip** as a replacement +4. **Rotate ALL leaked keys immediately:** + - OpenSky: https://opensky-network.org/ + - AIS Stream: https://aisstream.io/ + - Any other keys found in the leak +5. **Audit ALL other releases** — leaks tend to exist in multiple versions + +### Audit All Releases Script + +```python +import urllib.request, json + +TOKEN = "your_token" +headers = {"Authorization": f"token {TOKEN}", "Accept": "application/vnd.github+json"} + +# Get all releases +req = urllib.request.Request( + "https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases", + headers=headers +) +releases = json.loads(urllib.request.urlopen(req).read()) + +for r in releases: + for asset in r.get("assets", []): + # Download via API + req2 = urllib.request.Request( + asset["url"], + headers={**headers, "Accept": "application/octet-stream"} + ) + data = urllib.request.urlopen(req2).read() + filename = f"/tmp/{r['tag_name']}.zip" + with open(filename, "wb") as f: + f.write(data) + print(f"Downloaded {r['tag_name']}: {len(data)} bytes") + # Then run 7z l on each to check for .env files +``` + +--- + +## Lessons Learned (v0.8.0 Incident) + +1. **Rebasing can silently delete files.** After `git pull --rebase`, always verify that critical files like the API proxy route still exist. +2. **The zip command must explicitly exclude `.env` and `.env.local`.** These files are not in `.gitignore` patterns that 7z understands — you must pass `-xr!.env -xr!.env.local` every time. +3. **Always audit the zip before uploading.** A 10-second grep saves a key rotation. +4. **Never trust another tool's zip output.** Gemini's zip included `.env` with real keys, 30+ test files, debug outputs, and sample JSON dumps. +5. **2,000+ stars means 2,000+ potential eyes on every release.** Treat every zip as if it will be decompiled line by line. diff --git a/backend/.dockerignore b/backend/.dockerignore index 6e46f16..10dcee0 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -4,13 +4,29 @@ __pycache__/ .env .pytest_cache/ .coverage +.git/ +node_modules/ cctv.db +*.sqlite +*.db + +# Debug/log files *.txt !requirements.txt -# Exclude debug/cache JSON but keep package.json and tracked_names +!requirements-dev.txt +*.html +*.xlsx + +# Debug/cache JSON (keep package*.json and data files) ais_cache.json +carrier_cache.json carrier_positions.json dump.json debug_fast.json nyc_full.json nyc_sample.json +tmp_fast.json + +# Test files (not needed in production image) +test_*.py +tests/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 8616154..1fad94c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,12 +11,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt \ + && playwright install --with-deps chromium # Install Node.js dependencies (ws module for AIS WebSocket proxy) # Copy manifests first so this layer is cached unless deps change COPY package*.json ./ -RUN npm install --omit=dev +RUN npm ci --omit=dev # Copy source code COPY . . diff --git a/backend/check_regions.py b/backend/check_regions.py deleted file mode 100644 index ddf6a89..0000000 --- a/backend/check_regions.py +++ /dev/null @@ -1,17 +0,0 @@ -import requests - -regions = [ - {"lat": 39.8, "lon": -98.5, "dist": 2000}, # USA - {"lat": 50.0, "lon": 15.0, "dist": 2000}, # Europe - {"lat": 35.0, "lon": 105.0, "dist": 2000} # Asia / China -] - -for r in regions: - url = f"https://api.adsb.lol/v2/lat/{r['lat']}/lon/{r['lon']}/dist/{r['dist']}" - res = requests.get(url, timeout=10) - if res.status_code == 200: - data = res.json() - acs = data.get("ac", []) - print(f"Region lat:{r['lat']} lon:{r['lon']} dist:{r['dist']} -> Flights: {len(acs)}") - else: - print(f"Error for Region lat:{r['lat']} lon:{r['lon']}: HTTP {res.status_code}") diff --git a/backend/clean_osm_cctvs.py b/backend/clean_osm_cctvs.py deleted file mode 100644 index 17bd77c..0000000 --- a/backend/clean_osm_cctvs.py +++ /dev/null @@ -1,10 +0,0 @@ -import sqlite3 -import os - -db_path = os.path.join(os.path.dirname(__file__), 'cctv.db') -conn = sqlite3.connect(db_path) -cur = conn.cursor() -cur.execute("DELETE FROM cameras WHERE id LIKE 'OSM-%'") -print(f"Deleted {cur.rowcount} OSM cameras from DB.") -conn.commit() -conn.close() diff --git a/backend/data/sat_gp_cache.json b/backend/data/sat_gp_cache.json index 7fe0d81..df757ab 100644 --- a/backend/data/sat_gp_cache.json +++ b/backend/data/sat_gp_cache.json @@ -1 +1 @@ -[{"OBJECT_NAME": "LUCH 5A (SDCM/PRN 140)", "NORAD_CAT_ID": 37951, "MEAN_MOTION": 1.00268917, "ECCENTRICITY": 0.0003469, "INCLINATION": 8.5056, "RA_OF_ASC_NODE": 75.2345, "ARG_OF_PERICENTER": 261.676, "MEAN_ANOMALY": 295.0489, "BSTAR": 0.0, "EPOCH": "2026-03-11T19:41:25"}, {"OBJECT_NAME": "LUCH 5B (SDCM/PRN 125)", "NORAD_CAT_ID": 38977, "MEAN_MOTION": 1.00272061, "ECCENTRICITY": 0.0003301, "INCLINATION": 10.2747, "RA_OF_ASC_NODE": 50.8258, "ARG_OF_PERICENTER": 227.837, "MEAN_ANOMALY": 311.7381, "BSTAR": 0.0, "EPOCH": "2026-03-12T05:07:25"}, {"OBJECT_NAME": "LUCH 5V (SDCM/PRN 141)", "NORAD_CAT_ID": 39727, "MEAN_MOTION": 1.00269843, "ECCENTRICITY": 0.0003131, "INCLINATION": 4.8928, "RA_OF_ASC_NODE": 70.6943, "ARG_OF_PERICENTER": 295.3056, "MEAN_ANOMALY": 265.5873, "BSTAR": 0.0, "EPOCH": "2026-03-12T00:28:51"}, {"OBJECT_NAME": "LUCH (OLYMP-K 1)", "NORAD_CAT_ID": 40258, "MEAN_MOTION": 0.99116915, "ECCENTRICITY": 0.0003121, "INCLINATION": 1.4978, "RA_OF_ASC_NODE": 84.5508, "ARG_OF_PERICENTER": 187.5711, "MEAN_ANOMALY": 175.9774, "BSTAR": 0.0, "EPOCH": "2026-01-14T19:27:12"}, {"OBJECT_NAME": "LUCH DEB", "NORAD_CAT_ID": 44582, "MEAN_MOTION": 1.0070133, "ECCENTRICITY": 0.0016946, "INCLINATION": 14.6049, "RA_OF_ASC_NODE": 354.7884, "ARG_OF_PERICENTER": 88.0181, "MEAN_ANOMALY": 122.0268, "BSTAR": 0.0, "EPOCH": "2026-03-11T20:18:30"}, {"OBJECT_NAME": "LUCH-5X (OLYMP-K 2)", "NORAD_CAT_ID": 55841, "MEAN_MOTION": 1.00272211, "ECCENTRICITY": 0.0001708, "INCLINATION": 0.024, "RA_OF_ASC_NODE": 93.4971, "ARG_OF_PERICENTER": 122.5975, "MEAN_ANOMALY": 280.2686, "BSTAR": 0.0, "EPOCH": "2026-03-11T17:39:59"}, {"OBJECT_NAME": "LUCH (OLYMP-K 1) DEB", "NORAD_CAT_ID": 67745, "MEAN_MOTION": 0.97993456, "ECCENTRICITY": 0.0520769, "INCLINATION": 1.5795, "RA_OF_ASC_NODE": 85.6956, "ARG_OF_PERICENTER": 329.0115, "MEAN_ANOMALY": 28.4425, "BSTAR": 0.0, "EPOCH": "2026-02-26T11:11:16"}, {"OBJECT_NAME": "LUCH", "NORAD_CAT_ID": 23426, "MEAN_MOTION": 1.00183659, "ECCENTRICITY": 0.0004741, "INCLINATION": 14.7547, "RA_OF_ASC_NODE": 355.3521, "ARG_OF_PERICENTER": 225.8969, "MEAN_ANOMALY": 134.0694, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:42:18"}, {"OBJECT_NAME": "LUCH-1", "NORAD_CAT_ID": 23680, "MEAN_MOTION": 1.0026691, "ECCENTRICITY": 0.0004391, "INCLINATION": 15.0214, "RA_OF_ASC_NODE": 0.0177, "ARG_OF_PERICENTER": 148.464, "MEAN_ANOMALY": 54.6553, "BSTAR": 0.0, "EPOCH": "2026-03-11T21:11:18"}, {"OBJECT_NAME": "GSAT0219 (GALILEO 23)", "NORAD_CAT_ID": 43566, "MEAN_MOTION": 1.70474822, "ECCENTRICITY": 0.0004318, "INCLINATION": 57.205, "RA_OF_ASC_NODE": 344.5202, "ARG_OF_PERICENTER": 344.0327, "MEAN_ANOMALY": 15.9782, "BSTAR": 0.0, "EPOCH": "2026-03-08T00:37:58"}, {"OBJECT_NAME": "GSAT0207 (GALILEO 15)", "NORAD_CAT_ID": 41859, "MEAN_MOTION": 1.70474556, "ECCENTRICITY": 0.0005331, "INCLINATION": 55.4345, "RA_OF_ASC_NODE": 103.7754, "ARG_OF_PERICENTER": 309.7356, "MEAN_ANOMALY": 50.2853, "BSTAR": 0.0, "EPOCH": "2026-03-11T23:01:07"}, {"OBJECT_NAME": "GSAT0103 (GALILEO-FM3)", "NORAD_CAT_ID": 38857, "MEAN_MOTION": 1.70473527, "ECCENTRICITY": 0.000557, "INCLINATION": 55.7441, "RA_OF_ASC_NODE": 104.2408, "ARG_OF_PERICENTER": 286.1218, "MEAN_ANOMALY": 73.8831, "BSTAR": 0.0, "EPOCH": "2026-03-10T22:17:59"}, {"OBJECT_NAME": "GSAT0222 (GALILEO 26)", "NORAD_CAT_ID": 43565, "MEAN_MOTION": 1.70475258, "ECCENTRICITY": 0.0004084, "INCLINATION": 57.206, "RA_OF_ASC_NODE": 344.5721, "ARG_OF_PERICENTER": 287.9587, "MEAN_ANOMALY": 72.0183, "BSTAR": 0.0, "EPOCH": "2026-03-06T01:05:30"}, {"OBJECT_NAME": "GSAT0204 (GALILEO 8)", "NORAD_CAT_ID": 40545, "MEAN_MOTION": 1.70475628, "ECCENTRICITY": 0.0004133, "INCLINATION": 56.8258, "RA_OF_ASC_NODE": 344.3432, "ARG_OF_PERICENTER": 288.3156, "MEAN_ANOMALY": 71.6649, "BSTAR": 0.0, "EPOCH": "2026-03-09T03:54:07"}, {"OBJECT_NAME": "GSAT0205 (GALILEO 9)", "NORAD_CAT_ID": 40889, "MEAN_MOTION": 1.674108, "ECCENTRICITY": 0.0359599, "INCLINATION": 53.6403, "RA_OF_ASC_NODE": 225.6057, "ARG_OF_PERICENTER": 59.9658, "MEAN_ANOMALY": 303.5577, "BSTAR": 0.0, "EPOCH": "2026-02-12T22:54:15"}, {"OBJECT_NAME": "GSAT0220 (GALILEO 24)", "NORAD_CAT_ID": 43567, "MEAN_MOTION": 1.70475159, "ECCENTRICITY": 0.0005301, "INCLINATION": 57.2075, "RA_OF_ASC_NODE": 344.7516, "ARG_OF_PERICENTER": 308.5821, "MEAN_ANOMALY": 51.3768, "BSTAR": 0.0, "EPOCH": "2026-02-27T10:45:41"}, {"OBJECT_NAME": "GSAT0206 (GALILEO 10)", "NORAD_CAT_ID": 40890, "MEAN_MOTION": 1.70473808, "ECCENTRICITY": 0.0003567, "INCLINATION": 54.9789, "RA_OF_ASC_NODE": 224.2989, "ARG_OF_PERICENTER": 50.0728, "MEAN_ANOMALY": 309.9176, "BSTAR": 0.0, "EPOCH": "2026-03-11T11:56:44"}, {"OBJECT_NAME": "GSAT0223 (GALILEO 27)", "NORAD_CAT_ID": 49809, "MEAN_MOTION": 1.70475508, "ECCENTRICITY": 0.0002865, "INCLINATION": 57.2005, "RA_OF_ASC_NODE": 344.9238, "ARG_OF_PERICENTER": 278.195, "MEAN_ANOMALY": 264.6358, "BSTAR": 0.0, "EPOCH": "2026-02-17T00:57:26"}, {"OBJECT_NAME": "GSAT0209 (GALILEO 12)", "NORAD_CAT_ID": 41174, "MEAN_MOTION": 1.70474574, "ECCENTRICITY": 0.0004961, "INCLINATION": 55.7642, "RA_OF_ASC_NODE": 103.9358, "ARG_OF_PERICENTER": 322.9972, "MEAN_ANOMALY": 37.0359, "BSTAR": 0.0, "EPOCH": "2026-03-11T15:58:49"}, {"OBJECT_NAME": "GSAT0224 (GALILEO 28)", "NORAD_CAT_ID": 49810, "MEAN_MOTION": 1.7047576, "ECCENTRICITY": 0.0001942, "INCLINATION": 57.1948, "RA_OF_ASC_NODE": 344.4822, "ARG_OF_PERICENTER": 295.2728, "MEAN_ANOMALY": 253.6293, "BSTAR": 0.0, "EPOCH": "2026-03-05T06:58:38"}, {"OBJECT_NAME": "GSAT0208 (GALILEO 11)", "NORAD_CAT_ID": 41175, "MEAN_MOTION": 1.70474461, "ECCENTRICITY": 0.0004348, "INCLINATION": 55.7615, "RA_OF_ASC_NODE": 103.9751, "ARG_OF_PERICENTER": 323.6585, "MEAN_ANOMALY": 36.3777, "BSTAR": 0.0, "EPOCH": "2026-03-10T03:01:54"}, {"OBJECT_NAME": "GSAT0211 (GALILEO 14)", "NORAD_CAT_ID": 41549, "MEAN_MOTION": 1.70473955, "ECCENTRICITY": 0.0003471, "INCLINATION": 55.1288, "RA_OF_ASC_NODE": 224.3645, "ARG_OF_PERICENTER": 23.7611, "MEAN_ANOMALY": 336.2141, "BSTAR": 0.0, "EPOCH": "2026-03-12T00:14:31"}, {"OBJECT_NAME": "GSAT0225 (GALILEO 29)", "NORAD_CAT_ID": 59598, "MEAN_MOTION": 1.70475084, "ECCENTRICITY": 0.0002777, "INCLINATION": 55.0541, "RA_OF_ASC_NODE": 103.8268, "ARG_OF_PERICENTER": 260.3454, "MEAN_ANOMALY": 98.4267, "BSTAR": 0.0, "EPOCH": "2026-03-10T20:35:50"}, {"OBJECT_NAME": "GSAT0210 (GALILEO 13)", "NORAD_CAT_ID": 41550, "MEAN_MOTION": 1.70474, "ECCENTRICITY": 0.000138, "INCLINATION": 55.1262, "RA_OF_ASC_NODE": 224.3718, "ARG_OF_PERICENTER": 200.1148, "MEAN_ANOMALY": 159.839, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:20:19"}, {"OBJECT_NAME": "GSAT0227 (GALILEO 30)", "NORAD_CAT_ID": 59600, "MEAN_MOTION": 1.70474936, "ECCENTRICITY": 3.38e-05, "INCLINATION": 55.0487, "RA_OF_ASC_NODE": 103.8366, "ARG_OF_PERICENTER": 50.1046, "MEAN_ANOMALY": 309.9638, "BSTAR": 0.0, "EPOCH": "2026-03-10T10:58:17"}, {"OBJECT_NAME": "GSAT0232 (GALILEO 32)", "NORAD_CAT_ID": 61182, "MEAN_MOTION": 1.70443939, "ECCENTRICITY": 0.00036, "INCLINATION": 55.2225, "RA_OF_ASC_NODE": 224.5881, "ARG_OF_PERICENTER": 296.6314, "MEAN_ANOMALY": 116.1306, "BSTAR": 0.0, "EPOCH": "2026-02-17T15:52:34"}, {"OBJECT_NAME": "GSAT0212 (GALILEO 16)", "NORAD_CAT_ID": 41860, "MEAN_MOTION": 1.70474686, "ECCENTRICITY": 0.0003782, "INCLINATION": 55.4302, "RA_OF_ASC_NODE": 103.856, "ARG_OF_PERICENTER": 335.1738, "MEAN_ANOMALY": 24.8752, "BSTAR": 0.0, "EPOCH": "2026-03-08T21:06:50"}, {"OBJECT_NAME": "GSAT0226 (GALILEO 31)", "NORAD_CAT_ID": 61183, "MEAN_MOTION": 1.70474386, "ECCENTRICITY": 0.0001535, "INCLINATION": 55.2162, "RA_OF_ASC_NODE": 224.0211, "ARG_OF_PERICENTER": 134.0581, "MEAN_ANOMALY": 289.7766, "BSTAR": 0.0, "EPOCH": "2026-03-10T15:32:32"}, {"OBJECT_NAME": "GSAT0213 (GALILEO 17)", "NORAD_CAT_ID": 41861, "MEAN_MOTION": 1.70475014, "ECCENTRICITY": 0.0005437, "INCLINATION": 55.4354, "RA_OF_ASC_NODE": 103.7885, "ARG_OF_PERICENTER": 293.5263, "MEAN_ANOMALY": 66.4835, "BSTAR": 0.0, "EPOCH": "2026-03-11T14:14:38"}, {"OBJECT_NAME": "BEIDOU-2 M4 (C12)", "NORAD_CAT_ID": 38251, "MEAN_MOTION": 1.86229837, "ECCENTRICITY": 0.0012233, "INCLINATION": 55.7415, "RA_OF_ASC_NODE": 307.3942, "ARG_OF_PERICENTER": 287.3312, "MEAN_ANOMALY": 72.5873, "BSTAR": 0.0, "EPOCH": "2026-03-11T09:28:12"}, {"OBJECT_NAME": "BEIDOU-2 M1", "NORAD_CAT_ID": 31115, "MEAN_MOTION": 1.77349392, "ECCENTRICITY": 0.0002326, "INCLINATION": 50.9679, "RA_OF_ASC_NODE": 222.5681, "ARG_OF_PERICENTER": 33.8688, "MEAN_ANOMALY": 326.1041, "BSTAR": 0.0, "EPOCH": "2026-03-10T03:28:02"}, {"OBJECT_NAME": "BEIDOU-3 M24 (C46)", "NORAD_CAT_ID": 44542, "MEAN_MOTION": 1.86229065, "ECCENTRICITY": 0.0008558, "INCLINATION": 54.3987, "RA_OF_ASC_NODE": 186.0936, "ARG_OF_PERICENTER": 19.7869, "MEAN_ANOMALY": 340.233, "BSTAR": 0.0, "EPOCH": "2026-03-09T08:50:58"}, {"OBJECT_NAME": "BEIDOU-3 M10 (C30)", "NORAD_CAT_ID": 43246, "MEAN_MOTION": 1.86229238, "ECCENTRICITY": 0.0005206, "INCLINATION": 54.2796, "RA_OF_ASC_NODE": 303.4048, "ARG_OF_PERICENTER": 25.3248, "MEAN_ANOMALY": 334.7506, "BSTAR": 0.0, "EPOCH": "2026-03-11T03:46:01"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-7 (C16)", "NORAD_CAT_ID": 43539, "MEAN_MOTION": 1.00281075, "ECCENTRICITY": 0.0100539, "INCLINATION": 55.1863, "RA_OF_ASC_NODE": 163.9124, "ARG_OF_PERICENTER": 236.8206, "MEAN_ANOMALY": 121.4718, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:00:50"}, {"OBJECT_NAME": "BEIDOU-2 M6 (C14)", "NORAD_CAT_ID": 38775, "MEAN_MOTION": 1.86231059, "ECCENTRICITY": 0.0014107, "INCLINATION": 56.4604, "RA_OF_ASC_NODE": 66.1002, "ARG_OF_PERICENTER": 342.3947, "MEAN_ANOMALY": 17.5764, "BSTAR": 0.0, "EPOCH": "2026-03-12T04:17:16"}, {"OBJECT_NAME": "BEIDOU-3 M23 (C45)", "NORAD_CAT_ID": 44543, "MEAN_MOTION": 1.86227389, "ECCENTRICITY": 0.0004976, "INCLINATION": 54.3975, "RA_OF_ASC_NODE": 185.9688, "ARG_OF_PERICENTER": 10.6705, "MEAN_ANOMALY": 318.8703, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:20:09"}, {"OBJECT_NAME": "BEIDOU-3 M5 (C23)", "NORAD_CAT_ID": 43581, "MEAN_MOTION": 1.8622883, "ECCENTRICITY": 0.0002391, "INCLINATION": 54.0879, "RA_OF_ASC_NODE": 185.6474, "ARG_OF_PERICENTER": 304.9751, "MEAN_ANOMALY": 54.9906, "BSTAR": 0.0, "EPOCH": "2026-03-10T20:15:34"}, {"OBJECT_NAME": "BEIDOU-2 G6 (C02)", "NORAD_CAT_ID": 38953, "MEAN_MOTION": 1.00275207, "ECCENTRICITY": 0.0010487, "INCLINATION": 4.174, "RA_OF_ASC_NODE": 74.5597, "ARG_OF_PERICENTER": 306.8178, "MEAN_ANOMALY": 264.3824, "BSTAR": 0.0, "EPOCH": "2026-03-12T02:08:27"}, {"OBJECT_NAME": "CHINASAT 31 (BEIDOU-1 *)", "NORAD_CAT_ID": 26643, "MEAN_MOTION": 0.99256401, "ECCENTRICITY": 0.0077055, "INCLINATION": 12.453, "RA_OF_ASC_NODE": 28.0377, "ARG_OF_PERICENTER": 84.8081, "MEAN_ANOMALY": 92.3993, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:23:05"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-3 (C40)", "NORAD_CAT_ID": 44709, "MEAN_MOTION": 1.00267242, "ECCENTRICITY": 0.0040728, "INCLINATION": 54.9673, "RA_OF_ASC_NODE": 281.9576, "ARG_OF_PERICENTER": 188.7089, "MEAN_ANOMALY": 171.2538, "BSTAR": 0.0, "EPOCH": "2026-03-10T23:36:11"}, {"OBJECT_NAME": "BEIDOU-3 M6 (C24)", "NORAD_CAT_ID": 43582, "MEAN_MOTION": 1.86227773, "ECCENTRICITY": 0.0006052, "INCLINATION": 54.0876, "RA_OF_ASC_NODE": 185.6958, "ARG_OF_PERICENTER": 42.132, "MEAN_ANOMALY": 317.9013, "BSTAR": 0.0, "EPOCH": "2026-03-09T02:18:33"}, {"OBJECT_NAME": "BEIDOU-3S IGSO-1S (C31)", "NORAD_CAT_ID": 40549, "MEAN_MOTION": 1.00272878, "ECCENTRICITY": 0.0035532, "INCLINATION": 49.3768, "RA_OF_ASC_NODE": 296.637, "ARG_OF_PERICENTER": 190.3587, "MEAN_ANOMALY": 351.9385, "BSTAR": 0.0, "EPOCH": "2026-03-11T14:13:00"}, {"OBJECT_NAME": "BEIDOU-1 G3", "NORAD_CAT_ID": 27813, "MEAN_MOTION": 0.99765647, "ECCENTRICITY": 0.0019492, "INCLINATION": 11.1908, "RA_OF_ASC_NODE": 42.0457, "ARG_OF_PERICENTER": 301.5103, "MEAN_ANOMALY": 248.6131, "BSTAR": 0.0, "EPOCH": "2026-03-11T23:22:21"}, {"OBJECT_NAME": "BEIDOU-3 M22 (C44)", "NORAD_CAT_ID": 44793, "MEAN_MOTION": 1.86232103, "ECCENTRICITY": 0.0007399, "INCLINATION": 54.0269, "RA_OF_ASC_NODE": 303.384, "ARG_OF_PERICENTER": 37.3018, "MEAN_ANOMALY": 322.7988, "BSTAR": 0.0, "EPOCH": "2026-03-08T03:13:04"}, {"OBJECT_NAME": "BEIDOU-3 M12 (C26)", "NORAD_CAT_ID": 43602, "MEAN_MOTION": 1.86227861, "ECCENTRICITY": 0.000842, "INCLINATION": 54.1819, "RA_OF_ASC_NODE": 184.4184, "ARG_OF_PERICENTER": 39.2157, "MEAN_ANOMALY": 320.8348, "BSTAR": 0.0, "EPOCH": "2026-03-09T13:43:06"}, {"OBJECT_NAME": "BEIDOU-3S M2S (C58)", "NORAD_CAT_ID": 40748, "MEAN_MOTION": 1.86232227, "ECCENTRICITY": 0.0008633, "INCLINATION": 54.9571, "RA_OF_ASC_NODE": 305.4255, "ARG_OF_PERICENTER": 278.4244, "MEAN_ANOMALY": 81.5286, "BSTAR": 0.0, "EPOCH": "2026-03-11T01:28:40"}, {"OBJECT_NAME": "BEIDOU-1 G4", "NORAD_CAT_ID": 30323, "MEAN_MOTION": 0.9915875, "ECCENTRICITY": 0.0057083, "INCLINATION": 9.6649, "RA_OF_ASC_NODE": 57.9748, "ARG_OF_PERICENTER": 270.9959, "MEAN_ANOMALY": 99.7622, "BSTAR": 0.0, "EPOCH": "2026-03-11T18:47:26"}, {"OBJECT_NAME": "BEIDOU-3 M21 (C43)", "NORAD_CAT_ID": 44794, "MEAN_MOTION": 1.86227737, "ECCENTRICITY": 0.0005298, "INCLINATION": 54.0009, "RA_OF_ASC_NODE": 303.3612, "ARG_OF_PERICENTER": 31.2786, "MEAN_ANOMALY": 328.8028, "BSTAR": 0.0, "EPOCH": "2026-03-07T17:40:54"}, {"OBJECT_NAME": "BEIDOU-3 M11 (C25)", "NORAD_CAT_ID": 43603, "MEAN_MOTION": 1.86227327, "ECCENTRICITY": 0.0004937, "INCLINATION": 54.1793, "RA_OF_ASC_NODE": 184.3408, "ARG_OF_PERICENTER": 32.4409, "MEAN_ANOMALY": 327.5795, "BSTAR": 0.0, "EPOCH": "2026-03-10T18:43:37"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-1 (C06)", "NORAD_CAT_ID": 36828, "MEAN_MOTION": 1.00250567, "ECCENTRICITY": 0.0054287, "INCLINATION": 54.2928, "RA_OF_ASC_NODE": 163.8447, "ARG_OF_PERICENTER": 219.289, "MEAN_ANOMALY": 157.8366, "BSTAR": 0.0, "EPOCH": "2026-03-11T17:48:22"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-2 (C07)", "NORAD_CAT_ID": 37256, "MEAN_MOTION": 1.00257833, "ECCENTRICITY": 0.004749, "INCLINATION": 47.7215, "RA_OF_ASC_NODE": 273.0501, "ARG_OF_PERICENTER": 213.2827, "MEAN_ANOMALY": 326.5355, "BSTAR": 0.0, "EPOCH": "2026-03-10T11:51:55"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-3 (C08)", "NORAD_CAT_ID": 37384, "MEAN_MOTION": 1.00293315, "ECCENTRICITY": 0.0035062, "INCLINATION": 62.2726, "RA_OF_ASC_NODE": 41.1265, "ARG_OF_PERICENTER": 190.7769, "MEAN_ANOMALY": 297.326, "BSTAR": 0.0, "EPOCH": "2026-03-05T17:18:14"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-4 (C09)", "NORAD_CAT_ID": 37763, "MEAN_MOTION": 1.00264489, "ECCENTRICITY": 0.0155038, "INCLINATION": 54.5893, "RA_OF_ASC_NODE": 166.5556, "ARG_OF_PERICENTER": 231.0545, "MEAN_ANOMALY": 122.5808, "BSTAR": 0.0, "EPOCH": "2026-03-11T17:06:29"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-5 (C10)", "NORAD_CAT_ID": 37948, "MEAN_MOTION": 1.0027123, "ECCENTRICITY": 0.0108779, "INCLINATION": 47.8556, "RA_OF_ASC_NODE": 272.7511, "ARG_OF_PERICENTER": 221.1293, "MEAN_ANOMALY": 317.8974, "BSTAR": 0.0, "EPOCH": "2026-03-11T12:30:03"}, {"OBJECT_NAME": "BEIDOU-3S IGSO-2S (C56)", "NORAD_CAT_ID": 40938, "MEAN_MOTION": 1.00254296, "ECCENTRICITY": 0.0061515, "INCLINATION": 49.4373, "RA_OF_ASC_NODE": 260.4556, "ARG_OF_PERICENTER": 187.1638, "MEAN_ANOMALY": 17.4903, "BSTAR": 0.0, "EPOCH": "2026-01-27T16:14:57"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-6 (C13)", "NORAD_CAT_ID": 41434, "MEAN_MOTION": 1.00277411, "ECCENTRICITY": 0.0059566, "INCLINATION": 60.0969, "RA_OF_ASC_NODE": 39.005, "ARG_OF_PERICENTER": 233.2714, "MEAN_ANOMALY": 308.7278, "BSTAR": 0.0, "EPOCH": "2026-03-11T21:02:52"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-1 (C38)", "NORAD_CAT_ID": 44204, "MEAN_MOTION": 1.00269078, "ECCENTRICITY": 0.0025765, "INCLINATION": 58.5642, "RA_OF_ASC_NODE": 38.6507, "ARG_OF_PERICENTER": 236.085, "MEAN_ANOMALY": 305.5935, "BSTAR": 0.0, "EPOCH": "2026-03-11T19:27:27"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-2 (C39)", "NORAD_CAT_ID": 44337, "MEAN_MOTION": 1.00247364, "ECCENTRICITY": 0.0038734, "INCLINATION": 55.2338, "RA_OF_ASC_NODE": 159.8353, "ARG_OF_PERICENTER": 206.4752, "MEAN_ANOMALY": 153.7223, "BSTAR": 0.0, "EPOCH": "2026-02-26T16:31:51"}, {"OBJECT_NAME": "SKYSAT-C17", "NORAD_CAT_ID": 46179, "MEAN_MOTION": 15.7911991, "ECCENTRICITY": 0.0006126, "INCLINATION": 52.97, "RA_OF_ASC_NODE": 214.8332, "ARG_OF_PERICENTER": 89.9494, "MEAN_ANOMALY": 270.2227, "BSTAR": 0.00064037, "EPOCH": "2023-12-27T20:37:45"}, {"OBJECT_NAME": "SKYSAT-C18", "NORAD_CAT_ID": 46180, "MEAN_MOTION": 16.35747614, "ECCENTRICITY": 0.0017318, "INCLINATION": 52.9561, "RA_OF_ASC_NODE": 11.8431, "ARG_OF_PERICENTER": 265.4879, "MEAN_ANOMALY": 104.1762, "BSTAR": 0.0015942999999999999, "EPOCH": "2023-06-25T20:09:47"}, {"OBJECT_NAME": "SKYSAT-C19", "NORAD_CAT_ID": 46235, "MEAN_MOTION": 15.89514628, "ECCENTRICITY": 0.0002149, "INCLINATION": 52.9675, "RA_OF_ASC_NODE": 201.9813, "ARG_OF_PERICENTER": 132.3648, "MEAN_ANOMALY": 227.7558, "BSTAR": 0.0007014899999999999, "EPOCH": "2023-12-27T23:17:19"}, {"OBJECT_NAME": "SKYSAT-C14", "NORAD_CAT_ID": 45788, "MEAN_MOTION": 15.53833173, "ECCENTRICITY": 0.001113, "INCLINATION": 52.9777, "RA_OF_ASC_NODE": 149.837, "ARG_OF_PERICENTER": 213.0476, "MEAN_ANOMALY": 146.9838, "BSTAR": 0.00062044, "EPOCH": "2023-12-27T15:51:00"}, {"OBJECT_NAME": "SKYSAT-C16", "NORAD_CAT_ID": 45789, "MEAN_MOTION": 15.53303284, "ECCENTRICITY": 0.000551, "INCLINATION": 52.9788, "RA_OF_ASC_NODE": 133.7921, "ARG_OF_PERICENTER": 2.6753, "MEAN_ANOMALY": 357.4284, "BSTAR": 0.0005727, "EPOCH": "2023-12-28T12:24:10"}, {"OBJECT_NAME": "SKYSAT-C15", "NORAD_CAT_ID": 45790, "MEAN_MOTION": 15.47687342, "ECCENTRICITY": 0.0003931, "INCLINATION": 52.9782, "RA_OF_ASC_NODE": 154.0912, "ARG_OF_PERICENTER": 180.8503, "MEAN_ANOMALY": 179.2497, "BSTAR": 0.00058075, "EPOCH": "2023-12-27T15:49:43"}, {"OBJECT_NAME": "SKYSAT-C1", "NORAD_CAT_ID": 41601, "MEAN_MOTION": 15.34566965, "ECCENTRICITY": 0.0003399, "INCLINATION": 96.9703, "RA_OF_ASC_NODE": 108.0862, "ARG_OF_PERICENTER": 47.429, "MEAN_ANOMALY": 312.724, "BSTAR": 0.00030343, "EPOCH": "2026-03-12T01:09:40"}, {"OBJECT_NAME": "SKYSAT-C4", "NORAD_CAT_ID": 41771, "MEAN_MOTION": 15.45471666, "ECCENTRICITY": 0.0001121, "INCLINATION": 96.9292, "RA_OF_ASC_NODE": 97.1735, "ARG_OF_PERICENTER": 125.8621, "MEAN_ANOMALY": 234.2733, "BSTAR": 0.00037941000000000006, "EPOCH": "2026-03-12T04:20:09"}, {"OBJECT_NAME": "SKYSAT-C5", "NORAD_CAT_ID": 41772, "MEAN_MOTION": 15.34342714, "ECCENTRICITY": 0.0001677, "INCLINATION": 97.0615, "RA_OF_ASC_NODE": 116.0693, "ARG_OF_PERICENTER": 87.0701, "MEAN_ANOMALY": 273.0735, "BSTAR": 0.00025964, "EPOCH": "2026-03-12T02:35:47"}, {"OBJECT_NAME": "SKYSAT-C2", "NORAD_CAT_ID": 41773, "MEAN_MOTION": 15.39771652, "ECCENTRICITY": 0.0004872, "INCLINATION": 97.0284, "RA_OF_ASC_NODE": 96.905, "ARG_OF_PERICENTER": 63.3233, "MEAN_ANOMALY": 296.8512, "BSTAR": 0.00031758, "EPOCH": "2026-03-12T01:08:58"}, {"OBJECT_NAME": "SKYSAT-C3", "NORAD_CAT_ID": 41774, "MEAN_MOTION": 15.48440305, "ECCENTRICITY": 0.0001727, "INCLINATION": 96.9179, "RA_OF_ASC_NODE": 105.8492, "ARG_OF_PERICENTER": 110.4094, "MEAN_ANOMALY": 249.7343, "BSTAR": 0.00039806000000000005, "EPOCH": "2026-03-12T01:53:05"}, {"OBJECT_NAME": "SKYSAT-C11", "NORAD_CAT_ID": 42987, "MEAN_MOTION": 15.5256729, "ECCENTRICITY": 5.75e-05, "INCLINATION": 97.4203, "RA_OF_ASC_NODE": 216.0328, "ARG_OF_PERICENTER": 80.8687, "MEAN_ANOMALY": 279.263, "BSTAR": 0.00045917, "EPOCH": "2026-03-11T23:20:36"}, {"OBJECT_NAME": "SKYSAT-C10", "NORAD_CAT_ID": 42988, "MEAN_MOTION": 15.32739502, "ECCENTRICITY": 0.0001001, "INCLINATION": 97.4338, "RA_OF_ASC_NODE": 207.0079, "ARG_OF_PERICENTER": 92.9156, "MEAN_ANOMALY": 267.2201, "BSTAR": 0.00036314999999999996, "EPOCH": "2026-03-11T21:16:12"}, {"OBJECT_NAME": "SKYSAT-C9", "NORAD_CAT_ID": 42989, "MEAN_MOTION": 15.389814, "ECCENTRICITY": 0.0009899, "INCLINATION": 97.4262, "RA_OF_ASC_NODE": 207.3646, "ARG_OF_PERICENTER": 124.4639, "MEAN_ANOMALY": 235.7543, "BSTAR": 0.00045397, "EPOCH": "2026-03-12T04:24:50"}, {"OBJECT_NAME": "SKYSAT-C8", "NORAD_CAT_ID": 42990, "MEAN_MOTION": 15.34837012, "ECCENTRICITY": 0.0001247, "INCLINATION": 97.4354, "RA_OF_ASC_NODE": 206.5009, "ARG_OF_PERICENTER": 91.7403, "MEAN_ANOMALY": 268.3983, "BSTAR": 0.00037221, "EPOCH": "2026-03-11T22:23:19"}, {"OBJECT_NAME": "SKYSAT-C7", "NORAD_CAT_ID": 42991, "MEAN_MOTION": 15.34791075, "ECCENTRICITY": 0.0007306, "INCLINATION": 97.4367, "RA_OF_ASC_NODE": 207.5399, "ARG_OF_PERICENTER": 96.5879, "MEAN_ANOMALY": 263.6196, "BSTAR": 0.00039668, "EPOCH": "2026-03-11T23:43:01"}, {"OBJECT_NAME": "SKYSAT-C6", "NORAD_CAT_ID": 42992, "MEAN_MOTION": 15.32566954, "ECCENTRICITY": 0.0003707, "INCLINATION": 97.44, "RA_OF_ASC_NODE": 208.0149, "ARG_OF_PERICENTER": 122.1262, "MEAN_ANOMALY": 238.034, "BSTAR": 0.00041112, "EPOCH": "2026-03-12T04:24:36"}, {"OBJECT_NAME": "SKYSAT-C12", "NORAD_CAT_ID": 43797, "MEAN_MOTION": 15.45702021, "ECCENTRICITY": 0.0005776, "INCLINATION": 96.9383, "RA_OF_ASC_NODE": 98.4586, "ARG_OF_PERICENTER": 131.3627, "MEAN_ANOMALY": 228.8121, "BSTAR": 0.00039422000000000003, "EPOCH": "2026-03-12T03:41:44"}, {"OBJECT_NAME": "SKYSAT-C13", "NORAD_CAT_ID": 43802, "MEAN_MOTION": 15.77192201, "ECCENTRICITY": 0.0004377, "INCLINATION": 96.9401, "RA_OF_ASC_NODE": 118.7176, "ARG_OF_PERICENTER": 191.6058, "MEAN_ANOMALY": 168.5109, "BSTAR": 0.00050625, "EPOCH": "2026-03-12T01:49:58"}, {"OBJECT_NAME": "SKYSAT-A", "NORAD_CAT_ID": 39418, "MEAN_MOTION": 15.12373015, "ECCENTRICITY": 0.0020118, "INCLINATION": 97.3951, "RA_OF_ASC_NODE": 123.5529, "ARG_OF_PERICENTER": 291.5858, "MEAN_ANOMALY": 68.3229, "BSTAR": 0.0002253, "EPOCH": "2026-03-12T02:00:20"}, {"OBJECT_NAME": "COSMO-SKYMED 1", "NORAD_CAT_ID": 31598, "MEAN_MOTION": 14.96556565, "ECCENTRICITY": 0.0001468, "INCLINATION": 97.8879, "RA_OF_ASC_NODE": 262.1896, "ARG_OF_PERICENTER": 98.0922, "MEAN_ANOMALY": 262.0465, "BSTAR": 0.00035465999999999997, "EPOCH": "2026-03-12T03:19:14"}, {"OBJECT_NAME": "COSMO-SKYMED 4", "NORAD_CAT_ID": 37216, "MEAN_MOTION": 14.82166465, "ECCENTRICITY": 0.0001423, "INCLINATION": 97.8871, "RA_OF_ASC_NODE": 256.1612, "ARG_OF_PERICENTER": 84.0819, "MEAN_ANOMALY": 276.0556, "BSTAR": 6.249e-05, "EPOCH": "2026-03-12T03:51:54"}, {"OBJECT_NAME": "COSMO-SKYMED 2", "NORAD_CAT_ID": 32376, "MEAN_MOTION": 14.82146224, "ECCENTRICITY": 0.0001349, "INCLINATION": 97.8871, "RA_OF_ASC_NODE": 256.1488, "ARG_OF_PERICENTER": 83.659, "MEAN_ANOMALY": 276.4777, "BSTAR": 8.300400000000001e-05, "EPOCH": "2026-03-12T03:33:41"}, {"OBJECT_NAME": "COSMO-SKYMED 3", "NORAD_CAT_ID": 33412, "MEAN_MOTION": 15.06272446, "ECCENTRICITY": 0.0014443, "INCLINATION": 97.8416, "RA_OF_ASC_NODE": 288.1088, "ARG_OF_PERICENTER": 232.2586, "MEAN_ANOMALY": 127.7332, "BSTAR": 0.00025866, "EPOCH": "2026-03-12T04:15:42"}, {"OBJECT_NAME": "PLANETUM1", "NORAD_CAT_ID": 52738, "MEAN_MOTION": 16.30442171, "ECCENTRICITY": 0.0009881, "INCLINATION": 97.5537, "RA_OF_ASC_NODE": 110.7236, "ARG_OF_PERICENTER": 292.3042, "MEAN_ANOMALY": 67.7204, "BSTAR": 0.0013242000000000002, "EPOCH": "2024-11-28T20:09:41"}, {"OBJECT_NAME": "PAZ", "NORAD_CAT_ID": 43215, "MEAN_MOTION": 15.19148224, "ECCENTRICITY": 0.000172, "INCLINATION": 97.4463, "RA_OF_ASC_NODE": 79.9794, "ARG_OF_PERICENTER": 88.2166, "MEAN_ANOMALY": 271.9265, "BSTAR": 0.00010342, "EPOCH": "2026-03-12T05:09:15"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D07", "NORAD_CAT_ID": 52392, "MEAN_MOTION": 16.37606451, "ECCENTRICITY": 0.0008479, "INCLINATION": 97.4719, "RA_OF_ASC_NODE": 145.145, "ARG_OF_PERICENTER": 283.4988, "MEAN_ANOMALY": 76.5365, "BSTAR": 0.0008378600000000001, "EPOCH": "2026-01-25T14:26:58"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3C", "NORAD_CAT_ID": 46455, "MEAN_MOTION": 16.4332105, "ECCENTRICITY": 0.0008886, "INCLINATION": 97.224, "RA_OF_ASC_NODE": 102.405, "ARG_OF_PERICENTER": 228.0638, "MEAN_ANOMALY": 131.9907, "BSTAR": 0.00043027, "EPOCH": "2026-02-10T19:53:25"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D", "NORAD_CAT_ID": 46456, "MEAN_MOTION": 15.21343265, "ECCENTRICITY": 0.0011568, "INCLINATION": 97.3728, "RA_OF_ASC_NODE": 46.7737, "ARG_OF_PERICENTER": 112.6941, "MEAN_ANOMALY": 247.5519, "BSTAR": 0.00055382, "EPOCH": "2023-12-28T11:38:25"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D32", "NORAD_CAT_ID": 52449, "MEAN_MOTION": 15.2053231, "ECCENTRICITY": 0.0011601, "INCLINATION": 97.6185, "RA_OF_ASC_NODE": 74.9126, "ARG_OF_PERICENTER": 14.3554, "MEAN_ANOMALY": 345.8008, "BSTAR": 0.00056673, "EPOCH": "2023-12-28T10:45:20"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D33", "NORAD_CAT_ID": 52450, "MEAN_MOTION": 15.20833127, "ECCENTRICITY": 0.0013609, "INCLINATION": 97.6181, "RA_OF_ASC_NODE": 74.9956, "ARG_OF_PERICENTER": 10.9961, "MEAN_ANOMALY": 349.1568, "BSTAR": 0.00056472, "EPOCH": "2023-12-28T10:56:13"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 4A", "NORAD_CAT_ID": 52388, "MEAN_MOTION": 16.28950252, "ECCENTRICITY": 0.000805, "INCLINATION": 97.4754, "RA_OF_ASC_NODE": 100.0865, "ARG_OF_PERICENTER": 290.668, "MEAN_ANOMALY": 69.375, "BSTAR": 0.0010643, "EPOCH": "2025-12-16T07:00:53"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D03", "NORAD_CAT_ID": 49006, "MEAN_MOTION": 15.81057548, "ECCENTRICITY": 0.0001617, "INCLINATION": 97.3019, "RA_OF_ASC_NODE": 148.2338, "ARG_OF_PERICENTER": 149.4568, "MEAN_ANOMALY": 210.6795, "BSTAR": 0.0009996500000000001, "EPOCH": "2026-03-12T02:08:28"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2D", "NORAD_CAT_ID": 49256, "MEAN_MOTION": 15.0974802, "ECCENTRICITY": 0.0035062, "INCLINATION": 97.6321, "RA_OF_ASC_NODE": 194.1189, "ARG_OF_PERICENTER": 116.3563, "MEAN_ANOMALY": 244.1273, "BSTAR": 0.00045574, "EPOCH": "2026-03-12T03:48:54"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2A", "NORAD_CAT_ID": 44777, "MEAN_MOTION": 15.3650605, "ECCENTRICITY": 0.0007647, "INCLINATION": 97.5003, "RA_OF_ASC_NODE": 152.9064, "ARG_OF_PERICENTER": 77.6235, "MEAN_ANOMALY": 282.5864, "BSTAR": 0.00048879, "EPOCH": "2026-03-12T04:34:36"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2F", "NORAD_CAT_ID": 49338, "MEAN_MOTION": 15.06963444, "ECCENTRICITY": 0.0018859, "INCLINATION": 97.6533, "RA_OF_ASC_NODE": 197.768, "ARG_OF_PERICENTER": 193.8972, "MEAN_ANOMALY": 166.1738, "BSTAR": 0.00035447, "EPOCH": "2026-03-12T04:26:15"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2B", "NORAD_CAT_ID": 44836, "MEAN_MOTION": 15.19487412, "ECCENTRICITY": 0.0012998, "INCLINATION": 97.496, "RA_OF_ASC_NODE": 143.838, "ARG_OF_PERICENTER": 300.4301, "MEAN_ANOMALY": 59.5648, "BSTAR": 0.00035864, "EPOCH": "2026-03-12T04:27:15"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D27", "NORAD_CAT_ID": 52444, "MEAN_MOTION": 15.5907232, "ECCENTRICITY": 0.0004118, "INCLINATION": 97.5628, "RA_OF_ASC_NODE": 177.7646, "ARG_OF_PERICENTER": 45.0687, "MEAN_ANOMALY": 315.0902, "BSTAR": 0.00094372, "EPOCH": "2026-03-12T04:27:36"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D28", "NORAD_CAT_ID": 52445, "MEAN_MOTION": 15.60369385, "ECCENTRICITY": 0.0002816, "INCLINATION": 97.5572, "RA_OF_ASC_NODE": 177.6845, "ARG_OF_PERICENTER": 74.7718, "MEAN_ANOMALY": 285.3849, "BSTAR": 0.00093033, "EPOCH": "2026-03-12T04:16:22"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D14", "NORAD_CAT_ID": 51831, "MEAN_MOTION": 15.81018912, "ECCENTRICITY": 0.0003055, "INCLINATION": 97.3689, "RA_OF_ASC_NODE": 164.6195, "ARG_OF_PERICENTER": 153.2394, "MEAN_ANOMALY": 206.9032, "BSTAR": 0.001119, "EPOCH": "2026-03-12T04:46:58"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3B", "NORAD_CAT_ID": 46454, "MEAN_MOTION": 15.80772799, "ECCENTRICITY": 0.0003267, "INCLINATION": 97.2357, "RA_OF_ASC_NODE": 128.8526, "ARG_OF_PERICENTER": 145.1916, "MEAN_ANOMALY": 214.9567, "BSTAR": 0.00093894, "EPOCH": "2026-03-12T04:15:59"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D48", "NORAD_CAT_ID": 54695, "MEAN_MOTION": 15.33015753, "ECCENTRICITY": 0.0007636, "INCLINATION": 97.678, "RA_OF_ASC_NODE": 236.8004, "ARG_OF_PERICENTER": 104.5803, "MEAN_ANOMALY": 255.6286, "BSTAR": 0.000441, "EPOCH": "2026-03-12T05:04:27"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D16", "NORAD_CAT_ID": 51834, "MEAN_MOTION": 15.88595899, "ECCENTRICITY": 0.000278, "INCLINATION": 97.3671, "RA_OF_ASC_NODE": 165.9367, "ARG_OF_PERICENTER": 201.2109, "MEAN_ANOMALY": 158.9049, "BSTAR": 0.0011572000000000002, "EPOCH": "2026-03-12T04:00:57"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D11", "NORAD_CAT_ID": 51835, "MEAN_MOTION": 15.78195757, "ECCENTRICITY": 0.0003472, "INCLINATION": 97.3721, "RA_OF_ASC_NODE": 164.6089, "ARG_OF_PERICENTER": 168.3789, "MEAN_ANOMALY": 191.7558, "BSTAR": 0.0010862, "EPOCH": "2026-03-12T05:09:01"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3E", "NORAD_CAT_ID": 46457, "MEAN_MOTION": 15.5402675, "ECCENTRICITY": 0.0015073, "INCLINATION": 97.2429, "RA_OF_ASC_NODE": 122.2196, "ARG_OF_PERICENTER": 156.219, "MEAN_ANOMALY": 203.9763, "BSTAR": 0.00066941, "EPOCH": "2026-03-12T05:04:41"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D15", "NORAD_CAT_ID": 51839, "MEAN_MOTION": 15.69754731, "ECCENTRICITY": 0.0003908, "INCLINATION": 97.3688, "RA_OF_ASC_NODE": 162.5136, "ARG_OF_PERICENTER": 167.8753, "MEAN_ANOMALY": 192.2604, "BSTAR": 0.0010008, "EPOCH": "2026-03-12T03:59:42"}, {"OBJECT_NAME": "YAOGAN-29", "NORAD_CAT_ID": 41038, "MEAN_MOTION": 14.8299816, "ECCENTRICITY": 0.0001719, "INCLINATION": 98.0143, "RA_OF_ASC_NODE": 99.6599, "ARG_OF_PERICENTER": 31.3956, "MEAN_ANOMALY": 328.736, "BSTAR": 7.4899e-05, "EPOCH": "2026-03-12T05:21:48"}, {"OBJECT_NAME": "YAOGAN-23", "NORAD_CAT_ID": 40305, "MEAN_MOTION": 16.38800959, "ECCENTRICITY": 0.000344, "INCLINATION": 97.6653, "RA_OF_ASC_NODE": 349.5059, "ARG_OF_PERICENTER": 254.7314, "MEAN_ANOMALY": 115.2384, "BSTAR": 0.00072463, "EPOCH": "2024-12-12T06:42:04"}, {"OBJECT_NAME": "YAOGAN-13", "NORAD_CAT_ID": 37941, "MEAN_MOTION": 16.37208404, "ECCENTRICITY": 0.0005273, "INCLINATION": 97.6847, "RA_OF_ASC_NODE": 66.5996, "ARG_OF_PERICENTER": 226.2633, "MEAN_ANOMALY": 199.5818, "BSTAR": 0.00080897, "EPOCH": "2025-02-22T04:56:09"}, {"OBJECT_NAME": "YAOGAN-21", "NORAD_CAT_ID": 40143, "MEAN_MOTION": 15.24971108, "ECCENTRICITY": 0.0010144, "INCLINATION": 97.1617, "RA_OF_ASC_NODE": 115.6566, "ARG_OF_PERICENTER": 79.6619, "MEAN_ANOMALY": 280.5762, "BSTAR": 0.00025497, "EPOCH": "2026-03-12T05:23:21"}, {"OBJECT_NAME": "YAOGAN-10", "NORAD_CAT_ID": 36834, "MEAN_MOTION": 14.85025341, "ECCENTRICITY": 0.0001576, "INCLINATION": 97.9083, "RA_OF_ASC_NODE": 95.3601, "ARG_OF_PERICENTER": 85.8073, "MEAN_ANOMALY": 3.4135, "BSTAR": 0.00022192000000000002, "EPOCH": "2026-03-12T04:55:42"}, {"OBJECT_NAME": "YAOGAN-12", "NORAD_CAT_ID": 37875, "MEAN_MOTION": 15.25428944, "ECCENTRICITY": 0.0009317, "INCLINATION": 97.1282, "RA_OF_ASC_NODE": 114.3661, "ARG_OF_PERICENTER": 200.5489, "MEAN_ANOMALY": 159.5376, "BSTAR": 0.00010992, "EPOCH": "2026-03-12T05:11:32"}, {"OBJECT_NAME": "YAOGAN-3", "NORAD_CAT_ID": 32289, "MEAN_MOTION": 14.90377301, "ECCENTRICITY": 0.0001717, "INCLINATION": 97.8278, "RA_OF_ASC_NODE": 102.0055, "ARG_OF_PERICENTER": 81.1969, "MEAN_ANOMALY": 278.9444, "BSTAR": 9.3183e-05, "EPOCH": "2026-03-12T04:38:10"}, {"OBJECT_NAME": "YAOGAN-4", "NORAD_CAT_ID": 33446, "MEAN_MOTION": 14.82985794, "ECCENTRICITY": 0.0015198, "INCLINATION": 97.9248, "RA_OF_ASC_NODE": 5.0901, "ARG_OF_PERICENTER": 32.8004, "MEAN_ANOMALY": 327.415, "BSTAR": 0.00020976, "EPOCH": "2026-03-11T19:28:45"}, {"OBJECT_NAME": "YAOGAN-7", "NORAD_CAT_ID": 36110, "MEAN_MOTION": 14.77897532, "ECCENTRICITY": 0.0024578, "INCLINATION": 98.0206, "RA_OF_ASC_NODE": 317.5409, "ARG_OF_PERICENTER": 355.5201, "MEAN_ANOMALY": 4.5785, "BSTAR": 0.00012844, "EPOCH": "2026-03-12T03:20:59"}, {"OBJECT_NAME": "YAOGAN-30 03A", "NORAD_CAT_ID": 43028, "MEAN_MOTION": 15.02262974, "ECCENTRICITY": 0.0002067, "INCLINATION": 34.9939, "RA_OF_ASC_NODE": 141.5273, "ARG_OF_PERICENTER": 197.3007, "MEAN_ANOMALY": 162.7631, "BSTAR": 0.000255, "EPOCH": "2026-03-12T04:53:52"}, {"OBJECT_NAME": "YAOGAN-34 02", "NORAD_CAT_ID": 52084, "MEAN_MOTION": 13.45429161, "ECCENTRICITY": 0.0043399, "INCLINATION": 63.395, "RA_OF_ASC_NODE": 90.3272, "ARG_OF_PERICENTER": 351.6571, "MEAN_ANOMALY": 8.3729, "BSTAR": 3.1318e-05, "EPOCH": "2026-03-12T04:17:59"}, {"OBJECT_NAME": "YAOGAN-30 03B", "NORAD_CAT_ID": 43029, "MEAN_MOTION": 15.02145039, "ECCENTRICITY": 0.000257, "INCLINATION": 34.9933, "RA_OF_ASC_NODE": 141.2245, "ARG_OF_PERICENTER": 112.4912, "MEAN_ANOMALY": 247.6068, "BSTAR": 0.00032625, "EPOCH": "2026-03-12T04:28:32"}, {"OBJECT_NAME": "YAOGAN-30 03C", "NORAD_CAT_ID": 43030, "MEAN_MOTION": 15.02191345, "ECCENTRICITY": 0.0002677, "INCLINATION": 34.9943, "RA_OF_ASC_NODE": 140.9018, "ARG_OF_PERICENTER": 274.8642, "MEAN_ANOMALY": 85.176, "BSTAR": 0.00025435000000000003, "EPOCH": "2026-03-12T05:32:23"}, {"OBJECT_NAME": "YAOGAN-31 01A", "NORAD_CAT_ID": 43275, "MEAN_MOTION": 13.454526, "ECCENTRICITY": 0.0266983, "INCLINATION": 63.3981, "RA_OF_ASC_NODE": 340.777, "ARG_OF_PERICENTER": 7.1273, "MEAN_ANOMALY": 353.3431, "BSTAR": -9.3813e-05, "EPOCH": "2026-03-12T05:15:00"}, {"OBJECT_NAME": "YAOGAN-31 01B", "NORAD_CAT_ID": 43276, "MEAN_MOTION": 13.45448776, "ECCENTRICITY": 0.0266966, "INCLINATION": 63.3983, "RA_OF_ASC_NODE": 340.7773, "ARG_OF_PERICENTER": 7.0855, "MEAN_ANOMALY": 353.3827, "BSTAR": -2.909e-05, "EPOCH": "2026-03-12T05:16:17"}, {"OBJECT_NAME": "YAOGAN-31 01C", "NORAD_CAT_ID": 43277, "MEAN_MOTION": 13.45433196, "ECCENTRICITY": 0.0266944, "INCLINATION": 63.3992, "RA_OF_ASC_NODE": 340.1238, "ARG_OF_PERICENTER": 6.8286, "MEAN_ANOMALY": 353.6262, "BSTAR": 6.9309e-05, "EPOCH": "2026-03-12T05:18:31"}, {"OBJECT_NAME": "YAOGAN-30 06C", "NORAD_CAT_ID": 44451, "MEAN_MOTION": 15.02192486, "ECCENTRICITY": 0.0001148, "INCLINATION": 34.9928, "RA_OF_ASC_NODE": 200.9762, "ARG_OF_PERICENTER": 266.7805, "MEAN_ANOMALY": 93.2771, "BSTAR": 0.00030324000000000003, "EPOCH": "2026-03-12T02:38:36"}, {"OBJECT_NAME": "YAOGAN-39 01B", "NORAD_CAT_ID": 57728, "MEAN_MOTION": 15.17386002, "ECCENTRICITY": 0.0002399, "INCLINATION": 34.9951, "RA_OF_ASC_NODE": 36.6463, "ARG_OF_PERICENTER": 235.4693, "MEAN_ANOMALY": 124.5793, "BSTAR": 0.00040346, "EPOCH": "2026-03-12T04:33:30"}, {"OBJECT_NAME": "YAOGAN-36 01C", "NORAD_CAT_ID": 53947, "MEAN_MOTION": 15.3446709, "ECCENTRICITY": 0.0003404, "INCLINATION": 35.0025, "RA_OF_ASC_NODE": 145.0144, "ARG_OF_PERICENTER": 340.542, "MEAN_ANOMALY": 19.5168, "BSTAR": 0.00053977, "EPOCH": "2026-03-11T11:30:26"}, {"OBJECT_NAME": "YAOGAN-47", "NORAD_CAT_ID": 66988, "MEAN_MOTION": 15.23242362, "ECCENTRICITY": 0.0001584, "INCLINATION": 97.5108, "RA_OF_ASC_NODE": 141.4962, "ARG_OF_PERICENTER": 87.2387, "MEAN_ANOMALY": 272.9031, "BSTAR": 0.00031552, "EPOCH": "2026-03-12T04:46:32"}, {"OBJECT_NAME": "GAOFEN-3", "NORAD_CAT_ID": 41727, "MEAN_MOTION": 14.42216956, "ECCENTRICITY": 0.0001695, "INCLINATION": 98.4059, "RA_OF_ASC_NODE": 80.4001, "ARG_OF_PERICENTER": 89.2451, "MEAN_ANOMALY": 270.8933, "BSTAR": 3.8487e-05, "EPOCH": "2026-03-12T03:54:51"}, {"OBJECT_NAME": "GAOFEN-3 03", "NORAD_CAT_ID": 52200, "MEAN_MOTION": 14.422076, "ECCENTRICITY": 0.000158, "INCLINATION": 98.4113, "RA_OF_ASC_NODE": 81.1431, "ARG_OF_PERICENTER": 93.6187, "MEAN_ANOMALY": 266.5183, "BSTAR": 2.4808e-05, "EPOCH": "2026-03-12T04:04:32"}, {"OBJECT_NAME": "GAOFEN-6", "NORAD_CAT_ID": 43484, "MEAN_MOTION": 14.76602015, "ECCENTRICITY": 0.0013318, "INCLINATION": 97.7865, "RA_OF_ASC_NODE": 137.3609, "ARG_OF_PERICENTER": 127.3074, "MEAN_ANOMALY": 232.9352, "BSTAR": 0.00013023, "EPOCH": "2026-03-12T04:01:42"}, {"OBJECT_NAME": "GAOFEN-3 02", "NORAD_CAT_ID": 49495, "MEAN_MOTION": 14.42217124, "ECCENTRICITY": 0.00015, "INCLINATION": 98.4136, "RA_OF_ASC_NODE": 80.6577, "ARG_OF_PERICENTER": 297.5762, "MEAN_ANOMALY": 62.5275, "BSTAR": 7.9148e-05, "EPOCH": "2026-03-12T04:43:52"}, {"OBJECT_NAME": "GAOFEN-2", "NORAD_CAT_ID": 40118, "MEAN_MOTION": 14.80777993, "ECCENTRICITY": 0.000728, "INCLINATION": 98.0216, "RA_OF_ASC_NODE": 135.4748, "ARG_OF_PERICENTER": 176.6495, "MEAN_ANOMALY": 183.4767, "BSTAR": 0.00016761, "EPOCH": "2026-03-12T05:06:51"}, {"OBJECT_NAME": "GAOFEN-8", "NORAD_CAT_ID": 40701, "MEAN_MOTION": 15.42526201, "ECCENTRICITY": 0.0009561, "INCLINATION": 97.6941, "RA_OF_ASC_NODE": 258.9945, "ARG_OF_PERICENTER": 178.5084, "MEAN_ANOMALY": 181.6192, "BSTAR": 0.00024229, "EPOCH": "2026-03-12T05:06:32"}, {"OBJECT_NAME": "GAOFEN-7", "NORAD_CAT_ID": 44703, "MEAN_MOTION": 15.21347931, "ECCENTRICITY": 0.0015611, "INCLINATION": 97.254, "RA_OF_ASC_NODE": 136.25, "ARG_OF_PERICENTER": 21.707, "MEAN_ANOMALY": 338.4825, "BSTAR": 0.00025435000000000003, "EPOCH": "2026-03-12T04:30:44"}, {"OBJECT_NAME": "GAOFEN-5 02", "NORAD_CAT_ID": 49122, "MEAN_MOTION": 14.57725004, "ECCENTRICITY": 1.68e-05, "INCLINATION": 98.2671, "RA_OF_ASC_NODE": 146.1843, "ARG_OF_PERICENTER": 307.194, "MEAN_ANOMALY": 52.9243, "BSTAR": 8.2549e-05, "EPOCH": "2026-03-12T04:50:18"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D05", "NORAD_CAT_ID": 52390, "MEAN_MOTION": 15.80577207, "ECCENTRICITY": 0.0003566, "INCLINATION": 97.4776, "RA_OF_ASC_NODE": 185.7709, "ARG_OF_PERICENTER": 236.3158, "MEAN_ANOMALY": 123.777, "BSTAR": 0.0011418, "EPOCH": "2026-03-12T04:57:08"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D54", "NORAD_CAT_ID": 54254, "MEAN_MOTION": 15.68767962, "ECCENTRICITY": 0.000287, "INCLINATION": 97.6018, "RA_OF_ASC_NODE": 214.2093, "ARG_OF_PERICENTER": 309.5735, "MEAN_ANOMALY": 50.5272, "BSTAR": 0.00084221, "EPOCH": "2026-03-12T05:12:38"}, {"OBJECT_NAME": "SBIRS GEO-3 (USA 282)", "NORAD_CAT_ID": 43162, "MEAN_MOTION": 1.00273002, "ECCENTRICITY": 0.0002231, "INCLINATION": 2.1257, "RA_OF_ASC_NODE": 7.2861, "ARG_OF_PERICENTER": 349.6974, "MEAN_ANOMALY": 53.8116, "BSTAR": 0.0, "EPOCH": "2026-03-12T02:41:46"}, {"OBJECT_NAME": "SBIRS GEO-1 (USA 230)", "NORAD_CAT_ID": 37481, "MEAN_MOTION": 1.00272243, "ECCENTRICITY": 0.0002305, "INCLINATION": 4.3233, "RA_OF_ASC_NODE": 53.0978, "ARG_OF_PERICENTER": 295.8387, "MEAN_ANOMALY": 289.8228, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:48:38"}, {"OBJECT_NAME": "SBIRS GEO-2 (USA 241)", "NORAD_CAT_ID": 39120, "MEAN_MOTION": 1.00271763, "ECCENTRICITY": 0.0002239, "INCLINATION": 4.2783, "RA_OF_ASC_NODE": 52.3911, "ARG_OF_PERICENTER": 297.2305, "MEAN_ANOMALY": 101.366, "BSTAR": 0.0, "EPOCH": "2026-03-11T22:04:54"}, {"OBJECT_NAME": "SBIRS GEO-4 (USA 273)", "NORAD_CAT_ID": 41937, "MEAN_MOTION": 1.00273025, "ECCENTRICITY": 0.0002308, "INCLINATION": 2.0621, "RA_OF_ASC_NODE": 40.311, "ARG_OF_PERICENTER": 316.052, "MEAN_ANOMALY": 243.2237, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:15:38"}, {"OBJECT_NAME": "SBIRS GEO-5 (USA 315)", "NORAD_CAT_ID": 48618, "MEAN_MOTION": 1.00271937, "ECCENTRICITY": 0.000127, "INCLINATION": 5.228, "RA_OF_ASC_NODE": 328.3802, "ARG_OF_PERICENTER": 27.6658, "MEAN_ANOMALY": 211.6504, "BSTAR": 0.0, "EPOCH": "2026-03-11T20:17:41"}, {"OBJECT_NAME": "SBIRS GEO-6 (USA 336)", "NORAD_CAT_ID": 53355, "MEAN_MOTION": 1.00272445, "ECCENTRICITY": 0.0002129, "INCLINATION": 3.4254, "RA_OF_ASC_NODE": 315.9281, "ARG_OF_PERICENTER": 37.5335, "MEAN_ANOMALY": 299.8346, "BSTAR": 0.0, "EPOCH": "2026-03-11T22:58:49"}, {"OBJECT_NAME": "KONDOR-FKA NO. 2", "NORAD_CAT_ID": 62138, "MEAN_MOTION": 15.1977694, "ECCENTRICITY": 0.0001838, "INCLINATION": 97.4353, "RA_OF_ASC_NODE": 273.3867, "ARG_OF_PERICENTER": 85.6363, "MEAN_ANOMALY": 274.5082, "BSTAR": 0.00026618999999999997, "EPOCH": "2026-03-09T11:47:52"}, {"OBJECT_NAME": "KONDOR-FKA NO. 1", "NORAD_CAT_ID": 56756, "MEAN_MOTION": 15.19772289, "ECCENTRICITY": 0.000173, "INCLINATION": 97.4394, "RA_OF_ASC_NODE": 262.4533, "ARG_OF_PERICENTER": 84.6258, "MEAN_ANOMALY": 275.5174, "BSTAR": 0.00035188000000000005, "EPOCH": "2026-03-07T10:13:03"}, {"OBJECT_NAME": "NAVSTAR 66 (USA 232)", "NORAD_CAT_ID": 37753, "MEAN_MOTION": 2.00563371, "ECCENTRICITY": 0.0140152, "INCLINATION": 56.6082, "RA_OF_ASC_NODE": 335.8168, "ARG_OF_PERICENTER": 60.9318, "MEAN_ANOMALY": 306.3448, "BSTAR": 0.0, "EPOCH": "2026-03-11T15:38:34"}, {"OBJECT_NAME": "NAVSTAR 46 (USA 145)", "NORAD_CAT_ID": 25933, "MEAN_MOTION": 2.0056749, "ECCENTRICITY": 0.0106389, "INCLINATION": 51.5508, "RA_OF_ASC_NODE": 299.7378, "ARG_OF_PERICENTER": 171.865, "MEAN_ANOMALY": 195.1959, "BSTAR": 0.0, "EPOCH": "2026-03-11T14:02:32"}, {"OBJECT_NAME": "NAVSTAR 49 (USA 154)", "NORAD_CAT_ID": 26605, "MEAN_MOTION": 2.00570306, "ECCENTRICITY": 0.0173399, "INCLINATION": 55.5674, "RA_OF_ASC_NODE": 98.8043, "ARG_OF_PERICENTER": 265.6511, "MEAN_ANOMALY": 98.697, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:24:04"}, {"OBJECT_NAME": "NAVSTAR 52 (USA 168)", "NORAD_CAT_ID": 27704, "MEAN_MOTION": 1.94743161, "ECCENTRICITY": 0.0004627, "INCLINATION": 54.8698, "RA_OF_ASC_NODE": 328.8449, "ARG_OF_PERICENTER": 343.01, "MEAN_ANOMALY": 204.2637, "BSTAR": 0.0, "EPOCH": "2026-03-11T13:01:38"}, {"OBJECT_NAME": "NAVSTAR 53 (USA 175)", "NORAD_CAT_ID": 28129, "MEAN_MOTION": 1.9267861, "ECCENTRICITY": 0.000314, "INCLINATION": 54.9715, "RA_OF_ASC_NODE": 26.8846, "ARG_OF_PERICENTER": 315.1197, "MEAN_ANOMALY": 44.8484, "BSTAR": 0.0, "EPOCH": "2026-03-11T11:29:45"}, {"OBJECT_NAME": "NAVSTAR 55 (USA 178)", "NORAD_CAT_ID": 28361, "MEAN_MOTION": 2.00552002, "ECCENTRICITY": 0.0159682, "INCLINATION": 54.7915, "RA_OF_ASC_NODE": 90.8407, "ARG_OF_PERICENTER": 298.7021, "MEAN_ANOMALY": 62.7963, "BSTAR": 0.0, "EPOCH": "2026-03-06T22:19:05"}, {"OBJECT_NAME": "NAVSTAR 63 (USA 203)", "NORAD_CAT_ID": 34661, "MEAN_MOTION": 2.00555714, "ECCENTRICITY": 0.0144944, "INCLINATION": 54.5034, "RA_OF_ASC_NODE": 214.5667, "ARG_OF_PERICENTER": 61.4749, "MEAN_ANOMALY": 119.0599, "BSTAR": 0.0, "EPOCH": "2026-03-11T13:52:25"}, {"OBJECT_NAME": "DMSP 5D-3 F18 (USA 210)", "NORAD_CAT_ID": 35951, "MEAN_MOTION": 14.14837704, "ECCENTRICITY": 0.0011276, "INCLINATION": 98.8966, "RA_OF_ASC_NODE": 52.2085, "ARG_OF_PERICENTER": 161.2307, "MEAN_ANOMALY": 198.9284, "BSTAR": 0.00019681, "EPOCH": "2026-03-12T04:30:03"}, {"OBJECT_NAME": "AEHF-1 (USA 214)", "NORAD_CAT_ID": 36868, "MEAN_MOTION": 1.00269372, "ECCENTRICITY": 0.0004352, "INCLINATION": 7.9793, "RA_OF_ASC_NODE": 69.3098, "ARG_OF_PERICENTER": 282.2717, "MEAN_ANOMALY": 262.0934, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:35:41"}, {"OBJECT_NAME": "AEHF-2 (USA 235)", "NORAD_CAT_ID": 38254, "MEAN_MOTION": 1.0027285, "ECCENTRICITY": 0.0003128, "INCLINATION": 6.4257, "RA_OF_ASC_NODE": 58.1902, "ARG_OF_PERICENTER": 281.4504, "MEAN_ANOMALY": 241.8696, "BSTAR": 0.0, "EPOCH": "2026-03-12T04:32:38"}, {"OBJECT_NAME": "AEHF-4 (USA 288)", "NORAD_CAT_ID": 43651, "MEAN_MOTION": 1.00270846, "ECCENTRICITY": 0.0064013, "INCLINATION": 1.4224, "RA_OF_ASC_NODE": 355.301, "ARG_OF_PERICENTER": 359.2105, "MEAN_ANOMALY": 154.741, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:07:59"}, {"OBJECT_NAME": "AEHF-5 (USA 292)", "NORAD_CAT_ID": 44481, "MEAN_MOTION": 1.0026992, "ECCENTRICITY": 0.0071251, "INCLINATION": 1.5548, "RA_OF_ASC_NODE": 329.1921, "ARG_OF_PERICENTER": 3.4226, "MEAN_ANOMALY": 322.9206, "BSTAR": 0.0, "EPOCH": "2026-03-12T02:10:56"}, {"OBJECT_NAME": "WGS F7 (USA 263)", "NORAD_CAT_ID": 40746, "MEAN_MOTION": 1.00269703, "ECCENTRICITY": 3.38e-05, "INCLINATION": 0.013, "RA_OF_ASC_NODE": 132.0849, "ARG_OF_PERICENTER": 259.1579, "MEAN_ANOMALY": 16.9975, "BSTAR": 0.0, "EPOCH": "2026-03-12T04:13:38"}, {"OBJECT_NAME": "SBSS (USA 216)", "NORAD_CAT_ID": 37168, "MEAN_MOTION": 15.15746676, "ECCENTRICITY": 0.0082446, "INCLINATION": 97.7473, "RA_OF_ASC_NODE": 314.7425, "ARG_OF_PERICENTER": 176.9123, "MEAN_ANOMALY": 299.3232, "BSTAR": 0.00022238, "EPOCH": "2026-03-12T04:39:24"}, {"OBJECT_NAME": "USA 81", "NORAD_CAT_ID": 21949, "MEAN_MOTION": 14.3236887, "ECCENTRICITY": 0.0002397, "INCLINATION": 85.008, "RA_OF_ASC_NODE": 130.0083, "ARG_OF_PERICENTER": 75.9632, "MEAN_ANOMALY": 284.1827, "BSTAR": 6.0559e-05, "EPOCH": "2026-03-12T04:10:06"}, {"OBJECT_NAME": "NUSAT-9 (ALICE)", "NORAD_CAT_ID": 46828, "MEAN_MOTION": 16.33943729, "ECCENTRICITY": 0.001099, "INCLINATION": 97.0919, "RA_OF_ASC_NODE": 350.4269, "ARG_OF_PERICENTER": 266.8171, "MEAN_ANOMALY": 93.1869, "BSTAR": 0.0009418100000000001, "EPOCH": "2023-10-02T20:48:12"}, {"OBJECT_NAME": "NUSAT-5 (MARYAM)", "NORAD_CAT_ID": 43204, "MEAN_MOTION": 16.4605451, "ECCENTRICITY": 0.0008666, "INCLINATION": 97.5583, "RA_OF_ASC_NODE": 170.606, "ARG_OF_PERICENTER": 279.6992, "MEAN_ANOMALY": 80.3331, "BSTAR": 0.00028268, "EPOCH": "2023-12-24T01:49:10"}, {"OBJECT_NAME": "NUSAT-11 (CORA)", "NORAD_CAT_ID": 46829, "MEAN_MOTION": 16.4529159, "ECCENTRICITY": 0.0011325, "INCLINATION": 97.1012, "RA_OF_ASC_NODE": 355.4444, "ARG_OF_PERICENTER": 266.5295, "MEAN_ANOMALY": 93.4713, "BSTAR": 0.00028921000000000003, "EPOCH": "2023-10-07T01:20:17"}, {"OBJECT_NAME": "NUSAT-15 (KATHERINE)", "NORAD_CAT_ID": 46830, "MEAN_MOTION": 16.34076651, "ECCENTRICITY": 0.0011902, "INCLINATION": 97.1235, "RA_OF_ASC_NODE": 337.498, "ARG_OF_PERICENTER": 265.1534, "MEAN_ANOMALY": 94.8404, "BSTAR": 0.0008088800000000001, "EPOCH": "2023-09-17T08:00:12"}, {"OBJECT_NAME": "NUSAT-1 (FRESCO)", "NORAD_CAT_ID": 41557, "MEAN_MOTION": 16.3237962, "ECCENTRICITY": 0.0010102, "INCLINATION": 97.362, "RA_OF_ASC_NODE": 49.6734, "ARG_OF_PERICENTER": 286.5405, "MEAN_ANOMALY": 73.478, "BSTAR": 0.00095107, "EPOCH": "2023-10-20T20:09:20"}, {"OBJECT_NAME": "NUSAT-23 (ANNIE MAUNDER)", "NORAD_CAT_ID": 52168, "MEAN_MOTION": 15.38873861, "ECCENTRICITY": 0.0006398, "INCLINATION": 97.3379, "RA_OF_ASC_NODE": 82.1522, "ARG_OF_PERICENTER": 183.9945, "MEAN_ANOMALY": 176.1251, "BSTAR": 0.0006674200000000001, "EPOCH": "2023-12-28T10:21:17"}, {"OBJECT_NAME": "NUSAT-14 (HEDY)", "NORAD_CAT_ID": 46831, "MEAN_MOTION": 16.3603456, "ECCENTRICITY": 0.0012051, "INCLINATION": 97.1214, "RA_OF_ASC_NODE": 325.2102, "ARG_OF_PERICENTER": 265.1929, "MEAN_ANOMALY": 94.7993, "BSTAR": 0.00071439, "EPOCH": "2023-09-04T23:54:36"}, {"OBJECT_NAME": "NUSAT-2 (BATATA)", "NORAD_CAT_ID": 41558, "MEAN_MOTION": 16.50276834, "ECCENTRICITY": 0.0010829, "INCLINATION": 97.4059, "RA_OF_ASC_NODE": 41.3631, "ARG_OF_PERICENTER": 262.8032, "MEAN_ANOMALY": 97.2042, "BSTAR": 0.00017999000000000002, "EPOCH": "2023-10-04T06:12:00"}, {"OBJECT_NAME": "NUSAT-25 (MARIA TELKES)", "NORAD_CAT_ID": 52171, "MEAN_MOTION": 15.26497612, "ECCENTRICITY": 0.0002554, "INCLINATION": 97.3065, "RA_OF_ASC_NODE": 78.6893, "ARG_OF_PERICENTER": 144.7143, "MEAN_ANOMALY": 215.4265, "BSTAR": 0.00056233, "EPOCH": "2023-12-28T11:04:42"}, {"OBJECT_NAME": "NUSAT-10 (CAROLINE)", "NORAD_CAT_ID": 46832, "MEAN_MOTION": 16.26728051, "ECCENTRICITY": 0.0009442, "INCLINATION": 97.1204, "RA_OF_ASC_NODE": 13.8592, "ARG_OF_PERICENTER": 272.5521, "MEAN_ANOMALY": 87.4691, "BSTAR": 0.0010056, "EPOCH": "2023-10-24T13:56:03"}, {"OBJECT_NAME": "NUSAT-36 (ANNIE CANNON)", "NORAD_CAT_ID": 56190, "MEAN_MOTION": 15.28580851, "ECCENTRICITY": 0.0010119, "INCLINATION": 97.3802, "RA_OF_ASC_NODE": 254.9322, "ARG_OF_PERICENTER": 59.1427, "MEAN_ANOMALY": 301.0806, "BSTAR": 0.00059254, "EPOCH": "2023-12-28T11:08:22"}, {"OBJECT_NAME": "WORLDVIEW-3 (WV-3)", "NORAD_CAT_ID": 40115, "MEAN_MOTION": 14.84841989, "ECCENTRICITY": 9e-06, "INCLINATION": 97.863, "RA_OF_ASC_NODE": 147.3612, "ARG_OF_PERICENTER": 196.6723, "MEAN_ANOMALY": 163.4488, "BSTAR": 0.00015628000000000001, "EPOCH": "2026-03-12T04:50:11"}, {"OBJECT_NAME": "WORLDVIEW-2 (WV-2)", "NORAD_CAT_ID": 35946, "MEAN_MOTION": 14.37911775, "ECCENTRICITY": 0.0005152, "INCLINATION": 98.4689, "RA_OF_ASC_NODE": 145.9926, "ARG_OF_PERICENTER": 139.2471, "MEAN_ANOMALY": 220.9102, "BSTAR": 7.3868e-05, "EPOCH": "2026-03-12T03:53:45"}, {"OBJECT_NAME": "WORLDVIEW-1 (WV-1)", "NORAD_CAT_ID": 32060, "MEAN_MOTION": 15.24581715, "ECCENTRICITY": 0.0003254, "INCLINATION": 97.3824, "RA_OF_ASC_NODE": 192.2107, "ARG_OF_PERICENTER": 132.7217, "MEAN_ANOMALY": 227.4295, "BSTAR": 0.00035099, "EPOCH": "2026-03-12T01:33:36"}, {"OBJECT_NAME": "ISS (ZARYA)", "NORAD_CAT_ID": 25544, "MEAN_MOTION": 15.48595269, "ECCENTRICITY": 0.0007984, "INCLINATION": 51.6325, "RA_OF_ASC_NODE": 60.1463, "ARG_OF_PERICENTER": 183.4136, "MEAN_ANOMALY": 176.6799, "BSTAR": 0.00017667, "EPOCH": "2026-03-12T03:49:12"}, {"OBJECT_NAME": "ISS (NAUKA)", "NORAD_CAT_ID": 49044, "MEAN_MOTION": 15.48595269, "ECCENTRICITY": 0.0007984, "INCLINATION": 51.6325, "RA_OF_ASC_NODE": 60.1463, "ARG_OF_PERICENTER": 183.4136, "MEAN_ANOMALY": 176.6799, "BSTAR": 0.00017667, "EPOCH": "2026-03-12T03:49:12"}, {"OBJECT_NAME": "SWISSCUBE", "NORAD_CAT_ID": 35932, "MEAN_MOTION": 14.62264392, "ECCENTRICITY": 0.0007191, "INCLINATION": 98.4095, "RA_OF_ASC_NODE": 336.4275, "ARG_OF_PERICENTER": 147.901, "MEAN_ANOMALY": 212.263, "BSTAR": 0.00029944, "EPOCH": "2026-03-11T06:09:32"}, {"OBJECT_NAME": "AISSAT 1", "NORAD_CAT_ID": 36797, "MEAN_MOTION": 14.96906481, "ECCENTRICITY": 0.0009471, "INCLINATION": 98.1015, "RA_OF_ASC_NODE": 326.9707, "ARG_OF_PERICENTER": 25.1756, "MEAN_ANOMALY": 334.9925, "BSTAR": 0.00026122, "EPOCH": "2026-03-12T03:59:12"}, {"OBJECT_NAME": "AISSAT 2", "NORAD_CAT_ID": 40075, "MEAN_MOTION": 14.8560182, "ECCENTRICITY": 0.000478, "INCLINATION": 98.3401, "RA_OF_ASC_NODE": 268.4723, "ARG_OF_PERICENTER": 335.0232, "MEAN_ANOMALY": 25.0749, "BSTAR": 0.00040707, "EPOCH": "2023-12-28T11:59:02"}, {"OBJECT_NAME": "ISS OBJECT XK", "NORAD_CAT_ID": 65731, "MEAN_MOTION": 16.39076546, "ECCENTRICITY": 0.0002464, "INCLINATION": 51.6052, "RA_OF_ASC_NODE": 44.2508, "ARG_OF_PERICENTER": 211.8886, "MEAN_ANOMALY": 148.1991, "BSTAR": 0.00075547, "EPOCH": "2026-03-09T22:15:28"}, {"OBJECT_NAME": "OUTPOST MISSION 2", "NORAD_CAT_ID": 58334, "MEAN_MOTION": 15.50918773, "ECCENTRICITY": 0.0006983, "INCLINATION": 97.3966, "RA_OF_ASC_NODE": 160.9928, "ARG_OF_PERICENTER": 116.2514, "MEAN_ANOMALY": 243.9456, "BSTAR": 0.00067312, "EPOCH": "2026-03-12T05:03:57"}, {"OBJECT_NAME": "ISS OBJECT YE", "NORAD_CAT_ID": 67688, "MEAN_MOTION": 15.52753002, "ECCENTRICITY": 0.0007979, "INCLINATION": 51.6315, "RA_OF_ASC_NODE": 59.4462, "ARG_OF_PERICENTER": 160.0005, "MEAN_ANOMALY": 200.1299, "BSTAR": 0.00084959, "EPOCH": "2026-03-12T03:59:03"}, {"OBJECT_NAME": "ISS OBJECT XU", "NORAD_CAT_ID": 66908, "MEAN_MOTION": 15.7025379, "ECCENTRICITY": 0.000485, "INCLINATION": 51.6251, "RA_OF_ASC_NODE": 52.8423, "ARG_OF_PERICENTER": 95.5022, "MEAN_ANOMALY": 264.6529, "BSTAR": 0.0013761000000000001, "EPOCH": "2026-03-12T04:35:10"}, {"OBJECT_NAME": "ISS OBJECT XW", "NORAD_CAT_ID": 66910, "MEAN_MOTION": 15.6938585, "ECCENTRICITY": 0.0005328, "INCLINATION": 51.6248, "RA_OF_ASC_NODE": 52.8254, "ARG_OF_PERICENTER": 101.1641, "MEAN_ANOMALY": 258.9956, "BSTAR": 0.0013074, "EPOCH": "2026-03-12T04:32:16"}, {"OBJECT_NAME": "ISS OBJECT XX", "NORAD_CAT_ID": 66911, "MEAN_MOTION": 15.76798851, "ECCENTRICITY": 0.0006998, "INCLINATION": 51.6228, "RA_OF_ASC_NODE": 51.2413, "ARG_OF_PERICENTER": 99.7488, "MEAN_ANOMALY": 260.4302, "BSTAR": 0.0016961, "EPOCH": "2026-03-12T04:18:18"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 47853, "MEAN_MOTION": 16.41769315, "ECCENTRICITY": 0.0002766, "INCLINATION": 51.6054, "RA_OF_ASC_NODE": 359.3486, "ARG_OF_PERICENTER": 253.9051, "MEAN_ANOMALY": 215.3296, "BSTAR": 0.00025185999999999996, "EPOCH": "2024-03-08T00:53:30"}, {"OBJECT_NAME": "ISS OBJECT XY", "NORAD_CAT_ID": 66912, "MEAN_MOTION": 15.64208185, "ECCENTRICITY": 0.0003307, "INCLINATION": 51.6274, "RA_OF_ASC_NODE": 54.4268, "ARG_OF_PERICENTER": 103.5482, "MEAN_ANOMALY": 256.5881, "BSTAR": 0.0010452, "EPOCH": "2026-03-12T04:50:05"}, {"OBJECT_NAME": "ISS OBJECT YC", "NORAD_CAT_ID": 67686, "MEAN_MOTION": 15.53034287, "ECCENTRICITY": 0.0008147, "INCLINATION": 51.6316, "RA_OF_ASC_NODE": 59.1062, "ARG_OF_PERICENTER": 159.4103, "MEAN_ANOMALY": 200.7217, "BSTAR": 0.0009219699999999999, "EPOCH": "2026-03-12T05:27:31"}, {"OBJECT_NAME": "ISS DEB (SPX-26 IPA FSE)", "NORAD_CAT_ID": 55448, "MEAN_MOTION": 16.42868885, "ECCENTRICITY": 0.0004554, "INCLINATION": 51.6104, "RA_OF_ASC_NODE": 65.1026, "ARG_OF_PERICENTER": 338.3161, "MEAN_ANOMALY": 176.0212, "BSTAR": 0.00044128, "EPOCH": "2023-12-23T14:31:26"}, {"OBJECT_NAME": "OUTPOST MISSION 1", "NORAD_CAT_ID": 56226, "MEAN_MOTION": 15.70495602, "ECCENTRICITY": 0.0004569, "INCLINATION": 97.5964, "RA_OF_ASC_NODE": 224.3254, "ARG_OF_PERICENTER": 250.307, "MEAN_ANOMALY": 109.7699, "BSTAR": 0.00073797, "EPOCH": "2026-03-11T22:44:15"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 56434, "MEAN_MOTION": 16.34589017, "ECCENTRICITY": 0.0007268, "INCLINATION": 51.614, "RA_OF_ASC_NODE": 110.8216, "ARG_OF_PERICENTER": 312.4976, "MEAN_ANOMALY": 47.5434, "BSTAR": 0.0007953900000000001, "EPOCH": "2023-12-17T13:53:44"}, {"OBJECT_NAME": "ISS DEB [SPX-28 IPA FSE]", "NORAD_CAT_ID": 57212, "MEAN_MOTION": 16.42826151, "ECCENTRICITY": 0.0005882, "INCLINATION": 51.6078, "RA_OF_ASC_NODE": 38.878, "ARG_OF_PERICENTER": 256.2665, "MEAN_ANOMALY": 278.8784, "BSTAR": 0.00035698999999999995, "EPOCH": "2024-05-22T10:35:37"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 58174, "MEAN_MOTION": 16.3176295, "ECCENTRICITY": 0.0009401, "INCLINATION": 51.6127, "RA_OF_ASC_NODE": 333.6363, "ARG_OF_PERICENTER": 246.0894, "MEAN_ANOMALY": 113.9145, "BSTAR": 0.001226, "EPOCH": "2024-03-28T18:25:22"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 58203, "MEAN_MOTION": 15.98175731, "ECCENTRICITY": 0.0014888, "INCLINATION": 51.6276, "RA_OF_ASC_NODE": 303.0132, "ARG_OF_PERICENTER": 126.3295, "MEAN_ANOMALY": 233.9091, "BSTAR": 0.0096833, "EPOCH": "2023-11-14T05:06:41"}, {"OBJECT_NAME": "SPOT 5", "NORAD_CAT_ID": 27421, "MEAN_MOTION": 14.54663457, "ECCENTRICITY": 0.0129011, "INCLINATION": 97.9984, "RA_OF_ASC_NODE": 114.2317, "ARG_OF_PERICENTER": 217.914, "MEAN_ANOMALY": 141.2922, "BSTAR": 0.00010163, "EPOCH": "2026-03-12T02:50:35"}, {"OBJECT_NAME": "SPOT 6", "NORAD_CAT_ID": 38755, "MEAN_MOTION": 14.58543992, "ECCENTRICITY": 0.0001481, "INCLINATION": 98.2211, "RA_OF_ASC_NODE": 139.5992, "ARG_OF_PERICENTER": 92.9431, "MEAN_ANOMALY": 267.1937, "BSTAR": 6.222800000000001e-05, "EPOCH": "2026-03-12T04:40:18"}, {"OBJECT_NAME": "SPOT 7", "NORAD_CAT_ID": 40053, "MEAN_MOTION": 14.60864286, "ECCENTRICITY": 0.0001433, "INCLINATION": 98.0737, "RA_OF_ASC_NODE": 136.149, "ARG_OF_PERICENTER": 101.053, "MEAN_ANOMALY": 259.0832, "BSTAR": 8.736800000000001e-05, "EPOCH": "2026-03-12T04:45:21"}, {"OBJECT_NAME": "SHIJIAN-16 (SJ-16)", "NORAD_CAT_ID": 39358, "MEAN_MOTION": 14.92362839, "ECCENTRICITY": 0.0015448, "INCLINATION": 74.973, "RA_OF_ASC_NODE": 180.2534, "ARG_OF_PERICENTER": 105.8171, "MEAN_ANOMALY": 254.4722, "BSTAR": 0.00039719, "EPOCH": "2026-03-12T05:50:42"}, {"OBJECT_NAME": "SHIJIAN-6 03A (SJ-6 03A)", "NORAD_CAT_ID": 33408, "MEAN_MOTION": 15.16104225, "ECCENTRICITY": 0.0011222, "INCLINATION": 97.8538, "RA_OF_ASC_NODE": 101.1647, "ARG_OF_PERICENTER": 355.1225, "MEAN_ANOMALY": 4.9895, "BSTAR": 0.00035432000000000004, "EPOCH": "2026-03-12T04:42:39"}, {"OBJECT_NAME": "SHIJIAN-6 04B (SJ-6 04B)", "NORAD_CAT_ID": 37180, "MEAN_MOTION": 14.99125477, "ECCENTRICITY": 0.0008925, "INCLINATION": 97.8752, "RA_OF_ASC_NODE": 75.6618, "ARG_OF_PERICENTER": 284.8746, "MEAN_ANOMALY": 75.1488, "BSTAR": 8.4893e-05, "EPOCH": "2026-03-12T05:54:25"}, {"OBJECT_NAME": "SHIJIAN-20 (SJ-20)", "NORAD_CAT_ID": 44910, "MEAN_MOTION": 1.00082394, "ECCENTRICITY": 0.000152, "INCLINATION": 4.6683, "RA_OF_ASC_NODE": 76.2485, "ARG_OF_PERICENTER": 15.7007, "MEAN_ANOMALY": 205.4841, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:47:25"}, {"OBJECT_NAME": "SHIJIAN-6 01A (SJ-6 01A)", "NORAD_CAT_ID": 28413, "MEAN_MOTION": 15.16490256, "ECCENTRICITY": 0.000806, "INCLINATION": 97.5981, "RA_OF_ASC_NODE": 97.9246, "ARG_OF_PERICENTER": 109.7222, "MEAN_ANOMALY": 250.4881, "BSTAR": 0.00047933, "EPOCH": "2026-03-12T00:32:04"}, {"OBJECT_NAME": "SHIJIAN-30A (SJ-30A)", "NORAD_CAT_ID": 66545, "MEAN_MOTION": 15.1538923, "ECCENTRICITY": 0.0011386, "INCLINATION": 51.798, "RA_OF_ASC_NODE": 273.8657, "ARG_OF_PERICENTER": 300.2273, "MEAN_ANOMALY": 59.7576, "BSTAR": 0.0005092100000000001, "EPOCH": "2026-03-12T04:04:07"}, {"OBJECT_NAME": "SHIJIAN-6 02B (SJ-6 02B)", "NORAD_CAT_ID": 29506, "MEAN_MOTION": 15.01014103, "ECCENTRICITY": 0.0013938, "INCLINATION": 97.7071, "RA_OF_ASC_NODE": 100.9455, "ARG_OF_PERICENTER": 35.4398, "MEAN_ANOMALY": 324.7749, "BSTAR": 0.00022449, "EPOCH": "2026-03-12T05:32:57"}, {"OBJECT_NAME": "SHIJIAN-6 03B (SJ-6 03B)", "NORAD_CAT_ID": 33409, "MEAN_MOTION": 15.03894204, "ECCENTRICITY": 0.001991, "INCLINATION": 97.8673, "RA_OF_ASC_NODE": 89.4456, "ARG_OF_PERICENTER": 14.3939, "MEAN_ANOMALY": 345.7849, "BSTAR": 0.00014578, "EPOCH": "2026-03-12T04:09:46"}, {"OBJECT_NAME": "SHIJIAN-6 04A (SJ-6 04A)", "NORAD_CAT_ID": 37179, "MEAN_MOTION": 15.16687416, "ECCENTRICITY": 0.0018419, "INCLINATION": 97.8383, "RA_OF_ASC_NODE": 91.1217, "ARG_OF_PERICENTER": 194.7628, "MEAN_ANOMALY": 165.3069, "BSTAR": 0.00043187, "EPOCH": "2026-03-12T05:13:53"}, {"OBJECT_NAME": "SHIJIAN-6 02A (SJ-6 02A)", "NORAD_CAT_ID": 29505, "MEAN_MOTION": 15.15741876, "ECCENTRICITY": 0.0004838, "INCLINATION": 97.6501, "RA_OF_ASC_NODE": 111.5144, "ARG_OF_PERICENTER": 108.2802, "MEAN_ANOMALY": 251.8957, "BSTAR": 0.00030412, "EPOCH": "2026-03-12T03:41:48"}, {"OBJECT_NAME": "SHIJIAN-30B (SJ-30B)", "NORAD_CAT_ID": 66546, "MEAN_MOTION": 15.15324679, "ECCENTRICITY": 0.0009308, "INCLINATION": 51.7963, "RA_OF_ASC_NODE": 273.8531, "ARG_OF_PERICENTER": 302.5352, "MEAN_ANOMALY": 57.4725, "BSTAR": 0.00054976, "EPOCH": "2026-03-12T04:05:30"}, {"OBJECT_NAME": "SHIJIAN-30C (SJ-30C)", "NORAD_CAT_ID": 66547, "MEAN_MOTION": 15.15426464, "ECCENTRICITY": 0.0010112, "INCLINATION": 51.7972, "RA_OF_ASC_NODE": 273.8695, "ARG_OF_PERICENTER": 293.359, "MEAN_ANOMALY": 66.6322, "BSTAR": 0.00056799, "EPOCH": "2026-03-12T04:06:56"}, {"OBJECT_NAME": "SHIJIAN-28 (SJ-28)", "NORAD_CAT_ID": 66549, "MEAN_MOTION": 1.00271051, "ECCENTRICITY": 5.52e-05, "INCLINATION": 4.7899, "RA_OF_ASC_NODE": 273.6002, "ARG_OF_PERICENTER": 173.3077, "MEAN_ANOMALY": 208.3065, "BSTAR": 0.0, "EPOCH": "2026-03-12T00:35:06"}, {"OBJECT_NAME": "SHIJIAN-29A (SJ-29A)", "NORAD_CAT_ID": 67302, "MEAN_MOTION": 1.00273721, "ECCENTRICITY": 0.0002538, "INCLINATION": 3.1501, "RA_OF_ASC_NODE": 97.6081, "ARG_OF_PERICENTER": 312.2258, "MEAN_ANOMALY": 250.0919, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:48:02"}, {"OBJECT_NAME": "SHIJIAN-29B (SJ-29B)", "NORAD_CAT_ID": 67403, "MEAN_MOTION": 1.00273223, "ECCENTRICITY": 0.000457, "INCLINATION": 3.15, "RA_OF_ASC_NODE": 97.5914, "ARG_OF_PERICENTER": 321.5925, "MEAN_ANOMALY": 240.814, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:48:02"}, {"OBJECT_NAME": "SHIJIAN-15 (SJ-15)", "NORAD_CAT_ID": 39210, "MEAN_MOTION": 14.71361998, "ECCENTRICITY": 0.0007579, "INCLINATION": 98.1059, "RA_OF_ASC_NODE": 74.6034, "ARG_OF_PERICENTER": 95.7124, "MEAN_ANOMALY": 264.4947, "BSTAR": 0.00015554999999999999, "EPOCH": "2026-03-12T04:36:44"}, {"OBJECT_NAME": "SHIJIAN-17 (SJ-17)", "NORAD_CAT_ID": 41838, "MEAN_MOTION": 0.99865292, "ECCENTRICITY": 0.0001154, "INCLINATION": 5.5051, "RA_OF_ASC_NODE": 73.7674, "ARG_OF_PERICENTER": 325.3149, "MEAN_ANOMALY": 202.8964, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:32:41"}, {"OBJECT_NAME": "SHIJIAN-21 (SJ-21)", "NORAD_CAT_ID": 49330, "MEAN_MOTION": 1.00264563, "ECCENTRICITY": 0.004799, "INCLINATION": 4.8825, "RA_OF_ASC_NODE": 61.7205, "ARG_OF_PERICENTER": 171.4284, "MEAN_ANOMALY": 137.7691, "BSTAR": 0.0, "EPOCH": "2026-03-12T04:53:09"}, {"OBJECT_NAME": "SHIJIAN-6 05A (SJ-6 05A)", "NORAD_CAT_ID": 49961, "MEAN_MOTION": 14.99099329, "ECCENTRICITY": 0.000305, "INCLINATION": 97.4011, "RA_OF_ASC_NODE": 44.5496, "ARG_OF_PERICENTER": 71.9793, "MEAN_ANOMALY": 288.1763, "BSTAR": 0.00022625000000000002, "EPOCH": "2026-03-12T05:16:19"}, {"OBJECT_NAME": "SHIJIAN-6 05B (SJ-6 05B)", "NORAD_CAT_ID": 49962, "MEAN_MOTION": 15.11459544, "ECCENTRICITY": 0.0002702, "INCLINATION": 97.3727, "RA_OF_ASC_NODE": 54.736, "ARG_OF_PERICENTER": 57.6889, "MEAN_ANOMALY": 302.4603, "BSTAR": 0.00016631000000000003, "EPOCH": "2026-03-11T17:49:18"}, {"OBJECT_NAME": "GLONASS125 [COD]", "NORAD_CAT_ID": 37372, "MEAN_MOTION": 2.13104226, "ECCENTRICITY": 0.000871, "INCLINATION": 64.8285, "RA_OF_ASC_NODE": 104.8188, "ARG_OF_PERICENTER": 293.3373, "MEAN_ANOMALY": 12.0047, "BSTAR": 0.0, "EPOCH": "2023-11-08T23:59:42"}, {"OBJECT_NAME": "CAPELLA-10-WHITNEY", "NORAD_CAT_ID": 55909, "MEAN_MOTION": 14.95890017, "ECCENTRICITY": 0.0009144, "INCLINATION": 43.9994, "RA_OF_ASC_NODE": 246.9297, "ARG_OF_PERICENTER": 166.1646, "MEAN_ANOMALY": 193.9462, "BSTAR": 0.00065599, "EPOCH": "2023-12-27T21:10:20"}, {"OBJECT_NAME": "CAPELLA-9-WHITNEY", "NORAD_CAT_ID": 55910, "MEAN_MOTION": 14.99271847, "ECCENTRICITY": 0.0009067, "INCLINATION": 43.9991, "RA_OF_ASC_NODE": 244.4088, "ARG_OF_PERICENTER": 156.0492, "MEAN_ANOMALY": 204.0788, "BSTAR": 0.0013163, "EPOCH": "2023-12-27T21:00:03"}, {"OBJECT_NAME": "CAPELLA-6-WHITNEY", "NORAD_CAT_ID": 48605, "MEAN_MOTION": 15.36992166, "ECCENTRICITY": 0.0006978, "INCLINATION": 53.0293, "RA_OF_ASC_NODE": 151.2332, "ARG_OF_PERICENTER": 275.4158, "MEAN_ANOMALY": 84.6048, "BSTAR": 0.0028544000000000004, "EPOCH": "2023-12-28T11:48:20"}, {"OBJECT_NAME": "CAPELLA-8-WHITNEY", "NORAD_CAT_ID": 51071, "MEAN_MOTION": 16.38892911, "ECCENTRICITY": 0.0015575, "INCLINATION": 97.4006, "RA_OF_ASC_NODE": 322.5782, "ARG_OF_PERICENTER": 273.5387, "MEAN_ANOMALY": 175.0653, "BSTAR": 0.0030023, "EPOCH": "2023-09-06T01:34:15"}, {"OBJECT_NAME": "CAPELLA-7-WHITNEY", "NORAD_CAT_ID": 51072, "MEAN_MOTION": 16.44927659, "ECCENTRICITY": 0.0015288, "INCLINATION": 97.3947, "RA_OF_ASC_NODE": 311.3663, "ARG_OF_PERICENTER": 273.4288, "MEAN_ANOMALY": 179.2482, "BSTAR": 0.0011137, "EPOCH": "2023-08-26T19:44:02"}, {"OBJECT_NAME": "CAPELLA-11 (ACADIA-1)", "NORAD_CAT_ID": 57693, "MEAN_MOTION": 14.80532132, "ECCENTRICITY": 0.0002385, "INCLINATION": 53.0068, "RA_OF_ASC_NODE": 247.3309, "ARG_OF_PERICENTER": 122.2368, "MEAN_ANOMALY": 237.884, "BSTAR": 0.00043216, "EPOCH": "2026-03-11T12:14:44"}, {"OBJECT_NAME": "CAPELLA-14 (ACADIA-4)", "NORAD_CAT_ID": 59444, "MEAN_MOTION": 15.1066668, "ECCENTRICITY": 0.0004072, "INCLINATION": 45.605, "RA_OF_ASC_NODE": 46.7322, "ARG_OF_PERICENTER": 130.8856, "MEAN_ANOMALY": 229.2383, "BSTAR": 0.001358, "EPOCH": "2026-03-12T05:07:10"}, {"OBJECT_NAME": "CAPELLA-13 (ACADIA-3)", "NORAD_CAT_ID": 60419, "MEAN_MOTION": 14.87655284, "ECCENTRICITY": 0.0001329, "INCLINATION": 53.0038, "RA_OF_ASC_NODE": 123.7585, "ARG_OF_PERICENTER": 89.8746, "MEAN_ANOMALY": 270.2386, "BSTAR": 0.0005938299999999999, "EPOCH": "2026-03-11T13:10:05"}, {"OBJECT_NAME": "CAPELLA-15 (ACADIA-5)", "NORAD_CAT_ID": 60544, "MEAN_MOTION": 14.92443533, "ECCENTRICITY": 0.0003899, "INCLINATION": 97.6838, "RA_OF_ASC_NODE": 145.8785, "ARG_OF_PERICENTER": 55.9996, "MEAN_ANOMALY": 304.1593, "BSTAR": -0.00029632, "EPOCH": "2026-03-11T13:39:49"}, {"OBJECT_NAME": "CAPELLA-17 (ACADIA-7)", "NORAD_CAT_ID": 64583, "MEAN_MOTION": 14.90891268, "ECCENTRICITY": 0.0004418, "INCLINATION": 97.756, "RA_OF_ASC_NODE": 186.3323, "ARG_OF_PERICENTER": 322.3151, "MEAN_ANOMALY": 37.7757, "BSTAR": 0.0010062, "EPOCH": "2026-03-12T04:32:02"}, {"OBJECT_NAME": "CAPELLA-16 (ACADIA-6)", "NORAD_CAT_ID": 65318, "MEAN_MOTION": 14.93517063, "ECCENTRICITY": 0.0004731, "INCLINATION": 97.7403, "RA_OF_ASC_NODE": 147.7642, "ARG_OF_PERICENTER": 189.5884, "MEAN_ANOMALY": 170.5246, "BSTAR": 0.0007663100000000001, "EPOCH": "2026-03-12T04:21:35"}, {"OBJECT_NAME": "CAPELLA-19 (ACADIA-9)", "NORAD_CAT_ID": 67384, "MEAN_MOTION": 14.86727012, "ECCENTRICITY": 0.0001592, "INCLINATION": 97.8008, "RA_OF_ASC_NODE": 71.1496, "ARG_OF_PERICENTER": 94.8982, "MEAN_ANOMALY": 224.979, "BSTAR": -0.00055879, "EPOCH": "2026-03-12T00:00:01"}, {"OBJECT_NAME": "CAPELLA-18 (ACADIA-8)", "NORAD_CAT_ID": 67385, "MEAN_MOTION": 14.88701634, "ECCENTRICITY": 0.0005623, "INCLINATION": 97.8015, "RA_OF_ASC_NODE": 71.2073, "ARG_OF_PERICENTER": 82.1443, "MEAN_ANOMALY": 278.0412, "BSTAR": 0.00059446, "EPOCH": "2026-03-11T23:22:27"}, {"OBJECT_NAME": "CSO-3", "NORAD_CAT_ID": 63156, "MEAN_MOTION": 14.34022377, "ECCENTRICITY": 0.0001932, "INCLINATION": 98.606, "RA_OF_ASC_NODE": 14.3262, "ARG_OF_PERICENTER": 17.427, "MEAN_ANOMALY": 342.6981, "BSTAR": 0.00016071, "EPOCH": "2025-03-13T22:37:36"}, {"OBJECT_NAME": "PLEIADES NEO 3", "NORAD_CAT_ID": 48268, "MEAN_MOTION": 14.81671425, "ECCENTRICITY": 0.0001258, "INCLINATION": 97.8935, "RA_OF_ASC_NODE": 147.3679, "ARG_OF_PERICENTER": 98.2955, "MEAN_ANOMALY": 261.84, "BSTAR": 2.2518e-05, "EPOCH": "2026-03-12T05:00:57"}, {"OBJECT_NAME": "PLEIADES NEO 4", "NORAD_CAT_ID": 49070, "MEAN_MOTION": 14.81675215, "ECCENTRICITY": 0.0001322, "INCLINATION": 97.8925, "RA_OF_ASC_NODE": 146.7977, "ARG_OF_PERICENTER": 92.5432, "MEAN_ANOMALY": 267.5932, "BSTAR": 8.8559e-05, "EPOCH": "2026-03-11T15:14:20"}, {"OBJECT_NAME": "PLEIADES 1B", "NORAD_CAT_ID": 39019, "MEAN_MOTION": 14.58529461, "ECCENTRICITY": 0.0001585, "INCLINATION": 98.2006, "RA_OF_ASC_NODE": 147.5777, "ARG_OF_PERICENTER": 85.5374, "MEAN_ANOMALY": 274.6006, "BSTAR": 6.9127e-05, "EPOCH": "2026-03-12T05:07:57"}, {"OBJECT_NAME": "PLEIADES 1A", "NORAD_CAT_ID": 38012, "MEAN_MOTION": 14.58542295, "ECCENTRICITY": 0.0001677, "INCLINATION": 98.2036, "RA_OF_ASC_NODE": 147.5771, "ARG_OF_PERICENTER": 87.2616, "MEAN_ANOMALY": 358.2854, "BSTAR": 7.3992e-05, "EPOCH": "2026-03-12T04:42:04"}, {"OBJECT_NAME": "PLEIADES YEARLING", "NORAD_CAT_ID": 56207, "MEAN_MOTION": 15.36797276, "ECCENTRICITY": 0.0012954, "INCLINATION": 97.388, "RA_OF_ASC_NODE": 256.6744, "ARG_OF_PERICENTER": 51.7533, "MEAN_ANOMALY": 308.4875, "BSTAR": 0.0010478, "EPOCH": "2023-12-28T09:58:50"}, {"OBJECT_NAME": "GEOEYE 1", "NORAD_CAT_ID": 33331, "MEAN_MOTION": 14.64770869, "ECCENTRICITY": 0.0003834, "INCLINATION": 98.1202, "RA_OF_ASC_NODE": 146.3016, "ARG_OF_PERICENTER": 21.6179, "MEAN_ANOMALY": 338.5185, "BSTAR": 0.00012727, "EPOCH": "2026-03-12T03:29:57"}, {"OBJECT_NAME": "TANDEM-X", "NORAD_CAT_ID": 36605, "MEAN_MOTION": 15.19152049, "ECCENTRICITY": 0.0001839, "INCLINATION": 97.4475, "RA_OF_ASC_NODE": 79.6057, "ARG_OF_PERICENTER": 96.5354, "MEAN_ANOMALY": 263.609, "BSTAR": 8.376500000000001e-05, "EPOCH": "2026-03-11T12:13:02"}, {"OBJECT_NAME": "ICEYE-X9", "NORAD_CAT_ID": 47506, "MEAN_MOTION": 15.28271202, "ECCENTRICITY": 0.0008413, "INCLINATION": 97.3622, "RA_OF_ASC_NODE": 54.3082, "ARG_OF_PERICENTER": 126.886, "MEAN_ANOMALY": 233.3152, "BSTAR": 0.0010076, "EPOCH": "2023-12-28T11:08:25"}, {"OBJECT_NAME": "XR-1 (ICEYE-X10)", "NORAD_CAT_ID": 47507, "MEAN_MOTION": 15.22197725, "ECCENTRICITY": 0.001392, "INCLINATION": 97.3689, "RA_OF_ASC_NODE": 55.932, "ARG_OF_PERICENTER": 134.347, "MEAN_ANOMALY": 225.891, "BSTAR": 0.00062135, "EPOCH": "2023-12-28T04:01:27"}, {"OBJECT_NAME": "ICEYE-X8", "NORAD_CAT_ID": 47510, "MEAN_MOTION": 15.26805421, "ECCENTRICITY": 0.0007492, "INCLINATION": 97.3641, "RA_OF_ASC_NODE": 54.4014, "ARG_OF_PERICENTER": 124.0674, "MEAN_ANOMALY": 236.1278, "BSTAR": 0.00062244, "EPOCH": "2023-12-28T10:34:23"}, {"OBJECT_NAME": "ICEYE-X20", "NORAD_CAT_ID": 52759, "MEAN_MOTION": 15.31342668, "ECCENTRICITY": 0.0009251, "INCLINATION": 97.5547, "RA_OF_ASC_NODE": 121.6431, "ARG_OF_PERICENTER": 23.0489, "MEAN_ANOMALY": 337.1165, "BSTAR": 0.00093185, "EPOCH": "2023-12-28T10:29:34"}, {"OBJECT_NAME": "ICEYE-X17", "NORAD_CAT_ID": 52762, "MEAN_MOTION": 15.2124607, "ECCENTRICITY": 0.0013644, "INCLINATION": 97.5558, "RA_OF_ASC_NODE": 120.8021, "ARG_OF_PERICENTER": 1.6767, "MEAN_ANOMALY": 358.4512, "BSTAR": 0.00057881, "EPOCH": "2023-12-28T09:08:20"}, {"OBJECT_NAME": "ICEYE-X27", "NORAD_CAT_ID": 55062, "MEAN_MOTION": 15.21915655, "ECCENTRICITY": 0.0013054, "INCLINATION": 97.4564, "RA_OF_ASC_NODE": 59.6015, "ARG_OF_PERICENTER": 58.5293, "MEAN_ANOMALY": 301.7217, "BSTAR": 0.00058627, "EPOCH": "2023-12-28T03:09:32"}, {"OBJECT_NAME": "ICEYE-X12", "NORAD_CAT_ID": 48914, "MEAN_MOTION": 15.19543383, "ECCENTRICITY": 0.0001769, "INCLINATION": 97.6141, "RA_OF_ASC_NODE": 134.5462, "ARG_OF_PERICENTER": 318.3531, "MEAN_ANOMALY": 41.7569, "BSTAR": 0.0007832000000000001, "EPOCH": "2023-12-28T10:18:56"}, {"OBJECT_NAME": "ICEYE-X13", "NORAD_CAT_ID": 48916, "MEAN_MOTION": 15.18467295, "ECCENTRICITY": 0.0006345, "INCLINATION": 97.6161, "RA_OF_ASC_NODE": 135.3491, "ARG_OF_PERICENTER": 6.2034, "MEAN_ANOMALY": 353.9277, "BSTAR": 0.00073797, "EPOCH": "2023-12-28T11:30:27"}, {"OBJECT_NAME": "ICEYE-X15", "NORAD_CAT_ID": 48917, "MEAN_MOTION": 15.30363767, "ECCENTRICITY": 0.0010194, "INCLINATION": 97.6127, "RA_OF_ASC_NODE": 142.3594, "ARG_OF_PERICENTER": 272.128, "MEAN_ANOMALY": 87.8793, "BSTAR": 0.00079595, "EPOCH": "2023-12-28T10:33:29"}, {"OBJECT_NAME": "ICEYE-X16", "NORAD_CAT_ID": 51008, "MEAN_MOTION": 15.3391081, "ECCENTRICITY": 0.0005633, "INCLINATION": 97.4149, "RA_OF_ASC_NODE": 68.7598, "ARG_OF_PERICENTER": 271.0358, "MEAN_ANOMALY": 89.0239, "BSTAR": 0.0009538, "EPOCH": "2023-12-28T10:17:07"}, {"OBJECT_NAME": "ICEYE-X14", "NORAD_CAT_ID": 51070, "MEAN_MOTION": 15.19599986, "ECCENTRICITY": 0.0006515, "INCLINATION": 97.418, "RA_OF_ASC_NODE": 67.3206, "ARG_OF_PERICENTER": 19.2413, "MEAN_ANOMALY": 340.9067, "BSTAR": 0.00061029, "EPOCH": "2023-12-28T10:10:51"}, {"OBJECT_NAME": "CARCARA 1 (ICEYE-X18)", "NORAD_CAT_ID": 52749, "MEAN_MOTION": 15.22164983, "ECCENTRICITY": 0.0015759, "INCLINATION": 97.5512, "RA_OF_ASC_NODE": 118.1578, "ARG_OF_PERICENTER": 31.6119, "MEAN_ANOMALY": 328.606, "BSTAR": 0.0010144, "EPOCH": "2023-12-28T11:11:43"}, {"OBJECT_NAME": "ICEYE-X24", "NORAD_CAT_ID": 52755, "MEAN_MOTION": 15.27727758, "ECCENTRICITY": 0.0003109, "INCLINATION": 97.5567, "RA_OF_ASC_NODE": 121.2112, "ARG_OF_PERICENTER": 212.0851, "MEAN_ANOMALY": 148.0199, "BSTAR": 0.0010204, "EPOCH": "2023-12-28T10:06:30"}, {"OBJECT_NAME": "CARCARA 2 (ICEYE-X19)", "NORAD_CAT_ID": 52758, "MEAN_MOTION": 15.2367522, "ECCENTRICITY": 0.0016139, "INCLINATION": 97.5536, "RA_OF_ASC_NODE": 118.8008, "ARG_OF_PERICENTER": 18.762, "MEAN_ANOMALY": 341.4208, "BSTAR": 0.00097146, "EPOCH": "2023-12-28T11:28:53"}, {"OBJECT_NAME": "ICEYE-X57", "NORAD_CAT_ID": 64578, "MEAN_MOTION": 15.00889527, "ECCENTRICITY": 0.0002265, "INCLINATION": 97.7626, "RA_OF_ASC_NODE": 187.5625, "ARG_OF_PERICENTER": 73.3026, "MEAN_ANOMALY": 286.8446, "BSTAR": 0.0005237600000000001, "EPOCH": "2026-03-12T04:42:07"}, {"OBJECT_NAME": "ICEYE-X4", "NORAD_CAT_ID": 44390, "MEAN_MOTION": 15.27047711, "ECCENTRICITY": 0.001224, "INCLINATION": 97.8887, "RA_OF_ASC_NODE": 98.5467, "ARG_OF_PERICENTER": 285.0939, "MEAN_ANOMALY": 74.8944, "BSTAR": 0.00048293, "EPOCH": "2026-03-12T05:22:15"}, {"OBJECT_NAME": "ICEYE-X55", "NORAD_CAT_ID": 64581, "MEAN_MOTION": 14.94532114, "ECCENTRICITY": 0.0002167, "INCLINATION": 97.7608, "RA_OF_ASC_NODE": 186.9824, "ARG_OF_PERICENTER": 323.6964, "MEAN_ANOMALY": 36.4109, "BSTAR": 0.00041395, "EPOCH": "2026-03-12T05:49:30"}, {"OBJECT_NAME": "ICEYE-X33", "NORAD_CAT_ID": 60548, "MEAN_MOTION": 15.01161655, "ECCENTRICITY": 0.000602, "INCLINATION": 97.7035, "RA_OF_ASC_NODE": 150.9978, "ARG_OF_PERICENTER": 115.6574, "MEAN_ANOMALY": 244.5272, "BSTAR": 0.00040971000000000003, "EPOCH": "2026-03-12T04:15:43"}, {"OBJECT_NAME": "ICEYE-X40", "NORAD_CAT_ID": 60549, "MEAN_MOTION": 14.97573552, "ECCENTRICITY": 0.0005986, "INCLINATION": 97.6861, "RA_OF_ASC_NODE": 149.2244, "ARG_OF_PERICENTER": 58.452, "MEAN_ANOMALY": 301.7286, "BSTAR": 0.00028786, "EPOCH": "2026-03-12T03:57:02"}, {"OBJECT_NAME": "ICEYE-X21", "NORAD_CAT_ID": 55049, "MEAN_MOTION": 15.77175024, "ECCENTRICITY": 0.0003604, "INCLINATION": 97.3384, "RA_OF_ASC_NODE": 150.2359, "ARG_OF_PERICENTER": 66.1518, "MEAN_ANOMALY": 294.0126, "BSTAR": 0.00055357, "EPOCH": "2026-03-12T04:48:41"}] \ No newline at end of file +[{"OBJECT_NAME": "JILIN-1 GAOFEN 2B", "NORAD_CAT_ID": 44836, "MEAN_MOTION": 15.19498072, "ECCENTRICITY": 0.0012973, "INCLINATION": 97.496, "RA_OF_ASC_NODE": 144.6227, "ARG_OF_PERICENTER": 297.4393, "MEAN_ANOMALY": 62.5521, "BSTAR": 0.00033667, "EPOCH": "2026-03-12T23:25:12"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 4A", "NORAD_CAT_ID": 52388, "MEAN_MOTION": 16.28950252, "ECCENTRICITY": 0.000805, "INCLINATION": 97.4754, "RA_OF_ASC_NODE": 100.0865, "ARG_OF_PERICENTER": 290.668, "MEAN_ANOMALY": 69.375, "BSTAR": 0.0010643, "EPOCH": "2025-12-16T07:00:53"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2A", "NORAD_CAT_ID": 44777, "MEAN_MOTION": 15.3653115, "ECCENTRICITY": 0.0007611, "INCLINATION": 97.5003, "RA_OF_ASC_NODE": 153.7033, "ARG_OF_PERICENTER": 75.4328, "MEAN_ANOMALY": 284.7759, "BSTAR": 0.00049089, "EPOCH": "2026-03-12T23:19:57"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D07", "NORAD_CAT_ID": 52392, "MEAN_MOTION": 16.37606451, "ECCENTRICITY": 0.0008479, "INCLINATION": 97.4719, "RA_OF_ASC_NODE": 145.145, "ARG_OF_PERICENTER": 283.4988, "MEAN_ANOMALY": 76.5365, "BSTAR": 0.0008378600000000001, "EPOCH": "2026-01-25T14:26:58"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3C", "NORAD_CAT_ID": 46455, "MEAN_MOTION": 16.4332105, "ECCENTRICITY": 0.0008886, "INCLINATION": 97.224, "RA_OF_ASC_NODE": 102.405, "ARG_OF_PERICENTER": 228.0638, "MEAN_ANOMALY": 131.9907, "BSTAR": 0.00043027, "EPOCH": "2026-02-10T19:53:25"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D", "NORAD_CAT_ID": 46456, "MEAN_MOTION": 15.21343265, "ECCENTRICITY": 0.0011568, "INCLINATION": 97.3728, "RA_OF_ASC_NODE": 46.7737, "ARG_OF_PERICENTER": 112.6941, "MEAN_ANOMALY": 247.5519, "BSTAR": 0.00055382, "EPOCH": "2023-12-28T11:38:25"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D32", "NORAD_CAT_ID": 52449, "MEAN_MOTION": 15.2053231, "ECCENTRICITY": 0.0011601, "INCLINATION": 97.6185, "RA_OF_ASC_NODE": 74.9126, "ARG_OF_PERICENTER": 14.3554, "MEAN_ANOMALY": 345.8008, "BSTAR": 0.00056673, "EPOCH": "2023-12-28T10:45:20"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D33", "NORAD_CAT_ID": 52450, "MEAN_MOTION": 15.20833127, "ECCENTRICITY": 0.0013609, "INCLINATION": 97.6181, "RA_OF_ASC_NODE": 74.9956, "ARG_OF_PERICENTER": 10.9961, "MEAN_ANOMALY": 349.1568, "BSTAR": 0.00056472, "EPOCH": "2023-12-28T10:56:13"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D18", "NORAD_CAT_ID": 51844, "MEAN_MOTION": 15.77584928, "ECCENTRICITY": 0.0003896, "INCLINATION": 97.3668, "RA_OF_ASC_NODE": 163.7334, "ARG_OF_PERICENTER": 138.3279, "MEAN_ANOMALY": 221.8285, "BSTAR": 0.0010383, "EPOCH": "2026-03-12T06:23:35"}, {"OBJECT_NAME": "JILIN-1 06", "NORAD_CAT_ID": 43024, "MEAN_MOTION": 15.32497226, "ECCENTRICITY": 0.0006327, "INCLINATION": 97.4715, "RA_OF_ASC_NODE": 174.0678, "ARG_OF_PERICENTER": 211.0083, "MEAN_ANOMALY": 149.0786, "BSTAR": 0.00027117000000000005, "EPOCH": "2026-03-12T22:52:10"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3J", "NORAD_CAT_ID": 46462, "MEAN_MOTION": 15.76537835, "ECCENTRICITY": 0.0003956, "INCLINATION": 97.239, "RA_OF_ASC_NODE": 129.0062, "ARG_OF_PERICENTER": 131.4959, "MEAN_ANOMALY": 228.6647, "BSTAR": 0.00096192, "EPOCH": "2026-03-12T23:38:59"}, {"OBJECT_NAME": "JILIN-1 09", "NORAD_CAT_ID": 43943, "MEAN_MOTION": 15.1955602, "ECCENTRICITY": 0.000365, "INCLINATION": 97.4702, "RA_OF_ASC_NODE": 167.8157, "ARG_OF_PERICENTER": 81.0037, "MEAN_ANOMALY": 279.1611, "BSTAR": 0.00031630000000000004, "EPOCH": "2026-03-12T22:31:20"}, {"OBJECT_NAME": "JILIN-1 10", "NORAD_CAT_ID": 43946, "MEAN_MOTION": 15.24510683, "ECCENTRICITY": 0.0007015, "INCLINATION": 97.4732, "RA_OF_ASC_NODE": 170.8685, "ARG_OF_PERICENTER": 252.0458, "MEAN_ANOMALY": 108.0015, "BSTAR": 0.00034032, "EPOCH": "2026-03-12T22:38:58"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 03D54", "NORAD_CAT_ID": 54254, "MEAN_MOTION": 15.68916326, "ECCENTRICITY": 0.0002938, "INCLINATION": 97.6015, "RA_OF_ASC_NODE": 215.0398, "ARG_OF_PERICENTER": 308.3699, "MEAN_ANOMALY": 51.7297, "BSTAR": 0.00086729, "EPOCH": "2026-03-12T23:34:49"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D05", "NORAD_CAT_ID": 52390, "MEAN_MOTION": 15.80844877, "ECCENTRICITY": 0.0003522, "INCLINATION": 97.4773, "RA_OF_ASC_NODE": 186.4584, "ARG_OF_PERICENTER": 235.5289, "MEAN_ANOMALY": 124.5646, "BSTAR": 0.0011125, "EPOCH": "2026-03-12T20:08:44"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3A", "NORAD_CAT_ID": 44312, "MEAN_MOTION": 15.30366874, "ECCENTRICITY": 0.0002781, "INCLINATION": 44.9739, "RA_OF_ASC_NODE": 166.8187, "ARG_OF_PERICENTER": 132.8402, "MEAN_ANOMALY": 227.2715, "BSTAR": 0.00051061, "EPOCH": "2026-03-12T17:57:20"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D03", "NORAD_CAT_ID": 49006, "MEAN_MOTION": 15.81428509, "ECCENTRICITY": 0.0001547, "INCLINATION": 97.3012, "RA_OF_ASC_NODE": 149.1741, "ARG_OF_PERICENTER": 149.107, "MEAN_ANOMALY": 211.029, "BSTAR": 0.0010519000000000001, "EPOCH": "2026-03-12T23:24:16"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2D", "NORAD_CAT_ID": 49256, "MEAN_MOTION": 15.09759641, "ECCENTRICITY": 0.0035124, "INCLINATION": 97.6324, "RA_OF_ASC_NODE": 194.9111, "ARG_OF_PERICENTER": 113.7528, "MEAN_ANOMALY": 246.7391, "BSTAR": 0.00045155, "EPOCH": "2026-03-12T22:54:11"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 2F", "NORAD_CAT_ID": 49338, "MEAN_MOTION": 15.06971512, "ECCENTRICITY": 0.0018912, "INCLINATION": 97.6533, "RA_OF_ASC_NODE": 198.5605, "ARG_OF_PERICENTER": 191.4083, "MEAN_ANOMALY": 168.6718, "BSTAR": 0.00035243000000000004, "EPOCH": "2026-03-12T23:33:39"}, {"OBJECT_NAME": "JILIN-1 GAOFEN 3D27", "NORAD_CAT_ID": 52444, "MEAN_MOTION": 15.59177403, "ECCENTRICITY": 0.0004073, "INCLINATION": 97.5631, "RA_OF_ASC_NODE": 178.5839, "ARG_OF_PERICENTER": 41.1796, "MEAN_ANOMALY": 318.9766, "BSTAR": 0.00090932, "EPOCH": "2026-03-12T22:56:39"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-2 (C39)", "NORAD_CAT_ID": 44337, "MEAN_MOTION": 1.00247364, "ECCENTRICITY": 0.0038734, "INCLINATION": 55.2338, "RA_OF_ASC_NODE": 159.8353, "ARG_OF_PERICENTER": 206.4752, "MEAN_ANOMALY": 153.7223, "BSTAR": 0.0, "EPOCH": "2026-02-26T16:31:51"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-3 (C40)", "NORAD_CAT_ID": 44709, "MEAN_MOTION": 1.00267242, "ECCENTRICITY": 0.0040728, "INCLINATION": 54.9673, "RA_OF_ASC_NODE": 281.9576, "ARG_OF_PERICENTER": 188.7089, "MEAN_ANOMALY": 171.2538, "BSTAR": 0.0, "EPOCH": "2026-03-10T23:36:11"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-1 (C06)", "NORAD_CAT_ID": 36828, "MEAN_MOTION": 1.00250454, "ECCENTRICITY": 0.0054306, "INCLINATION": 54.2928, "RA_OF_ASC_NODE": 163.8363, "ARG_OF_PERICENTER": 219.2821, "MEAN_ANOMALY": 130.2701, "BSTAR": 0.0, "EPOCH": "2026-03-12T15:54:42"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-2 (C07)", "NORAD_CAT_ID": 37256, "MEAN_MOTION": 1.00257833, "ECCENTRICITY": 0.004749, "INCLINATION": 47.7215, "RA_OF_ASC_NODE": 273.0501, "ARG_OF_PERICENTER": 213.2827, "MEAN_ANOMALY": 326.5355, "BSTAR": 0.0, "EPOCH": "2026-03-10T11:51:55"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-3 (C08)", "NORAD_CAT_ID": 37384, "MEAN_MOTION": 1.00293315, "ECCENTRICITY": 0.0035062, "INCLINATION": 62.2726, "RA_OF_ASC_NODE": 41.1265, "ARG_OF_PERICENTER": 190.7769, "MEAN_ANOMALY": 297.326, "BSTAR": 0.0, "EPOCH": "2026-03-05T17:18:14"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-4 (C09)", "NORAD_CAT_ID": 37763, "MEAN_MOTION": 1.00264423, "ECCENTRICITY": 0.0155085, "INCLINATION": 54.5894, "RA_OF_ASC_NODE": 166.5549, "ARG_OF_PERICENTER": 231.062, "MEAN_ANOMALY": 149.1617, "BSTAR": 0.0, "EPOCH": "2026-03-11T18:52:33"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-5 (C10)", "NORAD_CAT_ID": 37948, "MEAN_MOTION": 1.0027097, "ECCENTRICITY": 0.0108821, "INCLINATION": 47.854, "RA_OF_ASC_NODE": 272.7388, "ARG_OF_PERICENTER": 221.1038, "MEAN_ANOMALY": 318.3286, "BSTAR": 0.0, "EPOCH": "2026-03-12T12:27:44"}, {"OBJECT_NAME": "BEIDOU-3S IGSO-1S (C31)", "NORAD_CAT_ID": 40549, "MEAN_MOTION": 1.00272545, "ECCENTRICITY": 0.0035578, "INCLINATION": 49.3752, "RA_OF_ASC_NODE": 296.6251, "ARG_OF_PERICENTER": 190.2427, "MEAN_ANOMALY": 352.4516, "BSTAR": 0.0, "EPOCH": "2026-03-12T14:10:39"}, {"OBJECT_NAME": "BEIDOU-3S IGSO-2S (C56)", "NORAD_CAT_ID": 40938, "MEAN_MOTION": 1.00254296, "ECCENTRICITY": 0.0061515, "INCLINATION": 49.4373, "RA_OF_ASC_NODE": 260.4556, "ARG_OF_PERICENTER": 187.1638, "MEAN_ANOMALY": 17.4903, "BSTAR": 0.0, "EPOCH": "2026-01-27T16:14:57"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-6 (C13)", "NORAD_CAT_ID": 41434, "MEAN_MOTION": 1.00277411, "ECCENTRICITY": 0.0059566, "INCLINATION": 60.0969, "RA_OF_ASC_NODE": 39.005, "ARG_OF_PERICENTER": 233.2714, "MEAN_ANOMALY": 308.7278, "BSTAR": 0.0, "EPOCH": "2026-03-11T21:02:52"}, {"OBJECT_NAME": "BEIDOU-2 IGSO-7 (C16)", "NORAD_CAT_ID": 43539, "MEAN_MOTION": 1.0028088, "ECCENTRICITY": 0.0100568, "INCLINATION": 55.1864, "RA_OF_ASC_NODE": 163.9036, "ARG_OF_PERICENTER": 236.8107, "MEAN_ANOMALY": 121.9133, "BSTAR": 0.0, "EPOCH": "2026-03-12T15:58:29"}, {"OBJECT_NAME": "BEIDOU-3 IGSO-1 (C38)", "NORAD_CAT_ID": 44204, "MEAN_MOTION": 1.00268724, "ECCENTRICITY": 0.00257, "INCLINATION": 58.5658, "RA_OF_ASC_NODE": 38.6399, "ARG_OF_PERICENTER": 236.059, "MEAN_ANOMALY": 334.6197, "BSTAR": 0.0, "EPOCH": "2026-03-12T21:19:16"}, {"OBJECT_NAME": "PLEIADES NEO 4", "NORAD_CAT_ID": 49070, "MEAN_MOTION": 14.81673234, "ECCENTRICITY": 0.0001352, "INCLINATION": 97.8929, "RA_OF_ASC_NODE": 148.1288, "ARG_OF_PERICENTER": 93.7225, "MEAN_ANOMALY": 266.4143, "BSTAR": 6.8709000000000006e-06, "EPOCH": "2026-03-12T23:39:16"}, {"OBJECT_NAME": "PLEIADES NEO 3", "NORAD_CAT_ID": 48268, "MEAN_MOTION": 14.81670227, "ECCENTRICITY": 0.0001274, "INCLINATION": 97.8927, "RA_OF_ASC_NODE": 148.0992, "ARG_OF_PERICENTER": 93.6542, "MEAN_ANOMALY": 266.4817, "BSTAR": -2.0012e-05, "EPOCH": "2026-03-12T22:50:40"}, {"OBJECT_NAME": "PLEIADES 1B", "NORAD_CAT_ID": 39019, "MEAN_MOTION": 14.58529402, "ECCENTRICITY": 0.0001607, "INCLINATION": 98.2006, "RA_OF_ASC_NODE": 148.3225, "ARG_OF_PERICENTER": 84.6695, "MEAN_ANOMALY": 275.4688, "BSTAR": 3.7744e-05, "EPOCH": "2026-03-12T23:14:37"}, {"OBJECT_NAME": "PLEIADES 1A", "NORAD_CAT_ID": 38012, "MEAN_MOTION": 14.58542777, "ECCENTRICITY": 0.0001684, "INCLINATION": 98.2035, "RA_OF_ASC_NODE": 148.3969, "ARG_OF_PERICENTER": 87.0333, "MEAN_ANOMALY": 35.4975, "BSTAR": 6.987100000000001e-05, "EPOCH": "2026-03-13T00:37:40"}, {"OBJECT_NAME": "PLEIADES YEARLING", "NORAD_CAT_ID": 56207, "MEAN_MOTION": 15.36797276, "ECCENTRICITY": 0.0012954, "INCLINATION": 97.388, "RA_OF_ASC_NODE": 256.6744, "ARG_OF_PERICENTER": 51.7533, "MEAN_ANOMALY": 308.4875, "BSTAR": 0.0010478, "EPOCH": "2023-12-28T09:58:50"}, {"OBJECT_NAME": "SKYSAT-C17", "NORAD_CAT_ID": 46179, "MEAN_MOTION": 15.7911991, "ECCENTRICITY": 0.0006126, "INCLINATION": 52.97, "RA_OF_ASC_NODE": 214.8332, "ARG_OF_PERICENTER": 89.9494, "MEAN_ANOMALY": 270.2227, "BSTAR": 0.00064037, "EPOCH": "2023-12-27T20:37:45"}, {"OBJECT_NAME": "SKYSAT-C18", "NORAD_CAT_ID": 46180, "MEAN_MOTION": 16.35747614, "ECCENTRICITY": 0.0017318, "INCLINATION": 52.9561, "RA_OF_ASC_NODE": 11.8431, "ARG_OF_PERICENTER": 265.4879, "MEAN_ANOMALY": 104.1762, "BSTAR": 0.0015942999999999999, "EPOCH": "2023-06-25T20:09:47"}, {"OBJECT_NAME": "SKYSAT-C16", "NORAD_CAT_ID": 45789, "MEAN_MOTION": 15.53303284, "ECCENTRICITY": 0.000551, "INCLINATION": 52.9788, "RA_OF_ASC_NODE": 133.7921, "ARG_OF_PERICENTER": 2.6753, "MEAN_ANOMALY": 357.4284, "BSTAR": 0.0005727, "EPOCH": "2023-12-28T12:24:10"}, {"OBJECT_NAME": "SKYSAT-C15", "NORAD_CAT_ID": 45790, "MEAN_MOTION": 15.47687342, "ECCENTRICITY": 0.0003931, "INCLINATION": 52.9782, "RA_OF_ASC_NODE": 154.0912, "ARG_OF_PERICENTER": 180.8503, "MEAN_ANOMALY": 179.2497, "BSTAR": 0.00058075, "EPOCH": "2023-12-27T15:49:43"}, {"OBJECT_NAME": "SKYSAT-C19", "NORAD_CAT_ID": 46235, "MEAN_MOTION": 15.89514628, "ECCENTRICITY": 0.0002149, "INCLINATION": 52.9675, "RA_OF_ASC_NODE": 201.9813, "ARG_OF_PERICENTER": 132.3648, "MEAN_ANOMALY": 227.7558, "BSTAR": 0.0007014899999999999, "EPOCH": "2023-12-27T23:17:19"}, {"OBJECT_NAME": "SKYSAT-C14", "NORAD_CAT_ID": 45788, "MEAN_MOTION": 15.53833173, "ECCENTRICITY": 0.001113, "INCLINATION": 52.9777, "RA_OF_ASC_NODE": 149.837, "ARG_OF_PERICENTER": 213.0476, "MEAN_ANOMALY": 146.9838, "BSTAR": 0.00062044, "EPOCH": "2023-12-27T15:51:00"}, {"OBJECT_NAME": "SKYSAT-C9", "NORAD_CAT_ID": 42989, "MEAN_MOTION": 15.38988377, "ECCENTRICITY": 0.0009875, "INCLINATION": 97.4263, "RA_OF_ASC_NODE": 207.5622, "ARG_OF_PERICENTER": 123.6852, "MEAN_ANOMALY": 236.5336, "BSTAR": 0.00044674000000000005, "EPOCH": "2026-03-12T09:05:43"}, {"OBJECT_NAME": "SKYSAT-A", "NORAD_CAT_ID": 39418, "MEAN_MOTION": 15.12379022, "ECCENTRICITY": 0.0020069, "INCLINATION": 97.3952, "RA_OF_ASC_NODE": 124.1299, "ARG_OF_PERICENTER": 289.4072, "MEAN_ANOMALY": 70.499, "BSTAR": 0.0002419, "EPOCH": "2026-03-12T16:17:48"}, {"OBJECT_NAME": "SKYSAT-B", "NORAD_CAT_ID": 40072, "MEAN_MOTION": 14.87741203, "ECCENTRICITY": 0.0004526, "INCLINATION": 98.3727, "RA_OF_ASC_NODE": 25.3764, "ARG_OF_PERICENTER": 220.9442, "MEAN_ANOMALY": 139.1434, "BSTAR": 0.00026118999999999996, "EPOCH": "2026-03-12T19:45:38"}, {"OBJECT_NAME": "SKYSAT-C1", "NORAD_CAT_ID": 41601, "MEAN_MOTION": 15.34579698, "ECCENTRICITY": 0.0003318, "INCLINATION": 96.9703, "RA_OF_ASC_NODE": 108.6412, "ARG_OF_PERICENTER": 46.8686, "MEAN_ANOMALY": 313.2835, "BSTAR": 0.00030730000000000004, "EPOCH": "2026-03-12T15:14:45"}, {"OBJECT_NAME": "SKYSAT-C4", "NORAD_CAT_ID": 41771, "MEAN_MOTION": 15.45499958, "ECCENTRICITY": 0.0001169, "INCLINATION": 96.9291, "RA_OF_ASC_NODE": 97.9154, "ARG_OF_PERICENTER": 127.1673, "MEAN_ANOMALY": 232.9684, "BSTAR": 0.00038268000000000004, "EPOCH": "2026-03-12T22:58:59"}, {"OBJECT_NAME": "SKYSAT-C5", "NORAD_CAT_ID": 41772, "MEAN_MOTION": 15.3435329, "ECCENTRICITY": 0.0001665, "INCLINATION": 97.0615, "RA_OF_ASC_NODE": 116.5685, "ARG_OF_PERICENTER": 86.9951, "MEAN_ANOMALY": 273.1483, "BSTAR": 0.00028166000000000004, "EPOCH": "2026-03-12T15:07:05"}, {"OBJECT_NAME": "SKYSAT-C2", "NORAD_CAT_ID": 41773, "MEAN_MOTION": 15.39788112, "ECCENTRICITY": 0.0004844, "INCLINATION": 97.0282, "RA_OF_ASC_NODE": 97.4666, "ARG_OF_PERICENTER": 62.0869, "MEAN_ANOMALY": 298.0868, "BSTAR": 0.00034027000000000005, "EPOCH": "2026-03-12T15:11:12"}, {"OBJECT_NAME": "SKYSAT-C3", "NORAD_CAT_ID": 41774, "MEAN_MOTION": 15.48464956, "ECCENTRICITY": 0.0001758, "INCLINATION": 96.9179, "RA_OF_ASC_NODE": 106.3442, "ARG_OF_PERICENTER": 112.3442, "MEAN_ANOMALY": 247.7996, "BSTAR": 0.00041287, "EPOCH": "2026-03-12T14:17:33"}, {"OBJECT_NAME": "SKYSAT-C11", "NORAD_CAT_ID": 42987, "MEAN_MOTION": 15.52628, "ECCENTRICITY": 5.32e-05, "INCLINATION": 97.4206, "RA_OF_ASC_NODE": 216.8986, "ARG_OF_PERICENTER": 77.8422, "MEAN_ANOMALY": 282.2891, "BSTAR": 0.0005607499999999999, "EPOCH": "2026-03-12T19:27:07"}, {"OBJECT_NAME": "SKYSAT-C10", "NORAD_CAT_ID": 42988, "MEAN_MOTION": 15.32763192, "ECCENTRICITY": 9.9e-05, "INCLINATION": 97.4337, "RA_OF_ASC_NODE": 207.9265, "ARG_OF_PERICENTER": 93.6981, "MEAN_ANOMALY": 266.4374, "BSTAR": 0.00040126, "EPOCH": "2026-03-12T19:12:20"}, {"OBJECT_NAME": "SKYSAT-C8", "NORAD_CAT_ID": 42990, "MEAN_MOTION": 15.34849583, "ECCENTRICITY": 0.000121, "INCLINATION": 97.4356, "RA_OF_ASC_NODE": 206.8952, "ARG_OF_PERICENTER": 95.1947, "MEAN_ANOMALY": 264.9434, "BSTAR": 0.00041110999999999996, "EPOCH": "2026-03-12T07:46:36"}, {"OBJECT_NAME": "SKYSAT-C7", "NORAD_CAT_ID": 42991, "MEAN_MOTION": 15.34815519, "ECCENTRICITY": 0.0007304, "INCLINATION": 97.4367, "RA_OF_ASC_NODE": 208.3947, "ARG_OF_PERICENTER": 94.2199, "MEAN_ANOMALY": 265.9879, "BSTAR": 0.00039872999999999997, "EPOCH": "2026-03-12T20:03:31"}, {"OBJECT_NAME": "SKYSAT-C6", "NORAD_CAT_ID": 42992, "MEAN_MOTION": 15.32572512, "ECCENTRICITY": 0.0003714, "INCLINATION": 97.4399, "RA_OF_ASC_NODE": 208.2118, "ARG_OF_PERICENTER": 121.7106, "MEAN_ANOMALY": 238.4499, "BSTAR": 0.0004073, "EPOCH": "2026-03-12T09:06:40"}, {"OBJECT_NAME": "SKYSAT-C12", "NORAD_CAT_ID": 43797, "MEAN_MOTION": 15.45733636, "ECCENTRICITY": 0.0005818, "INCLINATION": 96.9381, "RA_OF_ASC_NODE": 99.2636, "ARG_OF_PERICENTER": 129.5455, "MEAN_ANOMALY": 230.6311, "BSTAR": 0.00040006000000000004, "EPOCH": "2026-03-12T23:53:37"}, {"OBJECT_NAME": "TANDEM-X", "NORAD_CAT_ID": 36605, "MEAN_MOTION": 15.19155964, "ECCENTRICITY": 0.0001864, "INCLINATION": 97.4475, "RA_OF_ASC_NODE": 80.6448, "ARG_OF_PERICENTER": 96.5465, "MEAN_ANOMALY": 263.5981, "BSTAR": 8.3275e-05, "EPOCH": "2026-03-12T13:30:38"}, {"OBJECT_NAME": "GLONASS125 [COD]", "NORAD_CAT_ID": 37372, "MEAN_MOTION": 2.13104226, "ECCENTRICITY": 0.000871, "INCLINATION": 64.8285, "RA_OF_ASC_NODE": 104.8188, "ARG_OF_PERICENTER": 293.3373, "MEAN_ANOMALY": 12.0047, "BSTAR": 0.0, "EPOCH": "2023-11-08T23:59:42"}, {"OBJECT_NAME": "DMSP 5D-3 F18 (USA 210)", "NORAD_CAT_ID": 35951, "MEAN_MOTION": 14.14838255, "ECCENTRICITY": 0.0011316, "INCLINATION": 98.8967, "RA_OF_ASC_NODE": 52.9838, "ARG_OF_PERICENTER": 159.0723, "MEAN_ANOMALY": 201.0915, "BSTAR": 0.00018853, "EPOCH": "2026-03-12T23:10:14"}, {"OBJECT_NAME": "AEHF-1 (USA 214)", "NORAD_CAT_ID": 36868, "MEAN_MOTION": 1.00269589, "ECCENTRICITY": 0.0004343, "INCLINATION": 7.9805, "RA_OF_ASC_NODE": 69.3033, "ARG_OF_PERICENTER": 282.3643, "MEAN_ANOMALY": 82.2041, "BSTAR": 0.0, "EPOCH": "2026-03-12T15:34:31"}, {"OBJECT_NAME": "AEHF-2 (USA 235)", "NORAD_CAT_ID": 38254, "MEAN_MOTION": 1.00272809, "ECCENTRICITY": 0.0003131, "INCLINATION": 6.4271, "RA_OF_ASC_NODE": 58.1858, "ARG_OF_PERICENTER": 282.1904, "MEAN_ANOMALY": 95.2392, "BSTAR": 0.0, "EPOCH": "2026-03-12T18:46:43"}, {"OBJECT_NAME": "AEHF-5 (USA 292)", "NORAD_CAT_ID": 44481, "MEAN_MOTION": 1.00269627, "ECCENTRICITY": 0.0071225, "INCLINATION": 1.5539, "RA_OF_ASC_NODE": 329.2482, "ARG_OF_PERICENTER": 3.3882, "MEAN_ANOMALY": 220.3078, "BSTAR": 0.0, "EPOCH": "2026-03-12T19:17:47"}, {"OBJECT_NAME": "AEHF-4 (USA 288)", "NORAD_CAT_ID": 43651, "MEAN_MOTION": 1.00270539, "ECCENTRICITY": 0.0064009, "INCLINATION": 1.4222, "RA_OF_ASC_NODE": 355.3714, "ARG_OF_PERICENTER": 359.1771, "MEAN_ANOMALY": 52.1495, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:14:57"}, {"OBJECT_NAME": "WGS F7 (USA 263)", "NORAD_CAT_ID": 40746, "MEAN_MOTION": 1.0026956, "ECCENTRICITY": 3.31e-05, "INCLINATION": 0.0102, "RA_OF_ASC_NODE": 125.847, "ARG_OF_PERICENTER": 259.3514, "MEAN_ANOMALY": 92.7311, "BSTAR": 0.0, "EPOCH": "2026-03-12T08:51:38"}, {"OBJECT_NAME": "SBSS (USA 216)", "NORAD_CAT_ID": 37168, "MEAN_MOTION": 15.15755191, "ECCENTRICITY": 0.0082509, "INCLINATION": 97.7475, "RA_OF_ASC_NODE": 315.5523, "ARG_OF_PERICENTER": 174.1816, "MEAN_ANOMALY": 311.3892, "BSTAR": 0.00025053, "EPOCH": "2026-03-12T23:42:37"}, {"OBJECT_NAME": "NAVSTAR 66 (USA 232)", "NORAD_CAT_ID": 37753, "MEAN_MOTION": 2.00563358, "ECCENTRICITY": 0.0140181, "INCLINATION": 56.6079, "RA_OF_ASC_NODE": 335.7883, "ARG_OF_PERICENTER": 60.9438, "MEAN_ANOMALY": 114.8666, "BSTAR": 0.0, "EPOCH": "2026-03-12T09:12:38"}, {"OBJECT_NAME": "USA 81", "NORAD_CAT_ID": 21949, "MEAN_MOTION": 14.32369041, "ECCENTRICITY": 0.0002381, "INCLINATION": 85.0079, "RA_OF_ASC_NODE": 129.7261, "ARG_OF_PERICENTER": 74.9437, "MEAN_ANOMALY": 285.2019, "BSTAR": 6.1111e-05, "EPOCH": "2026-03-12T15:54:16"}, {"OBJECT_NAME": "NUSAT-6 (HYPATIA)", "NORAD_CAT_ID": 46272, "MEAN_MOTION": 16.40036993, "ECCENTRICITY": 0.0013029, "INCLINATION": 97.2026, "RA_OF_ASC_NODE": 336.2175, "ARG_OF_PERICENTER": 264.7707, "MEAN_ANOMALY": 95.2106, "BSTAR": 0.00055612, "EPOCH": "2024-09-13T16:58:59"}, {"OBJECT_NAME": "NUSAT-12 (DOROTHY)", "NORAD_CAT_ID": 46827, "MEAN_MOTION": 16.36178297, "ECCENTRICITY": 0.0011945, "INCLINATION": 97.1044, "RA_OF_ASC_NODE": 344.9336, "ARG_OF_PERICENTER": 264.565, "MEAN_ANOMALY": 95.4286, "BSTAR": 0.0008634300000000001, "EPOCH": "2023-09-26T20:09:18"}, {"OBJECT_NAME": "NUSAT-4 (ADA)", "NORAD_CAT_ID": 43195, "MEAN_MOTION": 16.38072031, "ECCENTRICITY": 0.0007046, "INCLINATION": 97.5559, "RA_OF_ASC_NODE": 181.2615, "ARG_OF_PERICENTER": 278.8217, "MEAN_ANOMALY": 81.2283, "BSTAR": 0.00073412, "EPOCH": "2024-01-04T03:41:36"}, {"OBJECT_NAME": "NUSAT-9 (ALICE)", "NORAD_CAT_ID": 46828, "MEAN_MOTION": 16.33943729, "ECCENTRICITY": 0.001099, "INCLINATION": 97.0919, "RA_OF_ASC_NODE": 350.4269, "ARG_OF_PERICENTER": 266.8171, "MEAN_ANOMALY": 93.1869, "BSTAR": 0.0009418100000000001, "EPOCH": "2023-10-02T20:48:12"}, {"OBJECT_NAME": "NUSAT-5 (MARYAM)", "NORAD_CAT_ID": 43204, "MEAN_MOTION": 16.4605451, "ECCENTRICITY": 0.0008666, "INCLINATION": 97.5583, "RA_OF_ASC_NODE": 170.606, "ARG_OF_PERICENTER": 279.6992, "MEAN_ANOMALY": 80.3331, "BSTAR": 0.00028268, "EPOCH": "2023-12-24T01:49:10"}, {"OBJECT_NAME": "NUSAT-15 (KATHERINE)", "NORAD_CAT_ID": 46830, "MEAN_MOTION": 16.34076651, "ECCENTRICITY": 0.0011902, "INCLINATION": 97.1235, "RA_OF_ASC_NODE": 337.498, "ARG_OF_PERICENTER": 265.1534, "MEAN_ANOMALY": 94.8404, "BSTAR": 0.0008088800000000001, "EPOCH": "2023-09-17T08:00:12"}, {"OBJECT_NAME": "NUSAT-14 (HEDY)", "NORAD_CAT_ID": 46831, "MEAN_MOTION": 16.3603456, "ECCENTRICITY": 0.0012051, "INCLINATION": 97.1214, "RA_OF_ASC_NODE": 325.2102, "ARG_OF_PERICENTER": 265.1929, "MEAN_ANOMALY": 94.7993, "BSTAR": 0.00071439, "EPOCH": "2023-09-04T23:54:36"}, {"OBJECT_NAME": "NUSAT-1 (FRESCO)", "NORAD_CAT_ID": 41557, "MEAN_MOTION": 16.3237962, "ECCENTRICITY": 0.0010102, "INCLINATION": 97.362, "RA_OF_ASC_NODE": 49.6734, "ARG_OF_PERICENTER": 286.5405, "MEAN_ANOMALY": 73.478, "BSTAR": 0.00095107, "EPOCH": "2023-10-20T20:09:20"}, {"OBJECT_NAME": "NUSAT-25 (MARIA TELKES)", "NORAD_CAT_ID": 52171, "MEAN_MOTION": 15.26497612, "ECCENTRICITY": 0.0002554, "INCLINATION": 97.3065, "RA_OF_ASC_NODE": 78.6893, "ARG_OF_PERICENTER": 144.7143, "MEAN_ANOMALY": 215.4265, "BSTAR": 0.00056233, "EPOCH": "2023-12-28T11:04:42"}, {"OBJECT_NAME": "NUSAT-10 (CAROLINE)", "NORAD_CAT_ID": 46832, "MEAN_MOTION": 16.26728051, "ECCENTRICITY": 0.0009442, "INCLINATION": 97.1204, "RA_OF_ASC_NODE": 13.8592, "ARG_OF_PERICENTER": 272.5521, "MEAN_ANOMALY": 87.4691, "BSTAR": 0.0010056, "EPOCH": "2023-10-24T13:56:03"}, {"OBJECT_NAME": "NUSAT-36 (ANNIE CANNON)", "NORAD_CAT_ID": 56190, "MEAN_MOTION": 15.28580851, "ECCENTRICITY": 0.0010119, "INCLINATION": 97.3802, "RA_OF_ASC_NODE": 254.9322, "ARG_OF_PERICENTER": 59.1427, "MEAN_ANOMALY": 301.0806, "BSTAR": 0.00059254, "EPOCH": "2023-12-28T11:08:22"}, {"OBJECT_NAME": "GAOFEN-6", "NORAD_CAT_ID": 43484, "MEAN_MOTION": 14.76601228, "ECCENTRICITY": 0.0013353, "INCLINATION": 97.7865, "RA_OF_ASC_NODE": 138.1453, "ARG_OF_PERICENTER": 124.7509, "MEAN_ANOMALY": 235.496, "BSTAR": 4.854e-05, "EPOCH": "2026-03-12T23:32:40"}, {"OBJECT_NAME": "GAOFEN-3 02", "NORAD_CAT_ID": 49495, "MEAN_MOTION": 14.42216342, "ECCENTRICITY": 0.0001492, "INCLINATION": 98.4137, "RA_OF_ASC_NODE": 81.4101, "ARG_OF_PERICENTER": 292.6666, "MEAN_ANOMALY": 67.4365, "BSTAR": -1.9557e-05, "EPOCH": "2026-03-12T23:02:48"}, {"OBJECT_NAME": "GAOFEN-2", "NORAD_CAT_ID": 40118, "MEAN_MOTION": 14.80777733, "ECCENTRICITY": 0.0007278, "INCLINATION": 98.0216, "RA_OF_ASC_NODE": 136.218, "ARG_OF_PERICENTER": 174.2003, "MEAN_ANOMALY": 185.9295, "BSTAR": 9.6902e-05, "EPOCH": "2026-03-12T22:57:13"}, {"OBJECT_NAME": "GAOFEN-8", "NORAD_CAT_ID": 40701, "MEAN_MOTION": 15.42568327, "ECCENTRICITY": 0.0009547, "INCLINATION": 97.6941, "RA_OF_ASC_NODE": 259.8162, "ARG_OF_PERICENTER": 176.4262, "MEAN_ANOMALY": 183.7055, "BSTAR": 0.00047347000000000003, "EPOCH": "2026-03-12T23:47:29"}, {"OBJECT_NAME": "GAOFEN-3", "NORAD_CAT_ID": 41727, "MEAN_MOTION": 14.42216414, "ECCENTRICITY": 0.0001677, "INCLINATION": 98.406, "RA_OF_ASC_NODE": 81.2201, "ARG_OF_PERICENTER": 88.9201, "MEAN_ANOMALY": 271.2181, "BSTAR": -3.7485000000000004e-05, "EPOCH": "2026-03-12T23:53:42"}, {"OBJECT_NAME": "GAOFEN-3 03", "NORAD_CAT_ID": 52200, "MEAN_MOTION": 14.42207076, "ECCENTRICITY": 0.0001564, "INCLINATION": 98.4113, "RA_OF_ASC_NODE": 81.9637, "ARG_OF_PERICENTER": 93.2489, "MEAN_ANOMALY": 266.888, "BSTAR": -4.6208e-05, "EPOCH": "2026-03-13T00:03:23"}, {"OBJECT_NAME": "GAOFEN-7", "NORAD_CAT_ID": 44703, "MEAN_MOTION": 15.21353042, "ECCENTRICITY": 0.0015576, "INCLINATION": 97.2541, "RA_OF_ASC_NODE": 136.6938, "ARG_OF_PERICENTER": 20.127, "MEAN_ANOMALY": 340.0577, "BSTAR": 0.00025648, "EPOCH": "2026-03-12T15:33:43"}, {"OBJECT_NAME": "GAOFEN-1 02", "NORAD_CAT_ID": 43259, "MEAN_MOTION": 14.76445906, "ECCENTRICITY": 0.0002241, "INCLINATION": 98.0636, "RA_OF_ASC_NODE": 134.7433, "ARG_OF_PERICENTER": 338.7467, "MEAN_ANOMALY": 21.3649, "BSTAR": -3.6829000000000003e-06, "EPOCH": "2026-03-12T23:54:28"}, {"OBJECT_NAME": "GAOFEN-1 04", "NORAD_CAT_ID": 43262, "MEAN_MOTION": 14.76444954, "ECCENTRICITY": 0.0001872, "INCLINATION": 98.0639, "RA_OF_ASC_NODE": 134.7449, "ARG_OF_PERICENTER": 76.2636, "MEAN_ANOMALY": 283.8782, "BSTAR": 0.00016722000000000002, "EPOCH": "2026-03-12T22:48:02"}, {"OBJECT_NAME": "GAOFEN-10R", "NORAD_CAT_ID": 44622, "MEAN_MOTION": 14.83167892, "ECCENTRICITY": 0.0008243, "INCLINATION": 98.0401, "RA_OF_ASC_NODE": 24.9509, "ARG_OF_PERICENTER": 97.2868, "MEAN_ANOMALY": 262.9282, "BSTAR": 0.00024608, "EPOCH": "2026-03-13T00:06:11"}, {"OBJECT_NAME": "GAOFEN-5 02", "NORAD_CAT_ID": 49122, "MEAN_MOTION": 14.57724529, "ECCENTRICITY": 1.25e-05, "INCLINATION": 98.2671, "RA_OF_ASC_NODE": 146.9344, "ARG_OF_PERICENTER": 295.3359, "MEAN_ANOMALY": 64.7827, "BSTAR": 2.1645e-05, "EPOCH": "2026-03-12T22:57:34"}, {"OBJECT_NAME": "PLANETUM1", "NORAD_CAT_ID": 52738, "MEAN_MOTION": 16.30442171, "ECCENTRICITY": 0.0009881, "INCLINATION": 97.5537, "RA_OF_ASC_NODE": 110.7236, "ARG_OF_PERICENTER": 292.3042, "MEAN_ANOMALY": 67.7204, "BSTAR": 0.0013242000000000002, "EPOCH": "2024-11-28T20:09:41"}, {"OBJECT_NAME": "KONDOR-FKA NO. 1", "NORAD_CAT_ID": 56756, "MEAN_MOTION": 15.19772289, "ECCENTRICITY": 0.000173, "INCLINATION": 97.4394, "RA_OF_ASC_NODE": 262.4533, "ARG_OF_PERICENTER": 84.6258, "MEAN_ANOMALY": 275.5174, "BSTAR": 0.00035188000000000005, "EPOCH": "2026-03-07T10:13:03"}, {"OBJECT_NAME": "KONDOR-FKA NO. 2", "NORAD_CAT_ID": 62138, "MEAN_MOTION": 15.19708149, "ECCENTRICITY": 0.0001651, "INCLINATION": 97.4355, "RA_OF_ASC_NODE": 276.8243, "ARG_OF_PERICENTER": 85.4428, "MEAN_ANOMALY": 274.6995, "BSTAR": 0.0004156, "EPOCH": "2026-03-12T23:33:10"}, {"OBJECT_NAME": "SPOT 5", "NORAD_CAT_ID": 27421, "MEAN_MOTION": 14.54664322, "ECCENTRICITY": 0.0129026, "INCLINATION": 97.9982, "RA_OF_ASC_NODE": 114.8242, "ARG_OF_PERICENTER": 215.9723, "MEAN_ANOMALY": 143.274, "BSTAR": 0.00010802000000000001, "EPOCH": "2026-03-12T17:42:03"}, {"OBJECT_NAME": "SPOT 6", "NORAD_CAT_ID": 38755, "MEAN_MOTION": 14.58543816, "ECCENTRICITY": 0.0001517, "INCLINATION": 98.2211, "RA_OF_ASC_NODE": 140.346, "ARG_OF_PERICENTER": 92.3727, "MEAN_ANOMALY": 267.7646, "BSTAR": 2.3367e-05, "EPOCH": "2026-03-12T22:46:57"}, {"OBJECT_NAME": "SPOT 7", "NORAD_CAT_ID": 40053, "MEAN_MOTION": 14.60865108, "ECCENTRICITY": 0.0001443, "INCLINATION": 98.0737, "RA_OF_ASC_NODE": 136.8839, "ARG_OF_PERICENTER": 98.9137, "MEAN_ANOMALY": 261.2227, "BSTAR": 9.229000000000001e-05, "EPOCH": "2026-03-12T22:50:17"}, {"OBJECT_NAME": "WORLDVIEW-3 (WV-3)", "NORAD_CAT_ID": 40115, "MEAN_MOTION": 14.84843729, "ECCENTRICITY": 1.44e-05, "INCLINATION": 97.8629, "RA_OF_ASC_NODE": 148.0927, "ARG_OF_PERICENTER": 185.9615, "MEAN_ANOMALY": 174.1598, "BSTAR": 0.000147, "EPOCH": "2026-03-12T22:37:37"}, {"OBJECT_NAME": "WORLDVIEW-2 (WV-2)", "NORAD_CAT_ID": 35946, "MEAN_MOTION": 14.37912101, "ECCENTRICITY": 0.0005178, "INCLINATION": 98.4688, "RA_OF_ASC_NODE": 146.7472, "ARG_OF_PERICENTER": 137.2782, "MEAN_ANOMALY": 222.8809, "BSTAR": 7.4586e-05, "EPOCH": "2026-03-12T22:15:59"}, {"OBJECT_NAME": "WORLDVIEW-1 (WV-1)", "NORAD_CAT_ID": 32060, "MEAN_MOTION": 15.24596218, "ECCENTRICITY": 0.0003296, "INCLINATION": 97.3823, "RA_OF_ASC_NODE": 192.9224, "ARG_OF_PERICENTER": 130.7434, "MEAN_ANOMALY": 229.4091, "BSTAR": 0.00036485, "EPOCH": "2026-03-12T18:53:15"}, {"OBJECT_NAME": "NAVSTAR 46 (USA 145)", "NORAD_CAT_ID": 25933, "MEAN_MOTION": 2.00567445, "ECCENTRICITY": 0.0106392, "INCLINATION": 51.5502, "RA_OF_ASC_NODE": 299.7052, "ARG_OF_PERICENTER": 171.887, "MEAN_ANOMALY": 12.7695, "BSTAR": 0.0, "EPOCH": "2026-03-12T07:54:38"}, {"OBJECT_NAME": "NAVSTAR 49 (USA 154)", "NORAD_CAT_ID": 26605, "MEAN_MOTION": 2.00570306, "ECCENTRICITY": 0.0173399, "INCLINATION": 55.5674, "RA_OF_ASC_NODE": 98.8043, "ARG_OF_PERICENTER": 265.6511, "MEAN_ANOMALY": 98.697, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:24:04"}, {"OBJECT_NAME": "NAVSTAR 52 (USA 168)", "NORAD_CAT_ID": 27704, "MEAN_MOTION": 1.94743161, "ECCENTRICITY": 0.0004627, "INCLINATION": 54.8698, "RA_OF_ASC_NODE": 328.8449, "ARG_OF_PERICENTER": 343.01, "MEAN_ANOMALY": 204.2637, "BSTAR": 0.0, "EPOCH": "2026-03-11T13:01:38"}, {"OBJECT_NAME": "NAVSTAR 53 (USA 175)", "NORAD_CAT_ID": 28129, "MEAN_MOTION": 1.92678601, "ECCENTRICITY": 0.000316, "INCLINATION": 54.9721, "RA_OF_ASC_NODE": 26.8461, "ARG_OF_PERICENTER": 314.5189, "MEAN_ANOMALY": 45.4493, "BSTAR": 0.0, "EPOCH": "2026-03-12T12:24:25"}, {"OBJECT_NAME": "NAVSTAR 55 (USA 178)", "NORAD_CAT_ID": 28361, "MEAN_MOTION": 2.00552002, "ECCENTRICITY": 0.0159682, "INCLINATION": 54.7915, "RA_OF_ASC_NODE": 90.8407, "ARG_OF_PERICENTER": 298.7021, "MEAN_ANOMALY": 62.7963, "BSTAR": 0.0, "EPOCH": "2026-03-06T22:19:05"}, {"OBJECT_NAME": "NAVSTAR 63 (USA 203)", "NORAD_CAT_ID": 34661, "MEAN_MOTION": 2.00555673, "ECCENTRICITY": 0.0144943, "INCLINATION": 54.5032, "RA_OF_ASC_NODE": 214.5267, "ARG_OF_PERICENTER": 61.4797, "MEAN_ANOMALY": 119.9013, "BSTAR": 0.0, "EPOCH": "2026-03-12T13:50:04"}, {"OBJECT_NAME": "CSO-3", "NORAD_CAT_ID": 63156, "MEAN_MOTION": 14.34022377, "ECCENTRICITY": 0.0001932, "INCLINATION": 98.606, "RA_OF_ASC_NODE": 14.3262, "ARG_OF_PERICENTER": 17.427, "MEAN_ANOMALY": 342.6981, "BSTAR": 0.00016071, "EPOCH": "2025-03-13T22:37:36"}, {"OBJECT_NAME": "LUCH DEB", "NORAD_CAT_ID": 44582, "MEAN_MOTION": 1.00701065, "ECCENTRICITY": 0.0017009, "INCLINATION": 14.6048, "RA_OF_ASC_NODE": 354.7817, "ARG_OF_PERICENTER": 87.9758, "MEAN_ANOMALY": 77.8273, "BSTAR": 0.0, "EPOCH": "2026-03-12T17:12:42"}, {"OBJECT_NAME": "LUCH (OLYMP-K 1) DEB", "NORAD_CAT_ID": 67745, "MEAN_MOTION": 0.97993456, "ECCENTRICITY": 0.0520769, "INCLINATION": 1.5795, "RA_OF_ASC_NODE": 85.6956, "ARG_OF_PERICENTER": 329.0115, "MEAN_ANOMALY": 28.4425, "BSTAR": 0.0, "EPOCH": "2026-02-26T11:11:16"}, {"OBJECT_NAME": "LUCH", "NORAD_CAT_ID": 23426, "MEAN_MOTION": 1.00183653, "ECCENTRICITY": 0.0004739, "INCLINATION": 14.7547, "RA_OF_ASC_NODE": 355.3521, "ARG_OF_PERICENTER": 225.6958, "MEAN_ANOMALY": 134.2706, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:42:18"}, {"OBJECT_NAME": "LUCH-1", "NORAD_CAT_ID": 23680, "MEAN_MOTION": 1.00266904, "ECCENTRICITY": 0.0004385, "INCLINATION": 15.0216, "RA_OF_ASC_NODE": 0.0101, "ARG_OF_PERICENTER": 148.0669, "MEAN_ANOMALY": 46.0498, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:31:31"}, {"OBJECT_NAME": "LUCH 5A (SDCM/PRN 140)", "NORAD_CAT_ID": 37951, "MEAN_MOTION": 1.00268952, "ECCENTRICITY": 0.0003437, "INCLINATION": 8.508, "RA_OF_ASC_NODE": 75.2195, "ARG_OF_PERICENTER": 262.1549, "MEAN_ANOMALY": 294.9566, "BSTAR": 0.0, "EPOCH": "2026-03-12T19:39:01"}, {"OBJECT_NAME": "LUCH 5B (SDCM/PRN 125)", "NORAD_CAT_ID": 38977, "MEAN_MOTION": 1.00271997, "ECCENTRICITY": 0.0003291, "INCLINATION": 10.2757, "RA_OF_ASC_NODE": 50.8206, "ARG_OF_PERICENTER": 227.4389, "MEAN_ANOMALY": 131.8074, "BSTAR": 0.0, "EPOCH": "2026-03-12T17:04:07"}, {"OBJECT_NAME": "LUCH 5V (SDCM/PRN 141)", "NORAD_CAT_ID": 39727, "MEAN_MOTION": 1.0026965, "ECCENTRICITY": 0.0003128, "INCLINATION": 4.894, "RA_OF_ASC_NODE": 70.6891, "ARG_OF_PERICENTER": 295.4524, "MEAN_ANOMALY": 85.6217, "BSTAR": 0.0, "EPOCH": "2026-03-12T12:27:36"}, {"OBJECT_NAME": "LUCH (OLYMP-K 1)", "NORAD_CAT_ID": 40258, "MEAN_MOTION": 0.99116915, "ECCENTRICITY": 0.0003121, "INCLINATION": 1.4978, "RA_OF_ASC_NODE": 84.5508, "ARG_OF_PERICENTER": 187.5711, "MEAN_ANOMALY": 175.9774, "BSTAR": 0.0, "EPOCH": "2026-01-14T19:27:12"}, {"OBJECT_NAME": "LUCH-5X (OLYMP-K 2)", "NORAD_CAT_ID": 55841, "MEAN_MOTION": 1.00270311, "ECCENTRICITY": 0.0001484, "INCLINATION": 0.0262, "RA_OF_ASC_NODE": 91.4113, "ARG_OF_PERICENTER": 119.8358, "MEAN_ANOMALY": 256.9568, "BSTAR": 0.0, "EPOCH": "2026-03-12T15:43:45"}, {"OBJECT_NAME": "ICEYE-X9", "NORAD_CAT_ID": 47506, "MEAN_MOTION": 15.28271202, "ECCENTRICITY": 0.0008413, "INCLINATION": 97.3622, "RA_OF_ASC_NODE": 54.3082, "ARG_OF_PERICENTER": 126.886, "MEAN_ANOMALY": 233.3152, "BSTAR": 0.0010076, "EPOCH": "2023-12-28T11:08:25"}, {"OBJECT_NAME": "XR-1 (ICEYE-X10)", "NORAD_CAT_ID": 47507, "MEAN_MOTION": 15.22197725, "ECCENTRICITY": 0.001392, "INCLINATION": 97.3689, "RA_OF_ASC_NODE": 55.932, "ARG_OF_PERICENTER": 134.347, "MEAN_ANOMALY": 225.891, "BSTAR": 0.00062135, "EPOCH": "2023-12-28T04:01:27"}, {"OBJECT_NAME": "ICEYE-X8", "NORAD_CAT_ID": 47510, "MEAN_MOTION": 15.26805421, "ECCENTRICITY": 0.0007492, "INCLINATION": 97.3641, "RA_OF_ASC_NODE": 54.4014, "ARG_OF_PERICENTER": 124.0674, "MEAN_ANOMALY": 236.1278, "BSTAR": 0.00062244, "EPOCH": "2023-12-28T10:34:23"}, {"OBJECT_NAME": "ICEYE-X15", "NORAD_CAT_ID": 48917, "MEAN_MOTION": 15.30363767, "ECCENTRICITY": 0.0010194, "INCLINATION": 97.6127, "RA_OF_ASC_NODE": 142.3594, "ARG_OF_PERICENTER": 272.128, "MEAN_ANOMALY": 87.8793, "BSTAR": 0.00079595, "EPOCH": "2023-12-28T10:33:29"}, {"OBJECT_NAME": "ICEYE-X16", "NORAD_CAT_ID": 51008, "MEAN_MOTION": 15.3391081, "ECCENTRICITY": 0.0005633, "INCLINATION": 97.4149, "RA_OF_ASC_NODE": 68.7598, "ARG_OF_PERICENTER": 271.0358, "MEAN_ANOMALY": 89.0239, "BSTAR": 0.0009538, "EPOCH": "2023-12-28T10:17:07"}, {"OBJECT_NAME": "ICEYE-X14", "NORAD_CAT_ID": 51070, "MEAN_MOTION": 15.19599986, "ECCENTRICITY": 0.0006515, "INCLINATION": 97.418, "RA_OF_ASC_NODE": 67.3206, "ARG_OF_PERICENTER": 19.2413, "MEAN_ANOMALY": 340.9067, "BSTAR": 0.00061029, "EPOCH": "2023-12-28T10:10:51"}, {"OBJECT_NAME": "CARCARA 1 (ICEYE-X18)", "NORAD_CAT_ID": 52749, "MEAN_MOTION": 15.22164983, "ECCENTRICITY": 0.0015759, "INCLINATION": 97.5512, "RA_OF_ASC_NODE": 118.1578, "ARG_OF_PERICENTER": 31.6119, "MEAN_ANOMALY": 328.606, "BSTAR": 0.0010144, "EPOCH": "2023-12-28T11:11:43"}, {"OBJECT_NAME": "ICEYE-X24", "NORAD_CAT_ID": 52755, "MEAN_MOTION": 15.27727758, "ECCENTRICITY": 0.0003109, "INCLINATION": 97.5567, "RA_OF_ASC_NODE": 121.2112, "ARG_OF_PERICENTER": 212.0851, "MEAN_ANOMALY": 148.0199, "BSTAR": 0.0010204, "EPOCH": "2023-12-28T10:06:30"}, {"OBJECT_NAME": "CARCARA 2 (ICEYE-X19)", "NORAD_CAT_ID": 52758, "MEAN_MOTION": 15.2367522, "ECCENTRICITY": 0.0016139, "INCLINATION": 97.5536, "RA_OF_ASC_NODE": 118.8008, "ARG_OF_PERICENTER": 18.762, "MEAN_ANOMALY": 341.4208, "BSTAR": 0.00097146, "EPOCH": "2023-12-28T11:28:53"}, {"OBJECT_NAME": "ICEYE-X20", "NORAD_CAT_ID": 52759, "MEAN_MOTION": 15.31342668, "ECCENTRICITY": 0.0009251, "INCLINATION": 97.5547, "RA_OF_ASC_NODE": 121.6431, "ARG_OF_PERICENTER": 23.0489, "MEAN_ANOMALY": 337.1165, "BSTAR": 0.00093185, "EPOCH": "2023-12-28T10:29:34"}, {"OBJECT_NAME": "ICEYE-X4", "NORAD_CAT_ID": 44390, "MEAN_MOTION": 15.27069536, "ECCENTRICITY": 0.0012209, "INCLINATION": 97.8887, "RA_OF_ASC_NODE": 99.3776, "ARG_OF_PERICENTER": 281.9334, "MEAN_ANOMALY": 78.0535, "BSTAR": 0.0005155499999999999, "EPOCH": "2026-03-13T00:14:33"}, {"OBJECT_NAME": "ICEYE-X17", "NORAD_CAT_ID": 52762, "MEAN_MOTION": 15.2124607, "ECCENTRICITY": 0.0013644, "INCLINATION": 97.5558, "RA_OF_ASC_NODE": 120.8021, "ARG_OF_PERICENTER": 1.6767, "MEAN_ANOMALY": 358.4512, "BSTAR": 0.00057881, "EPOCH": "2023-12-28T09:08:20"}, {"OBJECT_NAME": "ICEYE-X27", "NORAD_CAT_ID": 55062, "MEAN_MOTION": 15.21915655, "ECCENTRICITY": 0.0013054, "INCLINATION": 97.4564, "RA_OF_ASC_NODE": 59.6015, "ARG_OF_PERICENTER": 58.5293, "MEAN_ANOMALY": 301.7217, "BSTAR": 0.00058627, "EPOCH": "2023-12-28T03:09:32"}, {"OBJECT_NAME": "ICEYE-X12", "NORAD_CAT_ID": 48914, "MEAN_MOTION": 15.19543383, "ECCENTRICITY": 0.0001769, "INCLINATION": 97.6141, "RA_OF_ASC_NODE": 134.5462, "ARG_OF_PERICENTER": 318.3531, "MEAN_ANOMALY": 41.7569, "BSTAR": 0.0007832000000000001, "EPOCH": "2023-12-28T10:18:56"}, {"OBJECT_NAME": "ICEYE-X13", "NORAD_CAT_ID": 48916, "MEAN_MOTION": 15.18467295, "ECCENTRICITY": 0.0006345, "INCLINATION": 97.6161, "RA_OF_ASC_NODE": 135.3491, "ARG_OF_PERICENTER": 6.2034, "MEAN_ANOMALY": 353.9277, "BSTAR": 0.00073797, "EPOCH": "2023-12-28T11:30:27"}, {"OBJECT_NAME": "ICEYE-X50", "NORAD_CAT_ID": 63255, "MEAN_MOTION": 14.94927128, "ECCENTRICITY": 0.0002685, "INCLINATION": 97.7008, "RA_OF_ASC_NODE": 325.6941, "ARG_OF_PERICENTER": 131.5074, "MEAN_ANOMALY": 228.6378, "BSTAR": 0.00029823, "EPOCH": "2026-03-12T23:38:30"}, {"OBJECT_NAME": "ICEYE-X34", "NORAD_CAT_ID": 58294, "MEAN_MOTION": 15.52506915, "ECCENTRICITY": 0.000452, "INCLINATION": 97.3917, "RA_OF_ASC_NODE": 159.2488, "ARG_OF_PERICENTER": 154.7691, "MEAN_ANOMALY": 205.3783, "BSTAR": 0.0009229, "EPOCH": "2026-03-12T17:34:51"}, {"OBJECT_NAME": "ICEYE-X62", "NORAD_CAT_ID": 66755, "MEAN_MOTION": 15.12819493, "ECCENTRICITY": 0.0001877, "INCLINATION": 97.4275, "RA_OF_ASC_NODE": 147.0348, "ARG_OF_PERICENTER": 19.9717, "MEAN_ANOMALY": 180.5253, "BSTAR": -0.00199, "EPOCH": "2026-03-12T22:00:01"}, {"OBJECT_NAME": "ICEYE-X51", "NORAD_CAT_ID": 63257, "MEAN_MOTION": 15.00894278, "ECCENTRICITY": 0.0002089, "INCLINATION": 97.7361, "RA_OF_ASC_NODE": 327.4155, "ARG_OF_PERICENTER": 64.8302, "MEAN_ANOMALY": 295.3138, "BSTAR": 0.00044628, "EPOCH": "2026-03-12T23:43:01"}, {"OBJECT_NAME": "ICEYE-X35", "NORAD_CAT_ID": 58302, "MEAN_MOTION": 15.44720539, "ECCENTRICITY": 0.0010765, "INCLINATION": 97.385, "RA_OF_ASC_NODE": 154.4975, "ARG_OF_PERICENTER": 182.0371, "MEAN_ANOMALY": 178.0836, "BSTAR": 0.00085783, "EPOCH": "2026-03-13T00:28:05"}, {"OBJECT_NAME": "CAPELLA-6-WHITNEY", "NORAD_CAT_ID": 48605, "MEAN_MOTION": 15.36992166, "ECCENTRICITY": 0.0006978, "INCLINATION": 53.0293, "RA_OF_ASC_NODE": 151.2332, "ARG_OF_PERICENTER": 275.4158, "MEAN_ANOMALY": 84.6048, "BSTAR": 0.0028544000000000004, "EPOCH": "2023-12-28T11:48:20"}, {"OBJECT_NAME": "CAPELLA-8-WHITNEY", "NORAD_CAT_ID": 51071, "MEAN_MOTION": 16.38892911, "ECCENTRICITY": 0.0015575, "INCLINATION": 97.4006, "RA_OF_ASC_NODE": 322.5782, "ARG_OF_PERICENTER": 273.5387, "MEAN_ANOMALY": 175.0653, "BSTAR": 0.0030023, "EPOCH": "2023-09-06T01:34:15"}, {"OBJECT_NAME": "CAPELLA-7-WHITNEY", "NORAD_CAT_ID": 51072, "MEAN_MOTION": 16.44927659, "ECCENTRICITY": 0.0015288, "INCLINATION": 97.3947, "RA_OF_ASC_NODE": 311.3663, "ARG_OF_PERICENTER": 273.4288, "MEAN_ANOMALY": 179.2482, "BSTAR": 0.0011137, "EPOCH": "2023-08-26T19:44:02"}, {"OBJECT_NAME": "CAPELLA-10-WHITNEY", "NORAD_CAT_ID": 55909, "MEAN_MOTION": 14.95890017, "ECCENTRICITY": 0.0009144, "INCLINATION": 43.9994, "RA_OF_ASC_NODE": 246.9297, "ARG_OF_PERICENTER": 166.1646, "MEAN_ANOMALY": 193.9462, "BSTAR": 0.00065599, "EPOCH": "2023-12-27T21:10:20"}, {"OBJECT_NAME": "CAPELLA-9-WHITNEY", "NORAD_CAT_ID": 55910, "MEAN_MOTION": 14.99271847, "ECCENTRICITY": 0.0009067, "INCLINATION": 43.9991, "RA_OF_ASC_NODE": 244.4088, "ARG_OF_PERICENTER": 156.0492, "MEAN_ANOMALY": 204.0788, "BSTAR": 0.0013163, "EPOCH": "2023-12-27T21:00:03"}, {"OBJECT_NAME": "CAPELLA-11 (ACADIA-1)", "NORAD_CAT_ID": 57693, "MEAN_MOTION": 14.80571571, "ECCENTRICITY": 0.0002549, "INCLINATION": 53.0051, "RA_OF_ASC_NODE": 241.5, "ARG_OF_PERICENTER": 127.5905, "MEAN_ANOMALY": 232.5304, "BSTAR": 0.00045154, "EPOCH": "2026-03-12T20:38:53"}, {"OBJECT_NAME": "CAPELLA-14 (ACADIA-4)", "NORAD_CAT_ID": 59444, "MEAN_MOTION": 15.10673029, "ECCENTRICITY": 0.0004067, "INCLINATION": 45.6049, "RA_OF_ASC_NODE": 46.0365, "ARG_OF_PERICENTER": 131.8141, "MEAN_ANOMALY": 228.3093, "BSTAR": 0.0013693, "EPOCH": "2026-03-12T08:17:37"}, {"OBJECT_NAME": "CAPELLA-13 (ACADIA-3)", "NORAD_CAT_ID": 60419, "MEAN_MOTION": 14.87642553, "ECCENTRICITY": 0.000131, "INCLINATION": 53.0064, "RA_OF_ASC_NODE": 120.2379, "ARG_OF_PERICENTER": 99.2032, "MEAN_ANOMALY": 260.9096, "BSTAR": -0.0007528599999999999, "EPOCH": "2026-03-12T08:31:00"}, {"OBJECT_NAME": "CAPELLA-15 (ACADIA-5)", "NORAD_CAT_ID": 60544, "MEAN_MOTION": 14.92437598, "ECCENTRICITY": 0.0004118, "INCLINATION": 97.6855, "RA_OF_ASC_NODE": 146.8612, "ARG_OF_PERICENTER": 61.8229, "MEAN_ANOMALY": 298.3406, "BSTAR": -0.00029862, "EPOCH": "2026-03-12T13:48:01"}, {"OBJECT_NAME": "CAPELLA-17 (ACADIA-7)", "NORAD_CAT_ID": 64583, "MEAN_MOTION": 14.90903126, "ECCENTRICITY": 0.0004403, "INCLINATION": 97.7562, "RA_OF_ASC_NODE": 186.9266, "ARG_OF_PERICENTER": 321.1739, "MEAN_ANOMALY": 38.9163, "BSTAR": 0.0010373000000000001, "EPOCH": "2026-03-12T19:01:50"}, {"OBJECT_NAME": "CAPELLA-16 (ACADIA-6)", "NORAD_CAT_ID": 65318, "MEAN_MOTION": 14.93525871, "ECCENTRICITY": 0.0004694, "INCLINATION": 97.7404, "RA_OF_ASC_NODE": 148.2924, "ARG_OF_PERICENTER": 188.7741, "MEAN_ANOMALY": 171.3398, "BSTAR": 0.00076993, "EPOCH": "2026-03-12T17:13:23"}, {"OBJECT_NAME": "CAPELLA-19 (ACADIA-9)", "NORAD_CAT_ID": 67384, "MEAN_MOTION": 14.86660787, "ECCENTRICITY": 0.0001275, "INCLINATION": 97.8129, "RA_OF_ASC_NODE": 71.6661, "ARG_OF_PERICENTER": 133.6197, "MEAN_ANOMALY": 226.5125, "BSTAR": 0.00077301, "EPOCH": "2026-03-12T13:06:13"}, {"OBJECT_NAME": "CAPELLA-18 (ACADIA-8)", "NORAD_CAT_ID": 67385, "MEAN_MOTION": 14.88714988, "ECCENTRICITY": 0.0005578, "INCLINATION": 97.8012, "RA_OF_ASC_NODE": 72.2002, "ARG_OF_PERICENTER": 80.0416, "MEAN_ANOMALY": 280.143, "BSTAR": 0.00067787, "EPOCH": "2026-03-12T23:34:16"}, {"OBJECT_NAME": "SHIJIAN-16 (SJ-16)", "NORAD_CAT_ID": 39358, "MEAN_MOTION": 14.92365263, "ECCENTRICITY": 0.0015392, "INCLINATION": 74.9728, "RA_OF_ASC_NODE": 178.858, "ARG_OF_PERICENTER": 104.1103, "MEAN_ANOMALY": 256.1797, "BSTAR": 0.00033226, "EPOCH": "2026-03-12T23:32:35"}, {"OBJECT_NAME": "SHIJIAN-20 (SJ-20)", "NORAD_CAT_ID": 44910, "MEAN_MOTION": 1.00082407, "ECCENTRICITY": 0.0001536, "INCLINATION": 4.6699, "RA_OF_ASC_NODE": 76.2395, "ARG_OF_PERICENTER": 16.315, "MEAN_ANOMALY": 96.486, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:33:00"}, {"OBJECT_NAME": "SHIJIAN-30A (SJ-30A)", "NORAD_CAT_ID": 66545, "MEAN_MOTION": 15.15404982, "ECCENTRICITY": 0.0011393, "INCLINATION": 51.7982, "RA_OF_ASC_NODE": 270.159, "ARG_OF_PERICENTER": 302.9254, "MEAN_ANOMALY": 57.0626, "BSTAR": 0.00052842, "EPOCH": "2026-03-12T23:03:42"}, {"OBJECT_NAME": "SHIJIAN-6 02B (SJ-6 02B)", "NORAD_CAT_ID": 29506, "MEAN_MOTION": 15.01017278, "ECCENTRICITY": 0.0013865, "INCLINATION": 97.7074, "RA_OF_ASC_NODE": 101.7387, "ARG_OF_PERICENTER": 32.91, "MEAN_ANOMALY": 327.2984, "BSTAR": 0.00019369, "EPOCH": "2026-03-13T00:44:53"}, {"OBJECT_NAME": "SHIJIAN-6 03A (SJ-6 03A)", "NORAD_CAT_ID": 33408, "MEAN_MOTION": 15.16111704, "ECCENTRICITY": 0.0011124, "INCLINATION": 97.8538, "RA_OF_ASC_NODE": 101.9832, "ARG_OF_PERICENTER": 352.2529, "MEAN_ANOMALY": 7.8529, "BSTAR": 0.00022283, "EPOCH": "2026-03-12T23:43:08"}, {"OBJECT_NAME": "SHIJIAN-6 04B (SJ-6 04B)", "NORAD_CAT_ID": 37180, "MEAN_MOTION": 14.99125047, "ECCENTRICITY": 0.0008978, "INCLINATION": 97.8751, "RA_OF_ASC_NODE": 76.4035, "ARG_OF_PERICENTER": 281.6956, "MEAN_ANOMALY": 78.3259, "BSTAR": 4.2834000000000004e-05, "EPOCH": "2026-03-12T23:31:41"}, {"OBJECT_NAME": "SHIJIAN-6 01A (SJ-6 01A)", "NORAD_CAT_ID": 28413, "MEAN_MOTION": 15.16510771, "ECCENTRICITY": 0.000813, "INCLINATION": 97.598, "RA_OF_ASC_NODE": 98.916, "ARG_OF_PERICENTER": 106.4351, "MEAN_ANOMALY": 253.7775, "BSTAR": 0.00051499, "EPOCH": "2026-03-13T00:17:18"}, {"OBJECT_NAME": "SHIJIAN-6 02A (SJ-6 02A)", "NORAD_CAT_ID": 29505, "MEAN_MOTION": 15.15752173, "ECCENTRICITY": 0.0004821, "INCLINATION": 97.65, "RA_OF_ASC_NODE": 112.3783, "ARG_OF_PERICENTER": 106.3266, "MEAN_ANOMALY": 253.8497, "BSTAR": 0.00025578, "EPOCH": "2026-03-13T00:17:37"}, {"OBJECT_NAME": "SHIJIAN-6 03B (SJ-6 03B)", "NORAD_CAT_ID": 33409, "MEAN_MOTION": 15.0390167, "ECCENTRICITY": 0.0019821, "INCLINATION": 97.8672, "RA_OF_ASC_NODE": 90.2569, "ARG_OF_PERICENTER": 11.9486, "MEAN_ANOMALY": 348.2205, "BSTAR": 0.00024344, "EPOCH": "2026-03-12T23:19:30"}, {"OBJECT_NAME": "SHIJIAN-6 04A (SJ-6 04A)", "NORAD_CAT_ID": 37179, "MEAN_MOTION": 15.16697181, "ECCENTRICITY": 0.0018573, "INCLINATION": 97.8382, "RA_OF_ASC_NODE": 91.9394, "ARG_OF_PERICENTER": 192.3699, "MEAN_ANOMALY": 167.708, "BSTAR": 0.0003394, "EPOCH": "2026-03-13T00:13:55"}, {"OBJECT_NAME": "SHIJIAN-21 (SJ-21)", "NORAD_CAT_ID": 49330, "MEAN_MOTION": 1.00264174, "ECCENTRICITY": 0.0047998, "INCLINATION": 4.884, "RA_OF_ASC_NODE": 61.7158, "ARG_OF_PERICENTER": 171.42, "MEAN_ANOMALY": 20.9563, "BSTAR": 0.0, "EPOCH": "2026-03-12T21:03:15"}, {"OBJECT_NAME": "SHIJIAN-26 (SJ-26)", "NORAD_CAT_ID": 64199, "MEAN_MOTION": 15.22601859, "ECCENTRICITY": 0.0018416, "INCLINATION": 97.4606, "RA_OF_ASC_NODE": 151.1292, "ARG_OF_PERICENTER": 334.1721, "MEAN_ANOMALY": 25.8594, "BSTAR": 0.00030688, "EPOCH": "2026-03-12T22:51:36"}, {"OBJECT_NAME": "SHIJIAN-30B (SJ-30B)", "NORAD_CAT_ID": 66546, "MEAN_MOTION": 15.15341252, "ECCENTRICITY": 0.0009341, "INCLINATION": 51.7965, "RA_OF_ASC_NODE": 270.1468, "ARG_OF_PERICENTER": 305.3511, "MEAN_ANOMALY": 54.6592, "BSTAR": 0.00056793, "EPOCH": "2026-03-12T23:05:08"}, {"OBJECT_NAME": "SHIJIAN-30C (SJ-30C)", "NORAD_CAT_ID": 66547, "MEAN_MOTION": 15.15443136, "ECCENTRICITY": 0.0010148, "INCLINATION": 51.7973, "RA_OF_ASC_NODE": 270.163, "ARG_OF_PERICENTER": 296.2307, "MEAN_ANOMALY": 63.7626, "BSTAR": 0.00057546, "EPOCH": "2026-03-12T23:06:29"}, {"OBJECT_NAME": "SHIJIAN-28 (SJ-28)", "NORAD_CAT_ID": 66549, "MEAN_MOTION": 1.00270553, "ECCENTRICITY": 5.92e-05, "INCLINATION": 4.7879, "RA_OF_ASC_NODE": 273.5879, "ARG_OF_PERICENTER": 174.6023, "MEAN_ANOMALY": 150.2888, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:44:51"}, {"OBJECT_NAME": "SHIJIAN-29A (SJ-29A)", "NORAD_CAT_ID": 67302, "MEAN_MOTION": 1.00273696, "ECCENTRICITY": 0.0002525, "INCLINATION": 3.1518, "RA_OF_ASC_NODE": 97.5891, "ARG_OF_PERICENTER": 312.4778, "MEAN_ANOMALY": 149.6883, "BSTAR": 0.0, "EPOCH": "2026-03-12T21:04:30"}, {"OBJECT_NAME": "SHIJIAN-29B (SJ-29B)", "NORAD_CAT_ID": 67403, "MEAN_MOTION": 1.00273209, "ECCENTRICITY": 0.0004579, "INCLINATION": 3.1517, "RA_OF_ASC_NODE": 97.5724, "ARG_OF_PERICENTER": 321.8831, "MEAN_ANOMALY": 140.3816, "BSTAR": 0.0, "EPOCH": "2026-03-12T21:04:32"}, {"OBJECT_NAME": "SHIJIAN-15 (SJ-15)", "NORAD_CAT_ID": 39210, "MEAN_MOTION": 14.71364377, "ECCENTRICITY": 0.0007579, "INCLINATION": 98.1058, "RA_OF_ASC_NODE": 75.4158, "ARG_OF_PERICENTER": 93.5199, "MEAN_ANOMALY": 266.6875, "BSTAR": 0.00017073, "EPOCH": "2026-03-13T00:11:52"}, {"OBJECT_NAME": "SHIJIAN-17 (SJ-17)", "NORAD_CAT_ID": 41838, "MEAN_MOTION": 0.99865501, "ECCENTRICITY": 0.0001148, "INCLINATION": 5.5064, "RA_OF_ASC_NODE": 73.7601, "ARG_OF_PERICENTER": 325.6027, "MEAN_ANOMALY": 47.6368, "BSTAR": 0.0, "EPOCH": "2026-03-12T17:13:52"}, {"OBJECT_NAME": "SHIJIAN-6 05A (SJ-6 05A)", "NORAD_CAT_ID": 49961, "MEAN_MOTION": 14.99103097, "ECCENTRICITY": 0.0002978, "INCLINATION": 97.401, "RA_OF_ASC_NODE": 45.247, "ARG_OF_PERICENTER": 70.3537, "MEAN_ANOMALY": 289.8008, "BSTAR": 0.00021422, "EPOCH": "2026-03-12T22:53:37"}, {"OBJECT_NAME": "YAOGAN-29", "NORAD_CAT_ID": 41038, "MEAN_MOTION": 14.8299956, "ECCENTRICITY": 0.0001655, "INCLINATION": 98.0145, "RA_OF_ASC_NODE": 100.4716, "ARG_OF_PERICENTER": 30.8872, "MEAN_ANOMALY": 329.2439, "BSTAR": 8.0414e-05, "EPOCH": "2026-03-13T00:47:43"}, {"OBJECT_NAME": "YAOGAN-13", "NORAD_CAT_ID": 37941, "MEAN_MOTION": 16.37208404, "ECCENTRICITY": 0.0005273, "INCLINATION": 97.6847, "RA_OF_ASC_NODE": 66.5996, "ARG_OF_PERICENTER": 226.2633, "MEAN_ANOMALY": 199.5818, "BSTAR": 0.00080897, "EPOCH": "2025-02-22T04:56:09"}, {"OBJECT_NAME": "YAOGAN-23", "NORAD_CAT_ID": 40305, "MEAN_MOTION": 16.38800959, "ECCENTRICITY": 0.000344, "INCLINATION": 97.6653, "RA_OF_ASC_NODE": 349.5059, "ARG_OF_PERICENTER": 254.7314, "MEAN_ANOMALY": 115.2384, "BSTAR": 0.00072463, "EPOCH": "2024-12-12T06:42:04"}, {"OBJECT_NAME": "YAOGAN-3", "NORAD_CAT_ID": 32289, "MEAN_MOTION": 14.90381072, "ECCENTRICITY": 0.0001651, "INCLINATION": 97.8278, "RA_OF_ASC_NODE": 102.8032, "ARG_OF_PERICENTER": 80.3514, "MEAN_ANOMALY": 279.789, "BSTAR": 0.00019179, "EPOCH": "2026-03-12T23:58:19"}, {"OBJECT_NAME": "YAOGAN-4", "NORAD_CAT_ID": 33446, "MEAN_MOTION": 14.82989341, "ECCENTRICITY": 0.001514, "INCLINATION": 97.9247, "RA_OF_ASC_NODE": 6.0266, "ARG_OF_PERICENTER": 29.9854, "MEAN_ANOMALY": 330.2223, "BSTAR": 0.00021159, "EPOCH": "2026-03-12T18:09:00"}, {"OBJECT_NAME": "YAOGAN-7", "NORAD_CAT_ID": 36110, "MEAN_MOTION": 14.77898568, "ECCENTRICITY": 0.0024538, "INCLINATION": 98.0205, "RA_OF_ASC_NODE": 318.08, "ARG_OF_PERICENTER": 353.768, "MEAN_ANOMALY": 6.3222, "BSTAR": 0.00012852, "EPOCH": "2026-03-12T16:20:57"}, {"OBJECT_NAME": "YAOGAN-21", "NORAD_CAT_ID": 40143, "MEAN_MOTION": 15.24983491, "ECCENTRICITY": 0.0010087, "INCLINATION": 97.1616, "RA_OF_ASC_NODE": 116.4094, "ARG_OF_PERICENTER": 77.0859, "MEAN_ANOMALY": 283.1505, "BSTAR": 0.0002759, "EPOCH": "2026-03-13T00:17:13"}, {"OBJECT_NAME": "YAOGAN-10", "NORAD_CAT_ID": 36834, "MEAN_MOTION": 14.8502871, "ECCENTRICITY": 0.000157, "INCLINATION": 97.9083, "RA_OF_ASC_NODE": 96.1616, "ARG_OF_PERICENTER": 88.2711, "MEAN_ANOMALY": 355.4287, "BSTAR": 0.00026665, "EPOCH": "2026-03-13T00:18:32"}, {"OBJECT_NAME": "YAOGAN-12", "NORAD_CAT_ID": 37875, "MEAN_MOTION": 15.25438053, "ECCENTRICITY": 0.0009402, "INCLINATION": 97.1281, "RA_OF_ASC_NODE": 115.1159, "ARG_OF_PERICENTER": 198.0113, "MEAN_ANOMALY": 162.0794, "BSTAR": 0.00018808, "EPOCH": "2026-03-13T00:05:04"}, {"OBJECT_NAME": "YAOGAN-31 01A", "NORAD_CAT_ID": 43275, "MEAN_MOTION": 13.45452409, "ECCENTRICITY": 0.0267054, "INCLINATION": 63.398, "RA_OF_ASC_NODE": 338.8649, "ARG_OF_PERICENTER": 7.1284, "MEAN_ANOMALY": 353.3421, "BSTAR": -0.00011453, "EPOCH": "2026-03-12T23:05:16"}, {"OBJECT_NAME": "YAOGAN-31 01B", "NORAD_CAT_ID": 43276, "MEAN_MOTION": 13.45448395, "ECCENTRICITY": 0.0267038, "INCLINATION": 63.3983, "RA_OF_ASC_NODE": 338.8652, "ARG_OF_PERICENTER": 7.0871, "MEAN_ANOMALY": 353.3813, "BSTAR": -0.00013058, "EPOCH": "2026-03-12T23:06:33"}, {"OBJECT_NAME": "YAOGAN-31 01C", "NORAD_CAT_ID": 43277, "MEAN_MOTION": 13.45432622, "ECCENTRICITY": 0.0267011, "INCLINATION": 63.3992, "RA_OF_ASC_NODE": 338.2118, "ARG_OF_PERICENTER": 6.8292, "MEAN_ANOMALY": 353.6258, "BSTAR": -0.00011101, "EPOCH": "2026-03-12T23:08:48"}, {"OBJECT_NAME": "YAOGAN-30 06A", "NORAD_CAT_ID": 44449, "MEAN_MOTION": 15.02127467, "ECCENTRICITY": 0.0003556, "INCLINATION": 34.9926, "RA_OF_ASC_NODE": 197.2178, "ARG_OF_PERICENTER": 166.9293, "MEAN_ANOMALY": 193.1507, "BSTAR": 0.00030103000000000004, "EPOCH": "2026-03-12T19:38:24"}, {"OBJECT_NAME": "YAOGAN-30 06B", "NORAD_CAT_ID": 44450, "MEAN_MOTION": 15.02115308, "ECCENTRICITY": 0.0009346, "INCLINATION": 34.9914, "RA_OF_ASC_NODE": 196.9119, "ARG_OF_PERICENTER": 307.5766, "MEAN_ANOMALY": 52.4093, "BSTAR": 0.00030176, "EPOCH": "2026-03-12T19:09:15"}, {"OBJECT_NAME": "YAOGAN-34 02", "NORAD_CAT_ID": 52084, "MEAN_MOTION": 13.45429166, "ECCENTRICITY": 0.0043473, "INCLINATION": 63.395, "RA_OF_ASC_NODE": 88.2268, "ARG_OF_PERICENTER": 351.6745, "MEAN_ANOMALY": 8.3555, "BSTAR": 3.1227e-05, "EPOCH": "2026-03-12T23:55:19"}, {"OBJECT_NAME": "YAOGAN-30 06C", "NORAD_CAT_ID": 44451, "MEAN_MOTION": 15.0219734, "ECCENTRICITY": 0.0001127, "INCLINATION": 34.9926, "RA_OF_ASC_NODE": 196.9283, "ARG_OF_PERICENTER": 267.8527, "MEAN_ANOMALY": 92.2052, "BSTAR": 0.00029921, "EPOCH": "2026-03-12T18:35:39"}, {"OBJECT_NAME": "YAOGAN-30 03A", "NORAD_CAT_ID": 43028, "MEAN_MOTION": 15.02266947, "ECCENTRICITY": 0.0002112, "INCLINATION": 34.994, "RA_OF_ASC_NODE": 139.0986, "ARG_OF_PERICENTER": 199.6992, "MEAN_ANOMALY": 160.3635, "BSTAR": 0.00028292000000000004, "EPOCH": "2026-03-12T14:28:04"}, {"OBJECT_NAME": "YAOGAN-34 01", "NORAD_CAT_ID": 48340, "MEAN_MOTION": 13.45463327, "ECCENTRICITY": 0.0079204, "INCLINATION": 63.4044, "RA_OF_ASC_NODE": 33.3975, "ARG_OF_PERICENTER": 356.7421, "MEAN_ANOMALY": 3.3079, "BSTAR": -5.2496e-05, "EPOCH": "2026-03-13T00:35:35"}, {"OBJECT_NAME": "YAOGAN-30 03B", "NORAD_CAT_ID": 43029, "MEAN_MOTION": 15.02146947, "ECCENTRICITY": 0.0002652, "INCLINATION": 34.9934, "RA_OF_ASC_NODE": 138.3911, "ARG_OF_PERICENTER": 118.7147, "MEAN_ANOMALY": 241.3828, "BSTAR": 0.00027392, "EPOCH": "2026-03-12T15:38:29"}, {"OBJECT_NAME": "YAOGAN-30 03C", "NORAD_CAT_ID": 43030, "MEAN_MOTION": 15.02196827, "ECCENTRICITY": 0.0002679, "INCLINATION": 34.9945, "RA_OF_ASC_NODE": 137.2591, "ARG_OF_PERICENTER": 278.8217, "MEAN_ANOMALY": 81.2188, "BSTAR": 0.00028332000000000005, "EPOCH": "2026-03-12T19:53:44"}, {"OBJECT_NAME": "GSAT0219 (GALILEO 23)", "NORAD_CAT_ID": 43566, "MEAN_MOTION": 1.70474822, "ECCENTRICITY": 0.0004318, "INCLINATION": 57.205, "RA_OF_ASC_NODE": 344.5202, "ARG_OF_PERICENTER": 344.0327, "MEAN_ANOMALY": 15.9782, "BSTAR": 0.0, "EPOCH": "2026-03-08T00:37:58"}, {"OBJECT_NAME": "GSAT0207 (GALILEO 15)", "NORAD_CAT_ID": 41859, "MEAN_MOTION": 1.70474556, "ECCENTRICITY": 0.0005331, "INCLINATION": 55.4345, "RA_OF_ASC_NODE": 103.7754, "ARG_OF_PERICENTER": 309.7356, "MEAN_ANOMALY": 50.2853, "BSTAR": 0.0, "EPOCH": "2026-03-11T23:01:07"}, {"OBJECT_NAME": "GSAT0103 (GALILEO-FM3)", "NORAD_CAT_ID": 38857, "MEAN_MOTION": 1.70473527, "ECCENTRICITY": 0.000557, "INCLINATION": 55.7441, "RA_OF_ASC_NODE": 104.2408, "ARG_OF_PERICENTER": 286.1218, "MEAN_ANOMALY": 73.8831, "BSTAR": 0.0, "EPOCH": "2026-03-10T22:17:59"}, {"OBJECT_NAME": "GSAT0225 (GALILEO 29)", "NORAD_CAT_ID": 59598, "MEAN_MOTION": 1.70475084, "ECCENTRICITY": 0.0002777, "INCLINATION": 55.0541, "RA_OF_ASC_NODE": 103.8268, "ARG_OF_PERICENTER": 260.3454, "MEAN_ANOMALY": 98.4267, "BSTAR": 0.0, "EPOCH": "2026-03-10T20:35:50"}, {"OBJECT_NAME": "GSAT0211 (GALILEO 14)", "NORAD_CAT_ID": 41549, "MEAN_MOTION": 1.70473955, "ECCENTRICITY": 0.000347, "INCLINATION": 55.1288, "RA_OF_ASC_NODE": 224.3646, "ARG_OF_PERICENTER": 23.7611, "MEAN_ANOMALY": 336.2141, "BSTAR": 0.0, "EPOCH": "2026-03-12T00:14:31"}, {"OBJECT_NAME": "GSAT0227 (GALILEO 30)", "NORAD_CAT_ID": 59600, "MEAN_MOTION": 1.70474936, "ECCENTRICITY": 3.38e-05, "INCLINATION": 55.0487, "RA_OF_ASC_NODE": 103.8366, "ARG_OF_PERICENTER": 50.1046, "MEAN_ANOMALY": 309.9638, "BSTAR": 0.0, "EPOCH": "2026-03-10T10:58:17"}, {"OBJECT_NAME": "GSAT0210 (GALILEO 13)", "NORAD_CAT_ID": 41550, "MEAN_MOTION": 1.70474, "ECCENTRICITY": 0.000138, "INCLINATION": 55.1262, "RA_OF_ASC_NODE": 224.3718, "ARG_OF_PERICENTER": 200.1148, "MEAN_ANOMALY": 159.8391, "BSTAR": 0.0, "EPOCH": "2026-03-11T16:20:19"}, {"OBJECT_NAME": "GSAT0232 (GALILEO 32)", "NORAD_CAT_ID": 61182, "MEAN_MOTION": 1.70443939, "ECCENTRICITY": 0.00036, "INCLINATION": 55.2225, "RA_OF_ASC_NODE": 224.5881, "ARG_OF_PERICENTER": 296.6314, "MEAN_ANOMALY": 116.1306, "BSTAR": 0.0, "EPOCH": "2026-02-17T15:52:34"}, {"OBJECT_NAME": "GSAT0226 (GALILEO 31)", "NORAD_CAT_ID": 61183, "MEAN_MOTION": 1.70474402, "ECCENTRICITY": 0.0001607, "INCLINATION": 55.2157, "RA_OF_ASC_NODE": 223.9612, "ARG_OF_PERICENTER": 134.9858, "MEAN_ANOMALY": 193.0209, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:06:24"}, {"OBJECT_NAME": "GSAT0212 (GALILEO 16)", "NORAD_CAT_ID": 41860, "MEAN_MOTION": 1.70474686, "ECCENTRICITY": 0.0003782, "INCLINATION": 55.4302, "RA_OF_ASC_NODE": 103.856, "ARG_OF_PERICENTER": 335.1738, "MEAN_ANOMALY": 24.8752, "BSTAR": 0.0, "EPOCH": "2026-03-08T21:06:50"}, {"OBJECT_NAME": "GSAT0233 (GALILEO 33)", "NORAD_CAT_ID": 67160, "MEAN_MOTION": 1.70474578, "ECCENTRICITY": 0.0003011, "INCLINATION": 54.3935, "RA_OF_ASC_NODE": 105.2952, "ARG_OF_PERICENTER": 207.0582, "MEAN_ANOMALY": 359.4109, "BSTAR": 0.0, "EPOCH": "2026-02-13T12:00:00"}, {"OBJECT_NAME": "GSAT0213 (GALILEO 17)", "NORAD_CAT_ID": 41861, "MEAN_MOTION": 1.70475014, "ECCENTRICITY": 0.0005437, "INCLINATION": 55.4354, "RA_OF_ASC_NODE": 103.7885, "ARG_OF_PERICENTER": 293.5263, "MEAN_ANOMALY": 66.4835, "BSTAR": 0.0, "EPOCH": "2026-03-11T14:14:38"}, {"OBJECT_NAME": "GSAT0101 (GALILEO-PFM)", "NORAD_CAT_ID": 37846, "MEAN_MOTION": 1.70475478, "ECCENTRICITY": 0.0003975, "INCLINATION": 57.0318, "RA_OF_ASC_NODE": 344.8758, "ARG_OF_PERICENTER": 357.0591, "MEAN_ANOMALY": 2.9533, "BSTAR": 0.0, "EPOCH": "2026-02-22T11:08:37"}, {"OBJECT_NAME": "GSAT0234 (GALILEO 34)", "NORAD_CAT_ID": 67162, "MEAN_MOTION": 1.70475065, "ECCENTRICITY": 0.0003081, "INCLINATION": 54.3967, "RA_OF_ASC_NODE": 105.2938, "ARG_OF_PERICENTER": 266.8423, "MEAN_ANOMALY": 119.7309, "BSTAR": 0.0, "EPOCH": "2026-02-13T12:00:00"}, {"OBJECT_NAME": "GSAT0214 (GALILEO 18)", "NORAD_CAT_ID": 41862, "MEAN_MOTION": 1.70474843, "ECCENTRICITY": 0.0004466, "INCLINATION": 55.4333, "RA_OF_ASC_NODE": 103.761, "ARG_OF_PERICENTER": 297.8849, "MEAN_ANOMALY": 62.1388, "BSTAR": 0.0, "EPOCH": "2026-03-12T07:49:39"}, {"OBJECT_NAME": "GSAT0102 (GALILEO-FM2)", "NORAD_CAT_ID": 37847, "MEAN_MOTION": 1.70475778, "ECCENTRICITY": 0.0005422, "INCLINATION": 57.0301, "RA_OF_ASC_NODE": 344.6971, "ARG_OF_PERICENTER": 341.2092, "MEAN_ANOMALY": 154.1953, "BSTAR": 0.0, "EPOCH": "2026-03-01T01:30:59"}, {"OBJECT_NAME": "GSAT0215 (GALILEO 19)", "NORAD_CAT_ID": 43055, "MEAN_MOTION": 1.70474613, "ECCENTRICITY": 5.05e-05, "INCLINATION": 55.1053, "RA_OF_ASC_NODE": 224.1716, "ARG_OF_PERICENTER": 355.7025, "MEAN_ANOMALY": 4.2562, "BSTAR": 0.0, "EPOCH": "2026-03-12T05:30:38"}, {"OBJECT_NAME": "GSAT0216 (GALILEO 20)", "NORAD_CAT_ID": 43056, "MEAN_MOTION": 1.70474737, "ECCENTRICITY": 0.0001727, "INCLINATION": 55.1059, "RA_OF_ASC_NODE": 224.1799, "ARG_OF_PERICENTER": 313.6436, "MEAN_ANOMALY": 46.3012, "BSTAR": 0.0, "EPOCH": "2026-03-11T22:25:18"}, {"OBJECT_NAME": "GALILEO104 [GAL]", "NORAD_CAT_ID": 38858, "MEAN_MOTION": 1.64592169, "ECCENTRICITY": 0.0001481, "INCLINATION": 55.4156, "RA_OF_ASC_NODE": 121.7581, "ARG_OF_PERICENTER": 313.3657, "MEAN_ANOMALY": 182.5612, "BSTAR": 0.0, "EPOCH": "2024-06-18T00:14:41"}, {"OBJECT_NAME": "GSAT0217 (GALILEO 21)", "NORAD_CAT_ID": 43057, "MEAN_MOTION": 1.70474639, "ECCENTRICITY": 0.0001948, "INCLINATION": 55.1048, "RA_OF_ASC_NODE": 224.1555, "ARG_OF_PERICENTER": 350.8522, "MEAN_ANOMALY": 9.1027, "BSTAR": 0.0, "EPOCH": "2026-03-12T17:47:52"}, {"OBJECT_NAME": "PAZ", "NORAD_CAT_ID": 43215, "MEAN_MOTION": 15.19153269, "ECCENTRICITY": 0.0001692, "INCLINATION": 97.4462, "RA_OF_ASC_NODE": 80.7586, "ARG_OF_PERICENTER": 88.8281, "MEAN_ANOMALY": 271.3147, "BSTAR": 0.00011151, "EPOCH": "2026-03-13T00:07:27"}, {"OBJECT_NAME": "BEIDOU-2 M4 (C12)", "NORAD_CAT_ID": 38251, "MEAN_MOTION": 1.86229837, "ECCENTRICITY": 0.0012233, "INCLINATION": 55.7415, "RA_OF_ASC_NODE": 307.3942, "ARG_OF_PERICENTER": 287.3312, "MEAN_ANOMALY": 72.5873, "BSTAR": 0.0, "EPOCH": "2026-03-11T09:28:12"}, {"OBJECT_NAME": "BEIDOU-2 M1", "NORAD_CAT_ID": 31115, "MEAN_MOTION": 1.77349392, "ECCENTRICITY": 0.0002326, "INCLINATION": 50.9679, "RA_OF_ASC_NODE": 222.5681, "ARG_OF_PERICENTER": 33.8688, "MEAN_ANOMALY": 326.1041, "BSTAR": 0.0, "EPOCH": "2026-03-10T03:28:02"}, {"OBJECT_NAME": "BEIDOU-2 G8 (C01)", "NORAD_CAT_ID": 44231, "MEAN_MOTION": 1.00274335, "ECCENTRICITY": 0.0011489, "INCLINATION": 1.4128, "RA_OF_ASC_NODE": 74.2589, "ARG_OF_PERICENTER": 274.3988, "MEAN_ANOMALY": 259.4146, "BSTAR": 0.0, "EPOCH": "2026-03-12T19:32:31"}, {"OBJECT_NAME": "BEIDOU-3 M24 (C46)", "NORAD_CAT_ID": 44542, "MEAN_MOTION": 1.86229082, "ECCENTRICITY": 0.0008561, "INCLINATION": 54.3985, "RA_OF_ASC_NODE": 185.9849, "ARG_OF_PERICENTER": 19.8304, "MEAN_ANOMALY": 340.1884, "BSTAR": 0.0, "EPOCH": "2026-03-12T14:10:15"}, {"OBJECT_NAME": "BEIDOU-3 M23 (C45)", "NORAD_CAT_ID": 44543, "MEAN_MOTION": 1.86227389, "ECCENTRICITY": 0.0004976, "INCLINATION": 54.3975, "RA_OF_ASC_NODE": 185.9688, "ARG_OF_PERICENTER": 10.6705, "MEAN_ANOMALY": 318.8703, "BSTAR": 0.0, "EPOCH": "2026-03-12T03:20:09"}, {"OBJECT_NAME": "BEIDOU-3 M5 (C23)", "NORAD_CAT_ID": 43581, "MEAN_MOTION": 1.86228824, "ECCENTRICITY": 0.0002388, "INCLINATION": 54.0877, "RA_OF_ASC_NODE": 185.5927, "ARG_OF_PERICENTER": 304.9134, "MEAN_ANOMALY": 55.0505, "BSTAR": 0.0, "EPOCH": "2026-03-12T10:55:13"}, {"OBJECT_NAME": "BEIDOU-3 M22 (C44)", "NORAD_CAT_ID": 44793, "MEAN_MOTION": 1.86232103, "ECCENTRICITY": 0.0007399, "INCLINATION": 54.0269, "RA_OF_ASC_NODE": 303.384, "ARG_OF_PERICENTER": 37.3018, "MEAN_ANOMALY": 322.7988, "BSTAR": 0.0, "EPOCH": "2026-03-08T03:13:04"}, {"OBJECT_NAME": "BEIDOU-3 M6 (C24)", "NORAD_CAT_ID": 43582, "MEAN_MOTION": 1.86227817, "ECCENTRICITY": 0.0005998, "INCLINATION": 54.0872, "RA_OF_ASC_NODE": 185.5681, "ARG_OF_PERICENTER": 41.9373, "MEAN_ANOMALY": 318.0942, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:31:06"}, {"OBJECT_NAME": "BEIDOU-3 M21 (C43)", "NORAD_CAT_ID": 44794, "MEAN_MOTION": 1.86227925, "ECCENTRICITY": 0.0005425, "INCLINATION": 53.9966, "RA_OF_ASC_NODE": 303.2101, "ARG_OF_PERICENTER": 33.1471, "MEAN_ANOMALY": 326.9381, "BSTAR": 0.0, "EPOCH": "2026-03-12T00:46:41"}, {"OBJECT_NAME": "BEIDOU-2 G3", "NORAD_CAT_ID": 36590, "MEAN_MOTION": 1.00275215, "ECCENTRICITY": 0.0008048, "INCLINATION": 4.2857, "RA_OF_ASC_NODE": 61.8915, "ARG_OF_PERICENTER": 324.9092, "MEAN_ANOMALY": 74.4856, "BSTAR": 0.0, "EPOCH": "2026-03-12T14:06:02"}, {"OBJECT_NAME": "BEIDOU-3 G2 (C60)", "NORAD_CAT_ID": 45344, "MEAN_MOTION": 1.00272654, "ECCENTRICITY": 0.0002714, "INCLINATION": 2.67, "RA_OF_ASC_NODE": 45.7202, "ARG_OF_PERICENTER": 321.3893, "MEAN_ANOMALY": 146.2035, "BSTAR": 0.0, "EPOCH": "2026-03-12T17:31:35"}, {"OBJECT_NAME": "BEIDOU-3 M14 (C33)", "NORAD_CAT_ID": 43623, "MEAN_MOTION": 1.86231255, "ECCENTRICITY": 0.0006566, "INCLINATION": 56.5423, "RA_OF_ASC_NODE": 65.852, "ARG_OF_PERICENTER": 329.5536, "MEAN_ANOMALY": 30.428, "BSTAR": 0.0, "EPOCH": "2026-03-12T05:05:49"}, {"OBJECT_NAME": "BEIDOU-2 G7 (C03)", "NORAD_CAT_ID": 41586, "MEAN_MOTION": 1.0027645, "ECCENTRICITY": 0.0007523, "INCLINATION": 1.481, "RA_OF_ASC_NODE": 68.3002, "ARG_OF_PERICENTER": 346.1825, "MEAN_ANOMALY": 167.097, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:02:21"}, {"OBJECT_NAME": "BEIDOU-3 G3 (C61)", "NORAD_CAT_ID": 45807, "MEAN_MOTION": 1.00271689, "ECCENTRICITY": 0.0004782, "INCLINATION": 2.2094, "RA_OF_ASC_NODE": 67.1471, "ARG_OF_PERICENTER": 324.5605, "MEAN_ANOMALY": 189.8852, "BSTAR": 0.0, "EPOCH": "2026-03-12T20:02:21"}, {"OBJECT_NAME": "BEIDOU-3 M16 (C35)", "NORAD_CAT_ID": 43647, "MEAN_MOTION": 1.86232446, "ECCENTRICITY": 0.0008474, "INCLINATION": 54.1782, "RA_OF_ASC_NODE": 303.4188, "ARG_OF_PERICENTER": 32.1958, "MEAN_ANOMALY": 327.906, "BSTAR": 0.0, "EPOCH": "2026-03-11T07:05:10"}, {"OBJECT_NAME": "BEIDOU-3 M1 (C19)", "NORAD_CAT_ID": 43001, "MEAN_MOTION": 1.86231509, "ECCENTRICITY": 0.0012528, "INCLINATION": 56.6085, "RA_OF_ASC_NODE": 66.268, "ARG_OF_PERICENTER": 309.761, "MEAN_ANOMALY": 50.1495, "BSTAR": 0.0, "EPOCH": "2026-03-12T11:32:42"}, {"OBJECT_NAME": "SBIRS GEO-3 (USA 282)", "NORAD_CAT_ID": 43162, "MEAN_MOTION": 1.00273129, "ECCENTRICITY": 0.0002224, "INCLINATION": 2.1259, "RA_OF_ASC_NODE": 7.3101, "ARG_OF_PERICENTER": 349.6794, "MEAN_ANOMALY": 285.4736, "BSTAR": 0.0, "EPOCH": "2026-03-12T18:05:54"}, {"OBJECT_NAME": "SBIRS GEO-5 (USA 315)", "NORAD_CAT_ID": 48618, "MEAN_MOTION": 1.00272003, "ECCENTRICITY": 0.0001266, "INCLINATION": 5.2277, "RA_OF_ASC_NODE": 328.3819, "ARG_OF_PERICENTER": 25.9227, "MEAN_ANOMALY": 309.8958, "BSTAR": 0.0, "EPOCH": "2026-03-12T02:42:38"}, {"OBJECT_NAME": "SBIRS GEO-6 (USA 336)", "NORAD_CAT_ID": 53355, "MEAN_MOTION": 1.00272084, "ECCENTRICITY": 0.0002109, "INCLINATION": 3.424, "RA_OF_ASC_NODE": 315.939, "ARG_OF_PERICENTER": 38.6757, "MEAN_ANOMALY": 247.5789, "BSTAR": 0.0, "EPOCH": "2026-03-12T19:31:02"}, {"OBJECT_NAME": "SBIRS GEO-1 (USA 230)", "NORAD_CAT_ID": 37481, "MEAN_MOTION": 1.00272337, "ECCENTRICITY": 0.0002301, "INCLINATION": 4.3244, "RA_OF_ASC_NODE": 53.0984, "ARG_OF_PERICENTER": 296.3366, "MEAN_ANOMALY": 107.8414, "BSTAR": 0.0, "EPOCH": "2026-03-12T15:40:45"}, {"OBJECT_NAME": "SBIRS GEO-2 (USA 241)", "NORAD_CAT_ID": 39120, "MEAN_MOTION": 1.00271601, "ECCENTRICITY": 0.0002249, "INCLINATION": 4.2802, "RA_OF_ASC_NODE": 52.3926, "ARG_OF_PERICENTER": 298.377, "MEAN_ANOMALY": 49.142, "BSTAR": 0.0, "EPOCH": "2026-03-12T18:37:14"}, {"OBJECT_NAME": "SBIRS GEO-4 (USA 273)", "NORAD_CAT_ID": 41937, "MEAN_MOTION": 1.00272641, "ECCENTRICITY": 0.0002302, "INCLINATION": 2.063, "RA_OF_ASC_NODE": 40.3295, "ARG_OF_PERICENTER": 317.583, "MEAN_ANOMALY": 117.7402, "BSTAR": 0.0, "EPOCH": "2026-03-12T18:57:19"}, {"OBJECT_NAME": "GEOEYE 1", "NORAD_CAT_ID": 33331, "MEAN_MOTION": 14.64771945, "ECCENTRICITY": 0.0003779, "INCLINATION": 98.1201, "RA_OF_ASC_NODE": 147.1107, "ARG_OF_PERICENTER": 19.3091, "MEAN_ANOMALY": 340.8254, "BSTAR": 0.00012681999999999998, "EPOCH": "2026-03-12T23:10:22"}, {"OBJECT_NAME": "COSMO-SKYMED 1", "NORAD_CAT_ID": 31598, "MEAN_MOTION": 14.96566492, "ECCENTRICITY": 0.0001528, "INCLINATION": 97.8881, "RA_OF_ASC_NODE": 263.0659, "ARG_OF_PERICENTER": 97.1485, "MEAN_ANOMALY": 262.991, "BSTAR": 0.00044157999999999996, "EPOCH": "2026-03-13T00:10:53"}, {"OBJECT_NAME": "COSMO-SKYMED 2", "NORAD_CAT_ID": 32376, "MEAN_MOTION": 14.82147786, "ECCENTRICITY": 0.0001333, "INCLINATION": 97.8872, "RA_OF_ASC_NODE": 256.9473, "ARG_OF_PERICENTER": 83.6213, "MEAN_ANOMALY": 276.5152, "BSTAR": 9.134400000000001e-05, "EPOCH": "2026-03-12T23:00:16"}, {"OBJECT_NAME": "COSMO-SKYMED 4", "NORAD_CAT_ID": 37216, "MEAN_MOTION": 14.82143491, "ECCENTRICITY": 0.000151, "INCLINATION": 97.8872, "RA_OF_ASC_NODE": 256.9598, "ARG_OF_PERICENTER": 83.5443, "MEAN_ANOMALY": 276.5942, "BSTAR": 5.437300000000001e-05, "EPOCH": "2026-03-12T23:18:29"}, {"OBJECT_NAME": "COSMO-SKYMED 3", "NORAD_CAT_ID": 33412, "MEAN_MOTION": 15.06283189, "ECCENTRICITY": 0.0014547, "INCLINATION": 97.8418, "RA_OF_ASC_NODE": 288.92, "ARG_OF_PERICENTER": 229.1396, "MEAN_ANOMALY": 130.857, "BSTAR": 0.00042098000000000005, "EPOCH": "2026-03-12T23:23:37"}, {"OBJECT_NAME": "ISS (ZARYA)", "NORAD_CAT_ID": 25544, "MEAN_MOTION": 15.48614629, "ECCENTRICITY": 0.0007923, "INCLINATION": 51.6324, "RA_OF_ASC_NODE": 56.6367, "ARG_OF_PERICENTER": 186.141, "MEAN_ANOMALY": 173.9482, "BSTAR": 0.00021655, "EPOCH": "2026-03-12T20:51:23"}, {"OBJECT_NAME": "ISS (NAUKA)", "NORAD_CAT_ID": 49044, "MEAN_MOTION": 15.48614629, "ECCENTRICITY": 0.0007923, "INCLINATION": 51.6324, "RA_OF_ASC_NODE": 56.6367, "ARG_OF_PERICENTER": 186.141, "MEAN_ANOMALY": 173.9482, "BSTAR": 0.00021655, "EPOCH": "2026-03-12T20:51:23"}, {"OBJECT_NAME": "SWISSCUBE", "NORAD_CAT_ID": 35932, "MEAN_MOTION": 14.62269136, "ECCENTRICITY": 0.0007285, "INCLINATION": 98.4095, "RA_OF_ASC_NODE": 337.8898, "ARG_OF_PERICENTER": 144.0492, "MEAN_ANOMALY": 216.12, "BSTAR": 0.00030458000000000004, "EPOCH": "2026-03-12T16:38:47"}, {"OBJECT_NAME": "AISSAT 1", "NORAD_CAT_ID": 36797, "MEAN_MOTION": 14.96909805, "ECCENTRICITY": 0.0009449, "INCLINATION": 98.1016, "RA_OF_ASC_NODE": 327.5245, "ARG_OF_PERICENTER": 23.7278, "MEAN_ANOMALY": 336.4377, "BSTAR": 0.00027106000000000005, "EPOCH": "2026-03-12T16:49:15"}, {"OBJECT_NAME": "AISSAT 2", "NORAD_CAT_ID": 40075, "MEAN_MOTION": 14.8560182, "ECCENTRICITY": 0.000478, "INCLINATION": 98.3401, "RA_OF_ASC_NODE": 268.4723, "ARG_OF_PERICENTER": 335.0232, "MEAN_ANOMALY": 25.0749, "BSTAR": 0.00040707, "EPOCH": "2023-12-28T11:59:02"}, {"OBJECT_NAME": "ISS OBJECT XK", "NORAD_CAT_ID": 65731, "MEAN_MOTION": 16.39076546, "ECCENTRICITY": 0.0002464, "INCLINATION": 51.6052, "RA_OF_ASC_NODE": 44.2508, "ARG_OF_PERICENTER": 211.8886, "MEAN_ANOMALY": 148.1991, "BSTAR": 0.00075547, "EPOCH": "2026-03-09T22:15:28"}, {"OBJECT_NAME": "ISS OBJECT XU", "NORAD_CAT_ID": 66908, "MEAN_MOTION": 15.7025379, "ECCENTRICITY": 0.0004849, "INCLINATION": 51.6251, "RA_OF_ASC_NODE": 52.8423, "ARG_OF_PERICENTER": 95.5022, "MEAN_ANOMALY": 264.6529, "BSTAR": 0.0013761000000000001, "EPOCH": "2026-03-12T04:35:10"}, {"OBJECT_NAME": "ISS (UNITY)", "NORAD_CAT_ID": 25575, "MEAN_MOTION": 15.48614629, "ECCENTRICITY": 0.0007923, "INCLINATION": 51.6324, "RA_OF_ASC_NODE": 56.6367, "ARG_OF_PERICENTER": 186.141, "MEAN_ANOMALY": 173.9482, "BSTAR": 0.00021655, "EPOCH": "2026-03-12T20:51:23"}, {"OBJECT_NAME": "OUTPOST MISSION 2", "NORAD_CAT_ID": 58334, "MEAN_MOTION": 15.50973738, "ECCENTRICITY": 0.000699, "INCLINATION": 97.3967, "RA_OF_ASC_NODE": 161.7887, "ARG_OF_PERICENTER": 113.9372, "MEAN_ANOMALY": 246.2613, "BSTAR": 0.00064764, "EPOCH": "2026-03-12T23:38:50"}, {"OBJECT_NAME": "ISS OBJECT YE", "NORAD_CAT_ID": 67688, "MEAN_MOTION": 15.52834307, "ECCENTRICITY": 0.0007968, "INCLINATION": 51.6314, "RA_OF_ASC_NODE": 55.6037, "ARG_OF_PERICENTER": 162.5532, "MEAN_ANOMALY": 197.5733, "BSTAR": 0.00084072, "EPOCH": "2026-03-12T22:31:08"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 58229, "MEAN_MOTION": 16.43977649, "ECCENTRICITY": 0.0006009, "INCLINATION": 51.6071, "RA_OF_ASC_NODE": 235.9974, "ARG_OF_PERICENTER": 315.3037, "MEAN_ANOMALY": 44.7506, "BSTAR": 0.00037573, "EPOCH": "2024-06-26T14:41:28"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 62376, "MEAN_MOTION": 16.23766836, "ECCENTRICITY": 0.0010647, "INCLINATION": 51.6124, "RA_OF_ASC_NODE": 290.5973, "ARG_OF_PERICENTER": 277.8505, "MEAN_ANOMALY": 82.1305, "BSTAR": 0.0017468, "EPOCH": "2025-04-05T18:57:57"}, {"OBJECT_NAME": "ISS (ZVEZDA)", "NORAD_CAT_ID": 26400, "MEAN_MOTION": 15.48614629, "ECCENTRICITY": 0.0007923, "INCLINATION": 51.6324, "RA_OF_ASC_NODE": 56.6367, "ARG_OF_PERICENTER": 186.141, "MEAN_ANOMALY": 173.9482, "BSTAR": 0.00021655, "EPOCH": "2026-03-12T20:51:23"}, {"OBJECT_NAME": "ISS OBJECT XT", "NORAD_CAT_ID": 66907, "MEAN_MOTION": 15.70168045, "ECCENTRICITY": 0.0004715, "INCLINATION": 51.6249, "RA_OF_ASC_NODE": 52.2093, "ARG_OF_PERICENTER": 95.5605, "MEAN_ANOMALY": 264.593, "BSTAR": 0.0014377, "EPOCH": "2026-03-12T07:41:21"}, {"OBJECT_NAME": "ISS (DESTINY)", "NORAD_CAT_ID": 26700, "MEAN_MOTION": 15.48614629, "ECCENTRICITY": 0.0007923, "INCLINATION": 51.6324, "RA_OF_ASC_NODE": 56.6367, "ARG_OF_PERICENTER": 186.141, "MEAN_ANOMALY": 173.9482, "BSTAR": 0.00021655, "EPOCH": "2026-03-12T20:51:23"}, {"OBJECT_NAME": "ISS OBJECT XW", "NORAD_CAT_ID": 66910, "MEAN_MOTION": 15.69619162, "ECCENTRICITY": 0.0005325, "INCLINATION": 51.6244, "RA_OF_ASC_NODE": 48.9273, "ARG_OF_PERICENTER": 105.661, "MEAN_ANOMALY": 254.4975, "BSTAR": 0.0012885, "EPOCH": "2026-03-12T22:52:31"}, {"OBJECT_NAME": "ISS OBJECT XX", "NORAD_CAT_ID": 66911, "MEAN_MOTION": 15.76985712, "ECCENTRICITY": 0.0006975, "INCLINATION": 51.6223, "RA_OF_ASC_NODE": 49.6058, "ARG_OF_PERICENTER": 102.6102, "MEAN_ANOMALY": 257.5678, "BSTAR": 0.001714, "EPOCH": "2026-03-12T11:54:35"}, {"OBJECT_NAME": "ISS OBJECT XY", "NORAD_CAT_ID": 66912, "MEAN_MOTION": 15.64357529, "ECCENTRICITY": 0.0003327, "INCLINATION": 51.6271, "RA_OF_ASC_NODE": 50.5463, "ARG_OF_PERICENTER": 107.6228, "MEAN_ANOMALY": 252.5131, "BSTAR": 0.0010318, "EPOCH": "2026-03-12T23:14:01"}, {"OBJECT_NAME": "ISS DEB", "NORAD_CAT_ID": 47853, "MEAN_MOTION": 16.41769315, "ECCENTRICITY": 0.0002766, "INCLINATION": 51.6054, "RA_OF_ASC_NODE": 359.3486, "ARG_OF_PERICENTER": 253.9051, "MEAN_ANOMALY": 215.3296, "BSTAR": 0.00025185999999999996, "EPOCH": "2024-03-08T00:53:30"}, {"OBJECT_NAME": "ISS OBJECT YC", "NORAD_CAT_ID": 67686, "MEAN_MOTION": 15.53033071, "ECCENTRICITY": 0.000812, "INCLINATION": 51.6318, "RA_OF_ASC_NODE": 59.1061, "ARG_OF_PERICENTER": 159.6101, "MEAN_ANOMALY": 200.5215, "BSTAR": 0.00089399, "EPOCH": "2026-03-12T05:27:31"}] \ No newline at end of file diff --git a/backend/extract_ovens.py b/backend/extract_ovens.py deleted file mode 100644 index dd6ca8b..0000000 --- a/backend/extract_ovens.py +++ /dev/null @@ -1,25 +0,0 @@ -import re -import json - -try: - with open('liveua_test.html', 'r', encoding='utf-8') as f: - html = f.read() - - m = re.search(r"var\s+ovens\s*=\s*(.*?);(?!function)", html, re.DOTALL) - if m: - json_str = m.group(1) - # Handle if it is a string containing base64 - if json_str.startswith("'") or json_str.startswith('"'): - json_str = json_str.strip('"\'') - import base64 - import urllib.parse - json_str = base64.b64decode(urllib.parse.unquote(json_str)).decode('utf-8') - - data = json.loads(json_str) - with open('out_liveua.json', 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2) - print(f"Successfully extracted {len(data)} ovens items.") - else: - print("var ovens not found.") -except Exception as e: - print("Error:", e) diff --git a/backend/geocode_datacenters.py b/backend/geocode_datacenters.py deleted file mode 100644 index 0774ad3..0000000 --- a/backend/geocode_datacenters.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Geocode data center street addresses via Nominatim (OpenStreetMap). -Rate limit: 1 request/second (Nominatim policy). -Resumable: caches results in geocode_cache.json so interrupted runs can continue. -""" -import json -import time -import urllib.request -import urllib.parse -import os -import sys - -# Fix Windows console encoding + force unbuffered output -if sys.platform == "win32": - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - sys.stderr.reconfigure(encoding="utf-8", errors="replace") - -# Force line-buffered stdout for detached processes -class Unbuffered: - def __init__(self, stream): - self.stream = stream - def write(self, data): - self.stream.write(data) - self.stream.flush() - def writelines(self, datas): - self.stream.writelines(datas) - self.stream.flush() - def __getattr__(self, attr): - return getattr(self.stream, attr) - -sys.stdout = Unbuffered(sys.stdout) - -DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters.json") -CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "geocode_cache.json") -OUTPUT_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters_geocoded.json") - -NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" -USER_AGENT = "ShadowBroker-DataCenterGeocoder/1.0" - - -def geocode_address(address: str, retries: int = 3) -> tuple[float, float] | None: - """Geocode a single address via Nominatim. Returns (lat, lng) or None.""" - params = urllib.parse.urlencode({"q": address, "format": "json", "limit": 1}) - url = f"{NOMINATIM_URL}?{params}" - for attempt in range(retries): - req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - try: - resp = urllib.request.urlopen(req, timeout=15) - data = json.loads(resp.read()) - if data: - return float(data[0]["lat"]), float(data[0]["lon"]) - return None # Valid response but no results - except Exception as e: - if attempt < retries - 1: - wait = 2 ** (attempt + 1) - print(f" RETRY ({attempt+1}/{retries}): {e} — waiting {wait}s") - time.sleep(wait) - else: - print(f" ERROR (gave up after {retries} attempts): {e}") - return None - - -def main(): - with open(DATA_FILE, "r", encoding="utf-8") as f: - dcs = json.load(f) - - # Load cache - cache = {} - if os.path.exists(CACHE_FILE): - with open(CACHE_FILE, "r", encoding="utf-8") as f: - cache = json.load(f) - print(f"Loaded {len(cache)} cached geocode results") - - # Filter to DCs with real street addresses - to_geocode = [] - skipped = 0 - for i, dc in enumerate(dcs): - street = (dc.get("street") or "").strip() - if not street or len(street) <= 3 or street.lower() in ("tbc", "n/a", "na", "-"): - skipped += 1 - continue - to_geocode.append((i, dc)) - - print(f"Total DCs: {len(dcs)}") - print(f"Skipped (no real address): {skipped}") - print(f"To geocode: {len(to_geocode)}") - - # Count how many already cached - already_cached = sum(1 for _, dc in to_geocode if dc.get("address", "") in cache) - need_api = len(to_geocode) - already_cached - print(f"Already cached: {already_cached}") - print(f"Need API calls: {need_api}") - if need_api > 0: - print(f"Estimated time: {need_api // 60}m {need_api % 60}s") - print() - - geocoded = 0 - failed = 0 - api_calls = 0 - save_interval = 50 # Save cache every 50 API calls - - for idx, (i, dc) in enumerate(to_geocode): - address = dc.get("address", "").strip() - if not address: - # Build address from parts - parts = [dc.get("street", ""), dc.get("zip", ""), dc.get("city", ""), dc.get("country", "")] - address = " ".join(p.strip() for p in parts if p and p.strip()) - - if not address: - failed += 1 - continue - - # Check cache first - if address in cache: - result = cache[address] - if result: - dcs[i]["lat"] = result[0] - dcs[i]["lng"] = result[1] - dcs[i]["geocode_source"] = "nominatim" - geocoded += 1 - else: - failed += 1 - continue - - # API call — Nominatim requires 1 req/s, use 1.5s to avoid 429s after heavy use - time.sleep(1.5) - coords = geocode_address(address) - api_calls += 1 - - if coords: - cache[address] = coords - dcs[i]["lat"] = coords[0] - dcs[i]["lng"] = coords[1] - dcs[i]["geocode_source"] = "nominatim" - geocoded += 1 - print(f"[{api_calls}/{need_api}] OK: {dc.get('name', '?')} -> ({coords[0]:.4f}, {coords[1]:.4f})") - else: - cache[address] = None - failed += 1 - print(f"[{api_calls}/{need_api}] FAIL: {dc.get('name', '?')} | {address}") - - # Periodic cache save - if api_calls % save_interval == 0: - with open(CACHE_FILE, "w", encoding="utf-8") as f: - json.dump(cache, f) - print(f" -- Cache saved ({len(cache)} entries) --") - - # Final save - with open(CACHE_FILE, "w", encoding="utf-8") as f: - json.dump(cache, f) - - # Write output - only DCs with real coordinates - output = [dc for dc in dcs if dc.get("lat") is not None and dc.get("lng") is not None] - - with open(OUTPUT_FILE, "w", encoding="utf-8") as f: - json.dump(output, f, indent=2) - - print(f"\nDone!") - print(f"Geocoded: {geocoded}") - print(f"Failed: {failed}") - print(f"API calls made: {api_calls}") - print(f"Output: {len(output)} DCs with coordinates -> {OUTPUT_FILE}") - - -if __name__ == "__main__": - main() diff --git a/backend/main.py b/backend/main.py index aff5b02..2e632ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,17 +35,22 @@ for _var in _SECRET_VARS: except Exception as _e: logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}") -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI, Request, Response, Query from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps from services.ais_stream import start_ais_stream, stop_ais_stream from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded import uvicorn import hashlib import json as json_mod import socket +limiter = Limiter(key_func=get_remote_address) + def _build_cors_origins(): """Build a CORS origins whitelist: localhost + LAN IPs + env overrides. @@ -74,10 +79,32 @@ def _build_cors_origins(): @asynccontextmanager async def lifespan(app: FastAPI): - # Startup: Start background data fetching, AIS stream, and carrier tracker - start_carrier_tracker() + import threading + + # Start AIS stream first — it loads the disk cache (instant ships) then + # begins accumulating live vessel data via WebSocket in the background. start_ais_stream() + + # Carrier tracker runs its own initial update_carrier_positions() internally + # in _scheduler_loop, so we do NOT call it again in the preload thread. + start_carrier_tracker() + + # Start the recurring scheduler (fast=60s, slow=30min). start_scheduler() + + # Kick off the full data preload in a background thread so the server + # is listening on port 8000 instantly. The frontend's adaptive polling + # (retries every 3s) will pick up data piecemeal as each fetcher finishes. + def _background_preload(): + logger.info("=== PRELOADING DATA (background — server already accepting requests) ===") + try: + update_all_data() + logger.info("=== PRELOAD COMPLETE ===") + except Exception as e: + logger.error(f"Data preload failed (non-fatal): {e}") + + threading.Thread(target=_background_preload, daemon=True).start() + yield # Shutdown: Stop all background services stop_ais_stream() @@ -85,6 +112,8 @@ async def lifespan(app: FastAPI): stop_carrier_tracker() app = FastAPI(title="Live Risk Dashboard API", lifespan=lifespan) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) from fastapi.middleware.gzip import GZipMiddleware app.add_middleware(GZipMiddleware, minimum_size=1000) @@ -98,11 +127,23 @@ app.add_middleware( from services.data_fetcher import update_all_data +_refresh_in_progress = False + @app.get("/api/refresh") -async def force_refresh(): - # Force an immediate synchronous update of the data payload +@limiter.limit("2/minute") +async def force_refresh(request: Request): + global _refresh_in_progress + if _refresh_in_progress: + return {"status": "refresh already in progress"} import threading - t = threading.Thread(target=update_all_data) + def _do_refresh(): + global _refresh_in_progress + try: + update_all_data() + finally: + _refresh_in_progress = False + _refresh_in_progress = True + t = threading.Thread(target=_do_refresh) t.start() return {"status": "refreshing in background"} @@ -113,13 +154,14 @@ async def live_data(): def _etag_response(request: Request, payload: dict, prefix: str = "", default=None): """Serialize once, hash the bytes for ETag, return 304 or full response.""" content = json_mod.dumps(payload, default=default) - etag = hashlib.md5(f"{prefix}{content[:256]}".encode()).hexdigest()[:16] + etag = hashlib.md5(f"{prefix}{content}".encode()).hexdigest()[:16] if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) return Response(content=content, media_type="application/json", headers={"ETag": etag, "Cache-Control": "no-cache"}) @app.get("/api/live-data/fast") +@limiter.limit("120/minute") async def live_data_fast(request: Request): d = get_latest_data() payload = { @@ -140,6 +182,7 @@ async def live_data_fast(request: Request): return _etag_response(request, payload, prefix="fast|") @app.get("/api/live-data/slow") +@limiter.limit("60/minute") async def live_data_slow(request: Request): d = get_latest_data() payload = { @@ -210,13 +253,20 @@ async def api_get_openmhz_calls(sys_name: str): return get_recent_openmhz_calls(sys_name) @app.get("/api/radio/nearest") -async def api_get_nearest_radio(lat: float, lng: float): +async def api_get_nearest_radio( + lat: float = Query(..., ge=-90, le=90), + lng: float = Query(..., ge=-180, le=180), +): return find_nearest_openmhz_system(lat, lng) from services.radio_intercept import find_nearest_openmhz_systems_list @app.get("/api/radio/nearest-list") -async def api_get_nearest_radios_list(lat: float, lng: float, limit: int = 5): +async def api_get_nearest_radios_list( + lat: float = Query(..., ge=-90, le=90), + lng: float = Query(..., ge=-180, le=180), + limit: int = Query(5, ge=1, le=20), +): return find_nearest_openmhz_systems_list(lat, lng, limit=limit) from services.network_utils import fetch_with_curl @@ -249,14 +299,24 @@ async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0): from services.region_dossier import get_region_dossier @app.get("/api/region-dossier") -def api_region_dossier(lat: float, lng: float): +@limiter.limit("30/minute") +def api_region_dossier( + request: Request, + lat: float = Query(..., ge=-90, le=90), + lng: float = Query(..., ge=-180, le=180), +): """Sync def so FastAPI runs it in a threadpool — prevents blocking the event loop.""" return get_region_dossier(lat, lng) from services.sentinel_search import search_sentinel2_scene @app.get("/api/sentinel2/search") -def api_sentinel2_search(lat: float, lng: float): +@limiter.limit("30/minute") +def api_sentinel2_search( + request: Request, + lat: float = Query(..., ge=-90, le=90), + lng: float = Query(..., ge=-180, le=180), +): """Search for latest Sentinel-2 imagery at a point. Sync for threadpool execution.""" return search_sentinel2_scene(lat, lng) @@ -309,7 +369,28 @@ async def api_reset_news_feeds(): return {"status": "reset", "feeds": get_feeds()} return {"status": "error", "message": "Failed to reset feeds"} +# --------------------------------------------------------------------------- +# System — self-update +# --------------------------------------------------------------------------- +from pathlib import Path +from services.updater import perform_update, schedule_restart + +@app.post("/api/system/update") +@limiter.limit("1/minute") +async def system_update(request: Request): + """Download latest release, backup current files, extract update, and restart.""" + project_root = str(Path(__file__).resolve().parent.parent) + result = perform_update(project_root) + if result.get("status") == "error": + return Response( + content=json_mod.dumps(result), + status_code=500, + media_type="application/json", + ) + # Schedule restart AFTER response flushes (2s delay) + import threading + threading.Timer(2.0, schedule_restart, args=[project_root]).start() + return result + if __name__ == "__main__": uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) - -# Application successfully initialized with background scraping tasks diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..6605410 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_functions = test_* diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..a575cbd --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.4 +httpx==0.28.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 47b0998..287028f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,20 +1,22 @@ -fastapi>=0.103.1 -uvicorn>=0.23.2 -yfinance>=0.2.40 +fastapi==0.115.12 +uvicorn==0.34.0 +yfinance==0.2.54 feedparser==6.0.10 -legacy-cgi>=2.6 +legacy-cgi==2.6.2 requests==2.31.0 apscheduler==3.10.3 -pydantic>=2.3.0 -pydantic-settings>=2.0.3 -playwright>=1.58.0 -beautifulsoup4>=4.12.0 -cachetools>=5.3 -cloudscraper>=1.2.71 -python-dotenv>=1.0 -lxml>=5.0 -reverse_geocoder>=1.5 -sgp4>=2.23 -geopy>=2.4.0 -pytz>=2023.3 -pystac-client>=0.7.0 +pydantic==2.11.1 +pydantic-settings==2.8.1 +playwright==1.50.0 +playwright-stealth==1.0.6 +beautifulsoup4==4.13.3 +cachetools==5.5.2 +slowapi==0.1.9 +cloudscraper==1.2.71 +python-dotenv==1.0.1 +lxml==5.3.1 +reverse_geocoder==1.5.1 +sgp4==2.23 +geopy==2.4.1 +pytz==2024.2 +pystac-client==0.8.6 diff --git a/backend/services/ais_stream.py b/backend/services/ais_stream.py index 22c2a2e..9691022 100644 --- a/backend/services/ais_stream.py +++ b/backend/services/ais_stream.py @@ -144,7 +144,7 @@ def _save_cache(): with open(CACHE_FILE, 'w') as f: json.dump(data, f) logger.info(f"AIS cache saved: {len(data)} vessels") - except Exception as e: + except (IOError, OSError) as e: logger.error(f"Failed to save AIS cache: {e}") @@ -165,7 +165,7 @@ def _load_cache(): _vessels[int(k)] = v loaded += 1 logger.info(f"AIS cache loaded: {loaded} vessels from disk") - except Exception as e: + except (IOError, OSError, json.JSONDecodeError, ValueError) as e: logger.error(f"Failed to load AIS cache: {e}") @@ -326,7 +326,7 @@ def _ais_stream_loop(): _save_cache() last_log_time = now - except Exception as e: + except (ConnectionError, TimeoutError, OSError, ValueError, KeyError) as e: logger.error(f"AIS proxy connection error: {e}") if _ws_running: logger.info(f"Restarting AIS proxy in {backoff}s (exponential backoff)...") diff --git a/backend/services/carrier_tracker.py b/backend/services/carrier_tracker.py index 83f8599..4049990 100644 --- a/backend/services/carrier_tracker.py +++ b/backend/services/carrier_tracker.py @@ -218,7 +218,7 @@ def _load_cache() -> Dict[str, dict]: data = json.loads(CACHE_FILE.read_text()) logger.info(f"Carrier cache loaded: {len(data)} carriers from {CACHE_FILE}") return data - except Exception as e: + except (IOError, OSError, json.JSONDecodeError, ValueError) as e: logger.warning(f"Failed to load carrier cache: {e}") return {} @@ -228,7 +228,7 @@ def _save_cache(positions: Dict[str, dict]): try: CACHE_FILE.write_text(json.dumps(positions, indent=2)) logger.info(f"Carrier cache saved: {len(positions)} carriers") - except Exception as e: + except (IOError, OSError) as e: logger.warning(f"Failed to save carrier cache: {e}") @@ -275,15 +275,15 @@ def _fetch_gdelt_carrier_news() -> List[dict]: try: url = f"https://api.gdeltproject.org/api/v2/doc/doc?query={term}&mode=artlist&maxrecords=5&format=json×pan=14d" raw = fetch_with_curl(url, timeout=8) - if not raw: + if not raw or not hasattr(raw, 'text'): continue - data = json.loads(raw) + data = raw.json() articles = data.get("articles", []) for art in articles: title = art.get("title", "") url = art.get("url", "") results.append({"title": title, "url": url}) - except Exception as e: + except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e: logger.debug(f"GDELT search failed for '{term}': {e}") continue @@ -323,13 +323,8 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]: return updates -def update_carrier_positions(): - """Main update function — called on startup and every 12h.""" - global _last_update - - logger.info("Carrier tracker: updating positions from OSINT sources...") - - # Start with fallback positions (sourced from USNI News Fleet Tracker) +def _load_carrier_fallbacks() -> Dict[str, dict]: + """Build carrier positions from static fallbacks + disk cache (instant, no network).""" positions: Dict[str, dict] = {} for hull, info in CARRIER_REGISTRY.items(): positions[hull] = { @@ -344,11 +339,10 @@ def update_carrier_positions(): "updated": datetime.now(timezone.utc).isoformat() } - # Load cached positions (may have better data from previous runs) + # Overlay cached positions from previous runs (may have GDELT data) cached = _load_cache() for hull, cached_pos in cached.items(): if hull in positions: - # Only use cache if it has a real OSINT source (not just static) if cached_pos.get("source", "").startswith("GDELT") or cached_pos.get("source", "").startswith("News"): positions[hull].update({ "lat": cached_pos["lat"], @@ -357,8 +351,29 @@ def update_carrier_positions(): "source": cached_pos.get("source", "Cached OSINT"), "updated": cached_pos.get("updated", "") }) + return positions - # Try GDELT news for fresh positions + +def update_carrier_positions(): + """Main update function — called on startup and every 12h. + + Phase 1 (instant): publish fallback + cached positions so the map has carriers immediately. + Phase 2 (slow): query GDELT for fresh OSINT positions and update in-place. + """ + global _last_update + + # --- Phase 1: instant fallback + cache --- + positions = _load_carrier_fallbacks() + + with _positions_lock: + # Only overwrite if positions are currently empty (first startup). + # If we already have data from a previous cycle, keep it while GDELT runs. + if not _carrier_positions: + _carrier_positions.update(positions) + _last_update = datetime.now(timezone.utc) + logger.info(f"Carrier tracker: {len(positions)} carriers loaded from fallback/cache (GDELT enrichment starting...)") + + # --- Phase 2: slow GDELT enrichment --- try: articles = _fetch_gdelt_carrier_news() news_positions = _parse_carrier_positions_from_news(articles) @@ -369,7 +384,7 @@ def update_carrier_positions(): except Exception as e: logger.warning(f"GDELT carrier fetch failed: {e}") - # Save and update the global state + # Save and update the global state with enriched positions with _positions_lock: _carrier_positions.clear() _carrier_positions.update(positions) diff --git a/backend/services/cctv_pipeline.py b/backend/services/cctv_pipeline.py index 86f7126..37efcc2 100644 --- a/backend/services/cctv_pipeline.py +++ b/backend/services/cctv_pipeline.py @@ -41,7 +41,7 @@ class BaseCCTVIngestor(ABC): cursor = self.conn.cursor() for cam in cameras: cursor.execute(""" - INSERT INTO cameras + INSERT INTO cameras (id, source_agency, lat, lon, direction_facing, media_url, refresh_rate_seconds) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -59,6 +59,10 @@ class BaseCCTVIngestor(ABC): self.conn.commit() logger.info(f"Successfully ingested {len(cameras)} cameras from {self.__class__.__name__}") except Exception as e: + try: + self.conn.rollback() + except Exception: + pass logger.error(f"Failed to ingest cameras in {self.__class__.__name__}: {e}") class TFLJamCamIngestor(BaseCCTVIngestor): @@ -220,7 +224,7 @@ class GlobalOSMCrawlingIngestor(BaseCCTVIngestor): direction_str = item.get("tags", {}).get("camera:direction", "0") try: bearing = int(float(direction_str)) - except: + except (ValueError, TypeError): bearing = 0 mapbox_key = "YOUR_MAPBOX_TOKEN_HERE" diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 5033104..aa347d7 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -1,604 +1,51 @@ +"""Data fetcher orchestrator — schedules and coordinates all data source modules. + +Heavy logic has been extracted into services/fetchers/: + - _store.py — shared state (latest_data, locks, timestamps) + - plane_alert.py — aircraft enrichment DB + - flights.py — commercial flights, routes, trails, GPS jamming + - military.py — military flights, UAV detection + - satellites.py — satellite tracking (SGP4) + - news.py — RSS news fetching, clustering, risk assessment +""" import yfinance as yf -import feedparser -import requests -import logging -from services.network_utils import fetch_with_curl import csv -import os -import re -import random -import math +import io import json import time -from pathlib import Path -import threading -import io -from apscheduler.schedulers.background import BackgroundScheduler -import concurrent.futures +import math +import logging import heapq -from sgp4.api import Satrec, WGS72 -from sgp4.api import jday +import concurrent.futures +from pathlib import Path from datetime import datetime +from cachetools import TTLCache +from apscheduler.schedulers.background import BackgroundScheduler from dotenv import load_dotenv load_dotenv() -from services.cctv_pipeline import init_db, TFLJamCamIngestor, LTASingaporeIngestor, AustinTXIngestor, NYCDOTIngestor, get_all_cameras + +from services.network_utils import fetch_with_curl +from services.cctv_pipeline import ( + init_db, TFLJamCamIngestor, LTASingaporeIngestor, + AustinTXIngestor, NYCDOTIngestor, get_all_cameras, +) + +# Shared state — all fetcher modules read/write through this +from services.fetchers._store import ( + latest_data, source_timestamps, _mark_fresh, _data_lock, # noqa: F401 — source_timestamps re-exported for main.py +) + +# Domain-specific fetcher modules +from services.fetchers.flights import fetch_flights +from services.fetchers.military import fetch_military_flights +from services.fetchers.satellites import fetch_satellites +from services.fetchers.news import fetch_news logger = logging.getLogger(__name__) -def _gmst(jd_ut1): - """Greenwich Mean Sidereal Time in radians from Julian Date.""" - t = (jd_ut1 - 2451545.0) / 36525.0 - gmst_sec = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t - gmst_rad = (gmst_sec % 86400) / 86400.0 * 2 * math.pi - return gmst_rad - -# Pre-compiled regex patterns for airline code extraction (used in hot loop) -_RE_AIRLINE_CODE_1 = re.compile(r'^([A-Z]{3})\d') -_RE_AIRLINE_CODE_2 = re.compile(r'^([A-Z]{3})[A-Z\d]') - - # --------------------------------------------------------------------------- -# OpenSky Network API Client (OAuth2) +# Financial data # --------------------------------------------------------------------------- -class OpenSkyClient: - def __init__(self, client_id, client_secret): - self.client_id = client_id - self.client_secret = client_secret - self.token = None - self.expires_at = 0 - - def get_token(self): - import time - if self.token and time.time() < self.expires_at - 60: - return self.token - - url = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" - data = { - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret - } - try: - r = requests.post(url, data=data, timeout=10) - if r.status_code == 200: - res = r.json() - self.token = res.get("access_token") - self.expires_at = time.time() + res.get("expires_in", 1800) - logger.info("OpenSky OAuth2 token refreshed.") - return self.token - else: - logger.error(f"OpenSky Auth Failed: {r.status_code} {r.text}") - except Exception as e: - logger.error(f"OpenSky Auth Exception: {e}") - return None - -# User provided credentials -opensky_client = OpenSkyClient( - client_id=os.environ.get("OPENSKY_CLIENT_ID", ""), - client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "") -) - -# Throttling and caching for OpenSky to observe the 400 req/day limit -last_opensky_fetch = 0 -cached_opensky_flights = [] - -# --------------------------------------------------------------------------- -# Supplemental ADS-B sources for blind-spot gap-filling (Russia/China/Africa) -# These aggregators have different feeder pools than adsb.lol and can surface -# aircraft invisible to our primary source. Only gap-fill planes are kept. -# --------------------------------------------------------------------------- -_BLIND_SPOT_REGIONS = [ - {"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250}, - {"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250}, - {"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250}, - {"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250}, - {"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250}, - {"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250}, - {"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250}, - {"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250}, -] -_SUPPLEMENTAL_FETCH_INTERVAL = 120 # seconds — only query every 2 min -last_supplemental_fetch = 0 -cached_supplemental_flights = [] - - - -# In-memory store -latest_data = { - "last_updated": None, - "news": [], - "stocks": {}, - "oil": {}, - "flights": [], - "ships": [], - "military_flights": [], - "tracked_flights": [], - "cctv": [], - "weather": None, - # bikeshare removed per user request - "traffic": [], - "earthquakes": [], - "uavs": [], - "frontlines": None, - "gdelt": [], - "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 -_data_lock = threading.Lock() - -# --------------------------------------------------------------------------- -# Plane-Alert DB — load tracked aircraft from JSON on startup -# --------------------------------------------------------------------------- - -# Exact category → color mapping for all 53 known categories. -# O(1) dict lookup — no keyword scanning, no false positives. -_CATEGORY_COLOR: dict[str, str] = { - # YELLOW — Military / Intelligence / Defense - "USAF": "yellow", - "Other Air Forces": "yellow", - "Toy Soldiers": "yellow", - "Oxcart": "yellow", - "United States Navy": "yellow", - "GAF": "yellow", - "Hired Gun": "yellow", - "United States Marine Corps": "yellow", - "Gunship": "yellow", - "RAF": "yellow", - "Other Navies": "yellow", - "Special Forces": "yellow", - "Zoomies": "yellow", - "Royal Navy Fleet Air Arm": "yellow", - "Army Air Corps": "yellow", - "Aerobatic Teams": "yellow", - "UAV": "yellow", - "Ukraine": "yellow", - "Nuclear": "yellow", - # LIME — Emergency / Medical / Rescue / Fire - "Flying Doctors": "#32cd32", - "Aerial Firefighter": "#32cd32", - "Coastguard": "#32cd32", - # BLUE — Government / Law Enforcement / Civil - "Police Forces": "blue", - "Governments": "blue", - "Quango": "blue", - "UK National Police Air Service": "blue", - "CAP": "blue", - # BLACK — Privacy / PIA - "PIA": "black", - # RED — Dictator / Oligarch - "Dictator Alert": "red", - "Da Comrade": "red", - "Oligarch": "red", - # HOT PINK — High Value Assets / VIP / Celebrity - "Head of State": "#ff1493", - "Royal Aircraft": "#ff1493", - "Don't you know who I am?": "#ff1493", - "As Seen on TV": "#ff1493", - "Bizjets": "#ff1493", - "Vanity Plate": "#ff1493", - "Football": "#ff1493", - # ORANGE — Joe Cool - "Joe Cool": "orange", - # WHITE — Climate Crisis - "Climate Crisis": "white", - # PURPLE — General Tracked / Other Notable - "Historic": "purple", - "Jump Johnny Jump": "purple", - "Ptolemy would be proud": "purple", - "Distinctive": "purple", - "Dogs with Jobs": "purple", - "You came here in that thing?": "purple", - "Big Hello": "purple", - "Watch Me Fly": "purple", - "Perfectly Serviceable Aircraft": "purple", - "Jesus he Knows me": "purple", - "Gas Bags": "purple", - "Radiohead": "purple", -} - -def _category_to_color(cat: str) -> str: - """O(1) exact lookup. Unknown categories default to purple.""" - return _CATEGORY_COLOR.get(cat, "purple") - -_PLANE_ALERT_DB: dict = {} - -# --------------------------------------------------------------------------- -# POTUS Fleet — override colors and operator names for presidential aircraft. -# These are hardcoded ICAO hexes verified against FAA registry + plane-alert. -# --------------------------------------------------------------------------- -_POTUS_FLEET: dict[str, dict] = { - # Air Force One — Boeing VC-25A (747-200B) - "ADFDF8": {"color": "#ff1493", "operator": "Air Force One (82-8000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"}, - "ADFDF9": {"color": "#ff1493", "operator": "Air Force One (92-9000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"}, - # Air Force Two — Boeing C-32A (757-200) - "ADFEB7": {"color": "blue", "operator": "Air Force Two (98-0001)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "ADFEB8": {"color": "blue", "operator": "Air Force Two (98-0002)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "ADFEB9": {"color": "blue", "operator": "Air Force Two (99-0003)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "ADFEBA": {"color": "blue", "operator": "Air Force Two (99-0004)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "AE4AE6": {"color": "blue", "operator": "Air Force Two (09-0015)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "AE4AE8": {"color": "blue", "operator": "Air Force Two (09-0016)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "AE4AEA": {"color": "blue", "operator": "Air Force Two (09-0017)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - "AE4AEC": {"color": "blue", "operator": "Air Force Two (19-0018)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, - # Marine One — VH-3D Sea King / VH-92A Patriot - "AE0865": {"color": "#ff1493", "operator": "Marine One (VH-3D)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, - "AE5E76": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, - "AE5E77": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, - "AE5E79": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, -} - -def _load_plane_alert_db(): - """Load plane_alert_db.json (exported from SQLite) into memory.""" - global _PLANE_ALERT_DB - json_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", "plane_alert_db.json" - ) - if not os.path.exists(json_path): - logger.warning(f"Plane-Alert DB not found at {json_path}") - return - try: - with open(json_path, "r", encoding="utf-8") as fh: - raw = json.load(fh) - for icao_hex, info in raw.items(): - info["color"] = _category_to_color(info.get("category", "")) - # Apply POTUS fleet overrides (correct colors + clean operator names) - override = _POTUS_FLEET.get(icao_hex) - if override: - info["color"] = override["color"] - info["operator"] = override["operator"] - info["category"] = override["category"] - info["wiki"] = override.get("wiki", "") - info["potus_fleet"] = override.get("fleet", "") - _PLANE_ALERT_DB[icao_hex] = info - logger.info(f"Plane-Alert DB loaded: {len(_PLANE_ALERT_DB)} aircraft") - except Exception as e: - logger.error(f"Failed to load Plane-Alert DB: {e}") - -_load_plane_alert_db() - -def enrich_with_plane_alert(flight: dict) -> dict: - """If flight's icao24 is in the Plane-Alert DB, add alert metadata.""" - icao = flight.get("icao24", "").strip().upper() - if icao and icao in _PLANE_ALERT_DB: - info = _PLANE_ALERT_DB[icao] - flight["alert_category"] = info["category"] - flight["alert_color"] = info["color"] - flight["alert_operator"] = info["operator"] - flight["alert_type"] = info["ac_type"] - flight["alert_tags"] = info["tags"] - flight["alert_link"] = info["link"] - if info.get("wiki"): - flight["alert_wiki"] = info["wiki"] - if info.get("potus_fleet"): - flight["potus_fleet"] = info["potus_fleet"] - if info["registration"]: - flight["registration"] = info["registration"] - - return flight - -# (json imported at module top) -_TRACKED_NAMES_DB: dict = {} # Map from uppercase registration to {name, category} - -def _load_tracked_names(): - global _TRACKED_NAMES_DB - json_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "data", "tracked_names.json" - ) - if not os.path.exists(json_path): - return - try: - with open(json_path, "r", encoding="utf-8") as f: - data = json.load(f) - # data has: - # "names": [ {"name": "...", "category": "..."} ] - # "details": { "Name": { "category": "...", "registrations": ["..."] } } - for name, info in data.get("details", {}).items(): - cat = info.get("category", "Other") - for reg in info.get("registrations", []): - reg_clean = reg.strip().upper() - if reg_clean: - _TRACKED_NAMES_DB[reg_clean] = {"name": name, "category": cat} - logger.info(f"Tracked Names DB loaded: {len(_TRACKED_NAMES_DB)} registrations") - except Exception as e: - logger.error(f"Failed to load Tracked Names DB: {e}") - -_load_tracked_names() - -def enrich_with_tracked_names(flight: dict) -> dict: - """If flight's registration matches our Excel extraction, tag it as tracked.""" - # POTUS fleet overrides are authoritative — never let Excel overwrite them - icao = flight.get("icao24", "").strip().upper() - if icao in _POTUS_FLEET: - return flight - - reg = flight.get("registration", "").strip().upper() - callsign = flight.get("callsign", "").strip().upper() - - match = None - if reg and reg in _TRACKED_NAMES_DB: - match = _TRACKED_NAMES_DB[reg] - elif callsign and callsign in _TRACKED_NAMES_DB: - match = _TRACKED_NAMES_DB[callsign] - - if match: - name = match["name"] - # Let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC). - flight["alert_operator"] = name - flight["alert_category"] = match["category"] - - # Override pink default if the name implies a specific function - name_lower = name.lower() - is_gov = any(w in name_lower for w in ['state of ', 'government', 'republic', 'ministry', 'department', 'federal', 'cia']) - is_law = any(w in name_lower for w in ['police', 'marshal', 'sheriff', 'douane', 'customs', 'patrol', 'gendarmerie', 'guardia', 'law enforcement']) - is_med = any(w in name_lower for w in ['fire', 'bomberos', 'ambulance', 'paramedic', 'medevac', 'rescue', 'hospital', 'medical', 'lifeflight']) - - if is_gov or is_law: - flight["alert_color"] = "blue" - elif is_med: - flight["alert_color"] = "#32cd32" # lime - elif "alert_color" not in flight: - flight["alert_color"] = "pink" - - return flight - - -def generate_machine_assessment(title, description, risk_score): - if risk_score < 8: - return None - - import random - keywords = [word.lower() for word in title.split() + description.split()] - - assessment = "ANALYSIS: " - if any(k in keywords for k in ["strike", "missile", "attack", "bomb", "drone"]): - assessment += f"{random.randint(75, 95)}% probability of kinetic escalation within 24 hours. Recommend immediate asset relocation from projected blast radius." - elif any(k in keywords for k in ["sanction", "trade", "economy", "tariff", "boycott"]): - assessment += f"Significant economic severing detected. {random.randint(60, 85)}% chance of reciprocal sanctions. Global supply chains may experience cascading latency." - elif any(k in keywords for k in ["cyber", "hack", "breach", "ddos", "ransomware"]): - assessment += f"Asymmetric digital warfare signature matched. {random.randint(80, 99)}% probability of infrastructure probing. Initiate air-gapping protocol for critical nodes." - elif any(k in keywords for k in ["troop", "deploy", "border", "navy", "carrier"]): - assessment += f"Force projection detected. {random.randint(70, 90)}% probability of theater escalation. Monitor adjacent maritime and airspace for mobilization." - else: - assessment += f"Anomalous geopolitical shift detected. Confidence interval {random.randint(60, 90)}%. Awaiting further signals intelligence for definitive vector." - - return assessment - -# --------------------------------------------------------------------------- -# Keyword → coordinate mapping for geocoding news articles -# --------------------------------------------------------------------------- -_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), - "tokyo": (35.676, 139.650), - "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), - "kenya": (-1.292, 36.821), - "libya": (26.335, 17.228), - "mali": (17.570, -3.996), - "niger": (17.607, 8.081), - "somalia": (5.152, 46.199), - "ethiopia": (9.145, 40.489), - "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), - "bangkok": (13.756, 100.501), - "jakarta": (-6.208, 106.845), -} - -def fetch_news(): - from services.news_feed_config import get_feeds - feed_config = get_feeds() - feeds = {f["name"]: f["url"] for f in feed_config} - source_weights = {f["name"]: f["weight"] for f in feed_config} - - clusters = {} - _cluster_grid = {} # spatial hash grid: (cell_x, cell_y) → [cluster_keys] - - # Fetch all feeds in parallel for speed (each has a 10s timeout) - def _fetch_feed(item): - source_name, url = item - try: - xml_data = fetch_with_curl(url, timeout=10).text - return source_name, feedparser.parse(xml_data) - except Exception as e: - logger.warning(f"Feed {source_name} failed: {e}") - return source_name, None - - with concurrent.futures.ThreadPoolExecutor(max_workers=len(feeds)) as pool: - feed_results = list(pool.map(_fetch_feed, feeds.items())) - - for source_name, feed in feed_results: - if not feed: - continue - for entry in feed.entries[:5]: - title = entry.get('title', '') - summary = entry.get('summary', '') - - # Filter out Earthquakes/seismic events (redundant with dedicated EQ layer) - _seismic_kw = ["earthquake", "seismic", "quake", "tremor", "magnitude", "richter"] - _text_lower = (title + " " + summary).lower() - if any(kw in _text_lower for kw in _seismic_kw): - continue - - # GDACS-specific risk score mapping - if source_name == "GDACS": - alert_level = entry.get("gdacs_alertlevel", "Green") - if alert_level == "Red": risk_score = 10 - elif alert_level == "Orange": risk_score = 7 - else: risk_score = 4 - else: - risk_keywords = ['war', 'missile', 'strike', 'attack', 'crisis', 'tension', 'military', 'conflict', 'defense', 'clash', 'nuclear'] - text = (title + " " + summary).lower() - - risk_score = 1 - for kw in risk_keywords: - if kw in text: - risk_score += 2 - - risk_score = min(10, risk_score) - - - keyword_coords = _KEYWORD_COORDS - - lat, lng = None, None - - # Try GeoRSS Extraction first (common in GDACS) - if 'georss_point' in entry: - geo_parts = entry['georss_point'].split() - if len(geo_parts) == 2: - lat, lng = float(geo_parts[0]), float(geo_parts[1]) - elif 'where' in entry and hasattr(entry['where'], 'coordinates'): - # Some feeds use the 'where' attribute - coords = entry['where'].coordinates - lat, lng = coords[1], coords[0] # Usually lon, lat in GeoJSON style points - - # Fallback to Keyword Mapping - if lat is None: - padded_text = f" {text} " - for kw, coords in keyword_coords.items(): - if kw.startswith(" ") or kw.endswith(" "): - if kw in padded_text: - lat, lng = coords - break - else: - if re.search(r'\b' + re.escape(kw) + r'\b', text): - lat, lng = coords - break - - # If mapped, check if there is an existing cluster within ~400km (4 degrees) to merge them - # Uses spatial hash grid (4° cells) for O(1) lookup instead of O(n) scan - if lat is not None: - key = None - cell_x, cell_y = int(lng // 4), int(lat // 4) - for dx in range(-1, 2): - for dy in range(-1, 2): - for ckey in _cluster_grid.get((cell_x + dx, cell_y + dy), []): - parts = ckey.split(",") - elat, elng = float(parts[0]), float(parts[1]) - if ((lat - elat)**2 + (lng - elng)**2)**0.5 < 4.0: - key = ckey - break - if key: - break - if key: - break - if key is None: - key = f"{lat},{lng}" - _cluster_grid.setdefault((cell_x, cell_y), []).append(key) - else: - key = title - - if key not in clusters: - clusters[key] = [] - - clusters[key].append({ - "title": title, - "link": entry.get('link', ''), - "published": entry.get('published', ''), - "source": source_name, - "risk_score": risk_score, - "coords": [lat, lng] if lat is not None else None - }) - - - news_items = [] - for key, articles in clusters.items(): - # Sort internal articles primarily by risk score (highest first), then by source hierarchy - articles.sort(key=lambda x: (x['risk_score'], source_weights.get(x["source"], 0)), reverse=True) - max_risk = articles[0]['risk_score'] - - top_article = articles[0] - news_items.append({ - "title": top_article["title"], - "link": top_article["link"], - "published": top_article["published"], - "source": top_article["source"], - "risk_score": max_risk, - "coords": top_article["coords"], - "cluster_count": len(articles), - "articles": articles, - "machine_assessment": generate_machine_assessment(top_article["title"], "", max_risk) - }) - - news_items.sort(key=lambda x: x['risk_score'], reverse=True) - latest_data['news'] = news_items - _mark_fresh("news") - def _fetch_single_ticker(symbol: str, period: str = "2d"): """Fetch a single yfinance ticker. Returns (symbol, data_dict) or (symbol, None).""" try: @@ -617,873 +64,33 @@ def _fetch_single_ticker(symbol: str, period: str = "2d"): logger.warning(f"Could not fetch data for {symbol}: {e}") return symbol, None - def fetch_defense_stocks(): tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"] try: with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: results = pool.map(lambda t: _fetch_single_ticker(t, "2d"), tickers) stocks_data = {sym: data for sym, data in results if data} - latest_data['stocks'] = stocks_data + with _data_lock: + latest_data['stocks'] = stocks_data _mark_fresh("stocks") except Exception as e: logger.error(f"Error fetching stocks: {e}") def fetch_oil_prices(): - # CL=F is Crude Oil, BZ=F is Brent Crude tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"} try: with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: results = pool.map(lambda item: (_fetch_single_ticker(item[1], "5d")[1], item[0]), tickers.items()) oil_data = {name: data for data, name in results if data} - latest_data['oil'] = oil_data + with _data_lock: + latest_data['oil'] = oil_data _mark_fresh("oil") except Exception as e: logger.error(f"Error fetching oil: {e}") -dynamic_routes_cache = {} # callsign -> {data..., _ts: timestamp} -routes_fetch_in_progress = False -ROUTES_CACHE_TTL = 7200 # 2 hours -ROUTES_CACHE_MAX = 5000 - -def fetch_routes_background(sampled): - global dynamic_routes_cache, routes_fetch_in_progress - if routes_fetch_in_progress: - return - routes_fetch_in_progress = True - - try: - # Prune stale entries (older than 2 hours) and cap at max size - now_ts = time.time() - stale_keys = [k for k, v in dynamic_routes_cache.items() if now_ts - v.get('_ts', 0) > ROUTES_CACHE_TTL] - for k in stale_keys: - del dynamic_routes_cache[k] - if len(dynamic_routes_cache) > ROUTES_CACHE_MAX: - # Remove oldest entries - sorted_keys = sorted(dynamic_routes_cache, key=lambda k: dynamic_routes_cache[k].get('_ts', 0)) - for k in sorted_keys[:len(dynamic_routes_cache) - ROUTES_CACHE_MAX]: - del dynamic_routes_cache[k] - - callsigns_to_query = [] - for f in sampled: - c_sign = str(f.get("flight", "")).strip() - if c_sign and c_sign != "UNKNOWN": - callsigns_to_query.append({ - "callsign": c_sign, - "lat": f.get("lat", 0), - "lng": f.get("lon", 0) - }) - - batch_size = 100 - batches = [callsigns_to_query[i:i+batch_size] for i in range(0, len(callsigns_to_query), batch_size)] - - for batch in batches: - try: - r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": batch}, timeout=15) - if r.status_code == 200: - route_data = r.json() - route_list = [] - if isinstance(route_data, dict): - route_list = route_data.get("value", []) - elif isinstance(route_data, list): - route_list = route_data - - for route in route_list: - callsign = route.get("callsign", "") - airports = route.get("_airports", []) - if airports and len(airports) >= 2: - orig_apt = airports[0] - dest_apt = airports[-1] - dynamic_routes_cache[callsign] = { - "orig_name": f"{orig_apt.get('iata', '')}: {orig_apt.get('name', 'Unknown')}", - "dest_name": f"{dest_apt.get('iata', '')}: {dest_apt.get('name', 'Unknown')}", - "orig_loc": [orig_apt.get("lon", 0), orig_apt.get("lat", 0)], - "dest_loc": [dest_apt.get("lon", 0), dest_apt.get("lat", 0)], - "_ts": time.time(), - } - time.sleep(0.25) # Throttle strictly beneath 10 requests / second limit - except Exception: - pass - finally: - routes_fetch_in_progress = False - -# Helicopter type codes (backend classification) -_HELI_TYPES_BACKEND = { - "R22", "R44", "R66", "B06", "B06T", "B204", "B205", "B206", "B212", "B222", "B230", - "B407", "B412", "B427", "B429", "B430", "B505", "B525", - "AS32", "AS35", "AS50", "AS55", "AS65", - "EC20", "EC25", "EC30", "EC35", "EC45", "EC55", "EC75", - "H125", "H130", "H135", "H145", "H155", "H160", "H175", "H215", "H225", - "S55", "S58", "S61", "S64", "S70", "S76", "S92", - "A109", "A119", "A139", "A169", "A189", "AW09", - "MD52", "MD60", "MDHI", "MD90", "NOTR", - "B47G", "HUEY", "GAMA", "CABR", "EXE", -} - - -def _fetch_supplemental_sources(seen_hex: set) -> list: - """Fetch from airplanes.live and adsb.fi to fill blind-spot gaps. - - Only returns aircraft whose ICAO hex is NOT already in seen_hex. - Throttled to run every _SUPPLEMENTAL_FETCH_INTERVAL seconds. - Fully wrapped in try/except — returns [] on any failure. - """ - global last_supplemental_fetch, cached_supplemental_flights - - now = time.time() - if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL: - # Return cached results, but still filter against current seen_hex - return [f for f in cached_supplemental_flights - if f.get("hex", "").lower().strip() not in seen_hex] - - new_supplemental = [] - supplemental_hex = set() # track hex within supplemental to avoid internal dupes - - # --- airplanes.live (parallel, all hotspots) --- - def _fetch_airplaneslive(region): - try: - url = (f"https://api.airplanes.live/v2/point/" - f"{region['lat']}/{region['lon']}/{region['radius_nm']}") - res = fetch_with_curl(url, timeout=10) - if res.status_code == 200: - data = res.json() - return data.get("ac", []) - except Exception as e: - logger.debug(f"airplanes.live {region['name']} failed: {e}") - return [] - - try: - with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: - results = list(pool.map(_fetch_airplaneslive, _BLIND_SPOT_REGIONS)) - for region_flights in results: - for f in region_flights: - h = f.get("hex", "").lower().strip() - if h and h not in seen_hex and h not in supplemental_hex: - f["supplemental_source"] = "airplanes.live" - new_supplemental.append(f) - supplemental_hex.add(h) - except Exception as e: - logger.warning(f"airplanes.live supplemental fetch failed: {e}") - - ap_count = len(new_supplemental) - - # --- adsb.fi (sequential, 1.1s between requests to respect 1 req/sec limit) --- - try: - for region in _BLIND_SPOT_REGIONS: - try: - url = (f"https://opendata.adsb.fi/api/v3/lat/" - f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}") - res = fetch_with_curl(url, timeout=10) - if res.status_code == 200: - data = res.json() - for f in data.get("ac", []): - h = f.get("hex", "").lower().strip() - if h and h not in seen_hex and h not in supplemental_hex: - f["supplemental_source"] = "adsb.fi" - new_supplemental.append(f) - supplemental_hex.add(h) - except Exception as e: - logger.debug(f"adsb.fi {region['name']} failed: {e}") - time.sleep(1.1) # Rate limit: 1 req/sec - except Exception as e: - logger.warning(f"adsb.fi supplemental fetch failed: {e}") - - fi_count = len(new_supplemental) - ap_count - - cached_supplemental_flights = new_supplemental - last_supplemental_fetch = now - if new_supplemental: - _mark_fresh("supplemental_flights") - - logger.info(f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot " - f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})") - - return new_supplemental - - -def fetch_flights(): - # OpenSky Network public API for flights. We want to demonstrate global coverage. - flights = [] - try: - # Sample flights from North America, Europe, Asia - regions = [ - {"lat": 39.8, "lon": -98.5, "dist": 2000}, # USA - {"lat": 50.0, "lon": 15.0, "dist": 2000}, # Europe - {"lat": 35.0, "lon": 105.0, "dist": 2000}, # Asia / China - {"lat": -25.0, "lon": 133.0, "dist": 2000}, # Australia - {"lat": 0.0, "lon": 20.0, "dist": 2500}, # Africa - {"lat": -15.0, "lon": -60.0, "dist": 2000} # South America - ] - - all_adsb_flights = [] - - # Fetch all regions in parallel for ~5x speedup - def _fetch_region(r): - url = f"https://api.adsb.lol/v2/lat/{r['lat']}/lon/{r['lon']}/dist/{r['dist']}" - try: - res = fetch_with_curl(url, timeout=10) - if res.status_code == 200: - data = res.json() - return data.get("ac", []) - except Exception as e: - logger.warning(f"Region fetch failed for lat={r['lat']}: {e}") - return [] - - # Fetch all regions in parallel for maximum speed - with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: - results = pool.map(_fetch_region, regions) - for region_flights in results: - all_adsb_flights.extend(region_flights) - - # --------------------------------------------------------------------------- - # OpenSky Regional Fallback (Africa, Asia, South America) - # --------------------------------------------------------------------------- - now = time.time() - global last_opensky_fetch, cached_opensky_flights - - # OpenSky has a 400 req/day limit (~16 pings/hour) - # 5 minutes = 288 pings/day (Safe margin) - if now - last_opensky_fetch > 300: - token = opensky_client.get_token() - if token: - opensky_regions = [ - {"name": "Africa", "bbox": {"lamin": -35.0, "lomin": -20.0, "lamax": 38.0, "lomax": 55.0}}, - {"name": "Asia", "bbox": {"lamin": 0.0, "lomin": 30.0, "lamax": 75.0, "lomax": 150.0}}, - {"name": "South America", "bbox": {"lamin": -60.0, "lomin": -95.0, "lamax": 15.0, "lomax": -30.0}} - ] - - new_opensky_flights = [] - for os_reg in opensky_regions: - try: - bb = os_reg["bbox"] - os_url = f"https://opensky-network.org/api/states/all?lamin={bb['lamin']}&lomin={bb['lomin']}&lamax={bb['lamax']}&lomax={bb['lomax']}" - headers = {"Authorization": f"Bearer {token}"} - os_res = requests.get(os_url, headers=headers, timeout=15) - - if os_res.status_code == 200: - os_data = os_res.json() - states = os_data.get("states") or [] - logger.info(f"OpenSky: Fetched {len(states)} states for {os_reg['name']}") - - for s in states: - # OpenSky state vector mapping: - # 0icao, 1callsign, 2country, 3time, 4last, 5lon, 6lat, 7baro, 8ground, 9vel, 10track, 11vert, 12sens, 13geo, 14sqk - new_opensky_flights.append({ - "hex": s[0], - "flight": s[1].strip() if s[1] else "UNKNOWN", - "r": s[2], - "lon": s[5], - "lat": s[6], - "alt_baro": (s[7] * 3.28084) if s[7] else 0, # Meters to Feet for internal consistency - "track": s[10] or 0, - "gs": (s[9] * 1.94384) if s[9] else 0, # m/s to knots - "t": "Unknown", # Model unknown in states API - "is_opensky": True - }) - else: - logger.warning(f"OpenSky API {os_reg['name']} failed: {os_res.status_code}") - except Exception as ex: - logger.error(f"OpenSky fetching error for {os_reg['name']}: {ex}") - - cached_opensky_flights = new_opensky_flights - last_opensky_fetch = now - - # Merge cached OpenSky flights, but deduplicate by icao24 hex code - # ADS-B Exchange is primary; OpenSky only fills gaps - seen_hex = set() - for f in all_adsb_flights: - h = f.get("hex") - if h: - seen_hex.add(h.lower().strip()) - for osf in cached_opensky_flights: - h = osf.get("hex") - if h and h.lower().strip() not in seen_hex: - all_adsb_flights.append(osf) - seen_hex.add(h.lower().strip()) - - # ------------------------------------------------------------------- - # Supplemental Sources: airplanes.live + adsb.fi (blind-spot gap-fill) - # Only adds aircraft whose ICAO hex is NOT already in seen_hex. - # ------------------------------------------------------------------- - try: - gap_fill = _fetch_supplemental_sources(seen_hex) - for f in gap_fill: - all_adsb_flights.append(f) - h = f.get("hex", "").lower().strip() - if h: - seen_hex.add(h) - if gap_fill: - logger.info(f"Gap-fill: added {len(gap_fill)} aircraft to pipeline") - except Exception as e: - logger.warning(f"Supplemental source fetch failed (non-fatal): {e}") - - if all_adsb_flights: - - # The user requested maximum flight density. Rendering all available aircraft. - sampled = all_adsb_flights - - # Spin up the background batch route resolver if it's not already trickling - if not routes_fetch_in_progress: - threading.Thread(target=fetch_routes_background, args=(sampled,), daemon=True).start() - - for f in sampled: - try: - lat = f.get("lat") - lng = f.get("lon") - heading = f.get("track") or 0 - - if lat is None or lng is None: - continue - - flight_str = str(f.get("flight", "UNKNOWN")).strip() - if not flight_str or flight_str == "UNKNOWN": - flight_str = str(f.get("hex", "Unknown")) - - # Origin and destination are fetched via the background thread and cached - origin_loc = None - dest_loc = None - origin_name = "UNKNOWN" - dest_name = "UNKNOWN" - - if flight_str in dynamic_routes_cache: - cached = dynamic_routes_cache[flight_str] - origin_name = cached["orig_name"] - dest_name = cached["dest_name"] - origin_loc = cached["orig_loc"] - dest_loc = cached["dest_loc"] - - # Extract 3-letter ICAO Airline Code from CallSign (e.g. UAL123 -> UAL) - airline_code = "" - match = _RE_AIRLINE_CODE_1.match(flight_str) - if not match: - match = _RE_AIRLINE_CODE_2.match(flight_str) - if match: - airline_code = match.group(1) - - 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 - - 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" - - flights.append({ - "callsign": flight_str, - "country": f.get("r", "N/A"), - "lng": float(lng), - "lat": float(lat), - "alt": alt_value, - "heading": heading, - "type": "flight", - "origin_loc": origin_loc, - "dest_loc": dest_loc, - "origin_name": origin_name, - "dest_name": dest_name, - "registration": f.get("r", "N/A"), - "model": f.get("t", "Unknown"), - "icao24": f.get("hex", ""), - "speed_knots": speed_knots, - "squawk": f.get("squawk", ""), - "airline_code": airline_code, - "aircraft_category": ac_category, - "nac_p": f.get("nac_p") # Navigation accuracy — used for GPS jamming detection - }) - except Exception as loop_e: - logger.error(f"Flight interpolation error: {loop_e}") - continue - - except Exception as e: - logger.error(f"Error fetching adsb.lol flights: {e}") - - # Private jet ICAO type designator codes (business jets wealthy individuals fly) - PRIVATE_JET_TYPES = { - # Gulfstream - "G150", "G200", "G280", "GLEX", "G500", "G550", "G600", "G650", "G700", - "GLF2", "GLF3", "GLF4", "GLF5", "GLF6", "GL5T", "GL7T", "GV", "GIV", - # Bombardier - "CL30", "CL35", "CL60", "BD70", "BD10", "GL5T", "GL7T", - "CRJ1", "CRJ2", # Challenger variants used privately - # Cessna Citation - "C25A", "C25B", "C25C", "C500", "C501", "C510", "C525", "C526", - "C550", "C560", "C56X", "C680", "C68A", "C700", "C750", - # Dassault Falcon - "FA10", "FA20", "FA50", "FA7X", "FA8X", "F900", "F2TH", "ASTR", - # Embraer Business Jets - "E35L", "E545", "E550", "E55P", "LEGA", # Praetor / Legacy - "PH10", # Phenom 100 - "PH30", # Phenom 300 - # Learjet - "LJ23", "LJ24", "LJ25", "LJ28", "LJ31", "LJ35", "LJ36", - "LJ40", "LJ45", "LJ55", "LJ60", "LJ70", "LJ75", - # Hawker / Beechcraft - "H25A", "H25B", "H25C", "HA4T", "BE40", "PRM1", - # Other business jets - "HDJT", # HondaJet - "PC24", # Pilatus PC-24 - "EA50", # Eclipse 500 - "SF50", # Cirrus Vision Jet - "GALX", # IAI Galaxy - } - - commercial = [] - private_jets = [] - private_ga = [] - tracked = [] - - - for f in flights: - # Enrich every flight with plane-alert data - enrich_with_plane_alert(f) - enrich_with_tracked_names(f) - - callsign = f.get('callsign', '').strip().upper() - # Heuristic: standard airline callsigns are 3 letters + 1 to 4 digits (e.g., AFR7403, BAW12) - is_commercial_format = bool(re.match(r'^[A-Z]{3}\d{1,4}[A-Z]{0,2}$', callsign)) - - if f.get('alert_category'): - # This is a tracked aircraft — pull it out into tracked list - f['type'] = 'tracked_flight' - tracked.append(f) - elif f.get('airline_code') or is_commercial_format: - f['type'] = 'commercial_flight' - commercial.append(f) - elif f.get('model', '').upper() in PRIVATE_JET_TYPES: - f['type'] = 'private_jet' - private_jets.append(f) - else: - f['type'] = 'private_ga' - private_ga.append(f) - - # --- Smart merge: protect against partial API failures --- - # If the new dataset has dramatically fewer flights than what we already have, - # a region fetch probably failed — keep the old data to prevent planes vanishing. - prev_commercial_count = len(latest_data.get('commercial_flights', [])) - prev_total = prev_commercial_count + len(latest_data.get('private_jets', [])) + len(latest_data.get('private_flights', [])) - new_total = len(commercial) + len(private_jets) + len(private_ga) - - if new_total == 0: - logger.warning("No civilian flights found! Skipping overwrite to prevent clearing the map.") - elif prev_total > 100 and new_total < prev_total * 0.5: - # Dramatic drop (>50% loss) — a region probably failed, keep existing data - logger.warning(f"Flight count dropped from {prev_total} to {new_total} (>50% loss). Keeping previous data to prevent flicker.") - else: - # Merge: deduplicate by icao24, prefer new data - import time as _time - _now = _time.time() - - def _merge_category(new_list, old_list, max_stale_s=120): - """Merge new flights with old, keeping stale entries for up to max_stale_s.""" - by_icao = {} - # Old entries first (will be overwritten by new) - for f in old_list: - icao = f.get('icao24', '') - if icao: - f.setdefault('_seen_at', _now) - # Evict if stale for too long - if (_now - f.get('_seen_at', _now)) < max_stale_s: - by_icao[icao] = f - # New entries overwrite old - for f in new_list: - icao = f.get('icao24', '') - if icao: - f['_seen_at'] = _now - by_icao[icao] = f - else: - by_icao[id(f)] = f # no icao — keep as unique - return list(by_icao.values()) - - with _data_lock: - latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', [])) - latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', [])) - latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', [])) - - _mark_fresh("commercial_flights", "private_jets", "private_flights") - - # Always write raw flights for GPS jamming analysis (nac_p field) - if flights: - latest_data['flights'] = flights - - # Merge tracked civilian flights with any tracked military flights - # CRITICAL: Update positions for already-tracked aircraft on every cycle, - # not just add new ones — otherwise tracked positions go stale. - existing_tracked = latest_data.get('tracked_flights', []) - - # Build a map of fresh tracked data keyed by icao24 - fresh_tracked_map = {} - for t in tracked: - icao = t.get('icao24', '').upper() - if icao: - fresh_tracked_map[icao] = t - - # Update existing tracked entries with fresh positions, preserve metadata - merged_tracked = [] - seen_icaos = set() - for old_t in existing_tracked: - icao = old_t.get('icao24', '').upper() - if icao in fresh_tracked_map: - # Fresh data available — use it, but preserve any extra metadata from old entry - fresh = fresh_tracked_map[icao] - for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'): - if key in old_t and key not in fresh: - fresh[key] = old_t[key] - merged_tracked.append(fresh) - seen_icaos.add(icao) - else: - # No fresh data (military-only tracked, or plane landed/out of range) - merged_tracked.append(old_t) - seen_icaos.add(icao) - - # Add any newly-discovered tracked aircraft - for icao, t in fresh_tracked_map.items(): - if icao not in seen_icaos: - merged_tracked.append(t) - - latest_data['tracked_flights'] = merged_tracked - logger.info(f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian)") - - # ----------------------------------------------------------------------- - # Flight Trail Accumulation — build position history for unrouted flights - # ----------------------------------------------------------------------- - def _accumulate_trail(f, now_ts, check_route=True): - """Accumulate trail points for a single flight. Returns 1 if trail updated, 0 otherwise.""" - hex_id = f.get('icao24', '').lower() - if not hex_id: - return 0, None - if check_route and f.get('origin_name', 'UNKNOWN') != 'UNKNOWN': - f['trail'] = [] - return 0, hex_id - lat, lng, alt = f.get('lat'), f.get('lng'), f.get('alt', 0) - if lat is None or lng is None: - f['trail'] = flight_trails.get(hex_id, {}).get('points', []) - return 0, hex_id - point = [round(lat, 5), round(lng, 5), round(alt, 1), round(now_ts)] - if hex_id not in flight_trails: - flight_trails[hex_id] = {'points': [], 'last_seen': now_ts} - trail_data = flight_trails[hex_id] - if trail_data['points'] and trail_data['points'][-1][0] == point[0] and trail_data['points'][-1][1] == point[1]: - trail_data['last_seen'] = now_ts - else: - trail_data['points'].append(point) - trail_data['last_seen'] = now_ts - if len(trail_data['points']) > 200: - trail_data['points'] = trail_data['points'][-200:] - f['trail'] = trail_data['points'] - return 1, hex_id - - now_ts = datetime.utcnow().timestamp() - all_lists = [commercial, private_jets, private_ga, existing_tracked] - seen_hexes = set() - trail_count = 0 - with _trails_lock: - for flist in all_lists: - for f in flist: - count, hex_id = _accumulate_trail(f, now_ts, check_route=True) - trail_count += count - if hex_id: - seen_hexes.add(hex_id) - - # Also process military flights (separate list) - for mf in latest_data.get('military_flights', []): - count, hex_id = _accumulate_trail(mf, now_ts, check_route=False) - trail_count += count - if hex_id: - seen_hexes.add(hex_id) - - # Prune stale trails (5 min for non-tracked, 30 min for tracked) - tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])} - stale_keys = [] - for k, v in flight_trails.items(): - cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 300 - if v['last_seen'] < cutoff: - stale_keys.append(k) - for k in stale_keys: - del flight_trails[k] - - # Enforce global cap — evict oldest trails first - if len(flight_trails) > _MAX_TRACKED_TRAILS: - sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen']) - evict_count = len(flight_trails) - _MAX_TRACKED_TRAILS - for k in sorted_keys[:evict_count]: - del flight_trails[k] - - logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total") - - # ----------------------------------------------------------------------- - # GPS / GNSS Jamming Detection — aggregate NACp from ADS-B transponders - # NACp (Navigation Accuracy Category for Position): - # 11 = full accuracy (<3m), 8 = good (<93m), <8 = degraded = potential jamming - # We use a 1°×1° grid (~111km at equator) to aggregate interference zones. - # ----------------------------------------------------------------------- - try: - jamming_grid = {} # "lat,lng" -> {"degraded": int, "total": int} - raw_flights = latest_data.get('flights', []) - for rf in raw_flights: - rlat = rf.get('lat') - rlng = rf.get('lng') or rf.get('lon') - if rlat is None or rlng is None: - continue - nacp = rf.get('nac_p') - if nacp is None: - continue - # Grid key: snap to 1-degree cells - grid_key = f"{int(rlat)},{int(rlng)}" - if grid_key not in jamming_grid: - jamming_grid[grid_key] = {"degraded": 0, "total": 0} - jamming_grid[grid_key]["total"] += 1 - if nacp < 8: - jamming_grid[grid_key]["degraded"] += 1 - - jamming_zones = [] - for gk, counts in jamming_grid.items(): - if counts["total"] < 3: - continue # Need at least 3 aircraft to be meaningful - ratio = counts["degraded"] / counts["total"] - if ratio > 0.25: # >25% degraded = jamming - lat_i, lng_i = gk.split(",") - severity = "low" if ratio < 0.5 else "medium" if ratio < 0.75 else "high" - jamming_zones.append({ - "lat": int(lat_i) + 0.5, # Center of cell - "lng": int(lng_i) + 0.5, - "severity": severity, - "ratio": round(ratio, 2), - "degraded": counts["degraded"], - "total": counts["total"] - }) - latest_data['gps_jamming'] = jamming_zones - if jamming_zones: - logger.info(f"GPS Jamming: {len(jamming_zones)} interference zones detected") - except Exception as e: - logger.error(f"GPS Jamming detection error: {e}") - latest_data['gps_jamming'] = [] - - # ----------------------------------------------------------------------- - # Holding Pattern Detection — flag aircraft circling in place - # If cumulative heading change over last 8 trail points > 300°, it's circling - # ----------------------------------------------------------------------- - try: - holding_count = 0 - all_flight_lists = [commercial, private_jets, private_ga, - latest_data.get('tracked_flights', []), - latest_data.get('military_flights', [])] - for flist in all_flight_lists: - for f in flist: - hex_id = f.get('icao24', '').lower() - trail = flight_trails.get(hex_id, {}).get('points', []) - if len(trail) < 6: - f['holding'] = False - continue - # Calculate cumulative bearing change over last 8 points - pts = trail[-8:] - total_turn = 0.0 - prev_bearing = 0.0 - for i in range(1, len(pts)): - lat1, lng1 = math.radians(pts[i-1][0]), math.radians(pts[i-1][1]) - lat2, lng2 = math.radians(pts[i][0]), math.radians(pts[i][1]) - dlng = lng2 - lng1 - x = math.sin(dlng) * math.cos(lat2) - y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlng) - bearing = math.degrees(math.atan2(x, y)) % 360 - if i > 1: - delta = abs(bearing - prev_bearing) - if delta > 180: - delta = 360 - delta - total_turn += delta - prev_bearing = bearing - f['holding'] = total_turn > 300 # > 300° = nearly a full circle - if f['holding']: - holding_count += 1 - if holding_count: - logger.info(f"Holding patterns: {holding_count} aircraft circling") - except Exception as e: - logger.error(f"Holding pattern detection error: {e}") - - # Update timestamp so the ETag in /api/live-data/fast changes on every fetch cycle - latest_data['last_updated'] = datetime.utcnow().isoformat() - -def fetch_ships(): - """Fetch real-time AIS vessel data and combine with OSINT carrier positions.""" - from services.ais_stream import get_ais_vessels - from services.carrier_tracker import get_carrier_positions - - ships = [] - - # Dynamic OSINT carrier positions (updated from GDELT + cache) - try: - carriers = get_carrier_positions() - ships.extend(carriers) - except Exception as e: - logger.error(f"Carrier tracker error (non-fatal): {e}") - carriers = [] - - # Real AIS vessel data from aisstream.io - try: - ais_vessels = get_ais_vessels() - ships.extend(ais_vessels) - except Exception as e: - logger.error(f"AIS stream error (non-fatal): {e}") - ais_vessels = [] - - logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels") - latest_data['ships'] = ships - _mark_fresh("ships") - -def fetch_military_flights(): - # True ADS-B Exchange military data requires paid API access. - # We will use adsb.lol (an open source ADSB aggregator) /v2/mil fallback. - military_flights = [] - detected_uavs = [] - try: - url = "https://api.adsb.lol/v2/mil" - response = fetch_with_curl(url, timeout=10) - if response.status_code == 200: - ac = response.json().get('ac', []) - for f in ac: - try: - lat = f.get("lat") - lng = f.get("lon") - heading = f.get("track") or 0 - - if lat is None or lng is None: - continue - - 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" - if "H" in model and any(c.isdigit() for c in model): - mil_cat = "heli" - elif any(k in model for k in ["K35", "K46", "A33"]): - mil_cat = "tanker" - elif any(k in model for k in ["F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10"]): - mil_cat = "fighter" - elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]): - mil_cat = "cargo" - elif any(k in model for k in ["P8", "E3", "E8", "U2"]): - mil_cat = "recon" - - military_flights.append({ - "callsign": callsign, - "country": f.get("r", "Military Asset"), - "lng": float(lng), - "lat": float(lat), - "alt": alt_value, - "heading": heading, - "type": "military_flight", - "military_type": mil_cat, - "origin_loc": None, - "dest_loc": None, - "origin_name": "UNKNOWN", - "dest_name": "UNKNOWN", - "registration": f.get("r", "N/A"), - "model": f.get("t", "Unknown"), - "icao24": f.get("hex", ""), - "speed_knots": speed_knots, - "squawk": f.get("squawk", "") - }) - except Exception as loop_e: - logger.error(f"Mil flight interpolation error: {loop_e}") - continue - except Exception as e: - logger.error(f"Error fetching military flights: {e}") - - if not military_flights and not detected_uavs: - # API failed or rate limited — log but do NOT inject fake data - logger.warning("No military flights retrieved — keeping previous data if available") - # Preserve existing data rather than overwriting with empty - if latest_data.get('military_flights'): - return - - 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 - tracked_mil = [] - remaining_mil = [] - for mf in military_flights: - enrich_with_plane_alert(mf) - if mf.get('alert_category'): - mf['type'] = 'tracked_flight' - tracked_mil.append(mf) - else: - remaining_mil.append(mf) - latest_data['military_flights'] = remaining_mil - - # Store tracked military flights — update positions for existing entries - existing_tracked = latest_data.get('tracked_flights', []) - fresh_mil_map = {} - for t in tracked_mil: - icao = t.get('icao24', '').upper() - if icao: - fresh_mil_map[icao] = t - - # Update existing military tracked entries with fresh positions - updated_tracked = [] - seen_icaos = set() - for old_t in existing_tracked: - icao = old_t.get('icao24', '').upper() - if icao in fresh_mil_map: - fresh = fresh_mil_map[icao] - for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'): - if key in old_t and key not in fresh: - fresh[key] = old_t[key] - updated_tracked.append(fresh) - seen_icaos.add(icao) - else: - updated_tracked.append(old_t) - seen_icaos.add(icao) - for icao, t in fresh_mil_map.items(): - if icao not in seen_icaos: - updated_tracked.append(t) - latest_data['tracked_flights'] = updated_tracked - logger.info(f"Tracked flights: {len(updated_tracked)} total ({len(tracked_mil)} from military)") - +# --------------------------------------------------------------------------- +# Weather +# --------------------------------------------------------------------------- def fetch_weather(): try: url = "https://api.rainviewer.com/public/weather-maps.json" @@ -1492,28 +99,44 @@ def fetch_weather(): data = response.json() if "radar" in data and "past" in data["radar"]: latest_time = data["radar"]["past"][-1]["time"] - latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")} + with _data_lock: + latest_data["weather"] = {"time": latest_time, "host": data.get("host", "https://tilecache.rainviewer.com")} _mark_fresh("weather") except Exception as e: logger.error(f"Error fetching weather: {e}") +# --------------------------------------------------------------------------- +# CCTV +# --------------------------------------------------------------------------- def fetch_cctv(): try: - latest_data["cctv"] = get_all_cameras() + cameras = get_all_cameras() + with _data_lock: + latest_data["cctv"] = cameras _mark_fresh("cctv") except Exception as e: logger.error(f"Error fetching cctv from DB: {e}") - latest_data["cctv"] = [] + with _data_lock: + latest_data["cctv"] = [] +# --------------------------------------------------------------------------- +# KiwiSDR +# --------------------------------------------------------------------------- def fetch_kiwisdr(): try: from services.kiwisdr_fetcher import fetch_kiwisdr_nodes - latest_data["kiwisdr"] = fetch_kiwisdr_nodes() + nodes = fetch_kiwisdr_nodes() + with _data_lock: + latest_data["kiwisdr"] = nodes _mark_fresh("kiwisdr") except Exception as e: logger.error(f"Error fetching KiwiSDR nodes: {e}") - latest_data["kiwisdr"] = [] + with _data_lock: + latest_data["kiwisdr"] = [] +# --------------------------------------------------------------------------- +# NASA FIRMS Fires +# --------------------------------------------------------------------------- def fetch_firms_fires(): """Fetch global fire/thermal anomalies from NASA FIRMS (NOAA-20 VIIRS, 24h, no key needed).""" fires = [] @@ -1521,39 +144,37 @@ def fetch_firms_fires(): 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) + frp = float(row.get("frp", 0)) 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, + "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 - # Keep top 5000 by FRP (most intense fires first) — heapq is O(n) vs O(n log n) sort fires = heapq.nlargest(5000, all_rows, key=lambda x: x["frp"]) 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 + with _data_lock: + latest_data["firms_fires"] = fires if fires: _mark_fresh("firms_fires") +# --------------------------------------------------------------------------- +# Space Weather +# --------------------------------------------------------------------------- def fetch_space_weather(): """Fetch NOAA SWPC Kp index and recent solar events.""" try: @@ -1586,18 +207,21 @@ def fetch_space_weather(): "classtype": ev.get("classtype", ""), }) - latest_data["space_weather"] = { - "kp_index": kp_value, - "kp_text": kp_text, - "events": events, - } + with _data_lock: + 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 = {} +# --------------------------------------------------------------------------- +# Internet Outages (IODA) +# --------------------------------------------------------------------------- +_region_geocode_cache: TTLCache = TTLCache(maxsize=2000, ttl=86400) def _geocode_region(region_name: str, country_name: str) -> tuple: """Geocode a region using OpenStreetMap Nominatim (cached, respects rate limit).""" @@ -1622,14 +246,7 @@ def _geocode_region(region_name: str, country_name: str) -> tuple: 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 + """Fetch regional internet outage alerts from IODA (Georgia Tech).""" RELIABLE_DATASOURCES = {"bgp", "ping-slash24"} outages = [] try: @@ -1640,7 +257,6 @@ def fetch_internet_outages(): 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", {}) @@ -1650,7 +266,7 @@ def fetch_internet_outages(): continue datasource = alert.get("datasource", "") if datasource not in RELIABLE_DATASOURCES: - continue # Skip merit-nt and other unreliable sources + continue code = entity.get("code", "") name = entity.get("name", "") attrs = entity.get("attrs", {}) @@ -1663,7 +279,7 @@ def fetch_internet_outages(): severity = round((1 - value / history_value) * 100) severity = max(0, min(severity, 100)) if severity < 10: - continue # Skip minor fluctuations (<10% is normal jitter) + continue if code not in region_outages or severity > region_outages[code]["severity"]: region_outages[code] = { "region_code": code, @@ -1674,7 +290,6 @@ def fetch_internet_outages(): "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"]) @@ -1682,18 +297,20 @@ def fetch_internet_outages(): r["lat"] = coords[0] r["lng"] = coords[1] geocoded.append(r) - # Keep top 100 by severity outages = heapq.nlargest(100, geocoded, key=lambda x: x["severity"]) 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 + with _data_lock: + latest_data["internet_outages"] = outages if outages: _mark_fresh("internet_outages") +# --------------------------------------------------------------------------- +# Data Centers +# --------------------------------------------------------------------------- _DC_GEOCODED_PATH = Path(__file__).parent.parent / "data" / "datacenters_geocoded.json" - def fetch_datacenters(): """Load geocoded data centers (5K+ street-level precise locations).""" dcs = [] @@ -1716,53 +333,19 @@ def fetch_datacenters(): "city": entry.get("city", ""), "country": entry.get("country", ""), "zip": entry.get("zip", ""), - "lat": lat, - "lng": lng, + "lat": lat, "lng": lng, }) logger.info(f"Data centers: {len(dcs)} geocoded locations loaded") except Exception as e: logger.error(f"Error loading data centers: {e}") - latest_data["datacenters"] = dcs + with _data_lock: + latest_data["datacenters"] = dcs if dcs: _mark_fresh("datacenters") -def fetch_bikeshare(): - bikes = [] - try: - # CitiBike NYC Free GBFS Feed - info_url = "https://gbfs.citibikenyc.com/gbfs/en/station_information.json" - status_url = "https://gbfs.citibikenyc.com/gbfs/en/station_status.json" - - info_res = fetch_with_curl(info_url, timeout=10) - status_res = fetch_with_curl(status_url, timeout=10) - - if info_res.status_code == 200 and status_res.status_code == 200: - stations = info_res.json()["data"]["stations"] - statuses = status_res.json()["data"]["stations"] - - # Map statuses - status_map = {s["station_id"]: s for s in statuses} - - # Top 100 stations for performance - for st in stations[:100]: - sid = st["station_id"] - stat = status_map.get(sid, {}) - bikes.append({ - "id": sid, - "name": st.get("name", "Station"), - "lat": st.get("lat", 0), - "lng": st.get("lon", 0), - "capacity": st.get("capacity", 0), - "available": stat.get("num_bikes_available", 0) - }) - except Exception as e: - logger.error(f"Error fetching bikeshare: {e}") - latest_data["bikeshare"] = bikes - -def fetch_traffic(): - # Deprecated: TomTom warning signs removed from UI to declutter CCTV mesh - latest_data["traffic"] = [] - +# --------------------------------------------------------------------------- +# Earthquakes +# --------------------------------------------------------------------------- def fetch_earthquakes(): quakes = [] try: @@ -1774,488 +357,77 @@ def fetch_earthquakes(): mag = f["properties"]["mag"] lng, lat, depth = f["geometry"]["coordinates"] quakes.append({ - "id": f["id"], - "mag": mag, - "lat": lat, - "lng": lng, + "id": f["id"], "mag": mag, + "lat": lat, "lng": lng, "place": f["properties"]["place"] }) except Exception as e: logger.error(f"Error fetching earthquakes: {e}") - latest_data["earthquakes"] = quakes + with _data_lock: + latest_data["earthquakes"] = quakes if quakes: _mark_fresh("earthquakes") -# Satellite GP data cache — re-download from CelesTrak only every 30 minutes -_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none"} -_sat_classified_cache = {"data": None, "gp_fetch_ts": 0} # Cache classified sat list (skip re-classification when TLEs unchanged) -_SAT_CACHE_PATH = Path(__file__).parent.parent / "data" / "sat_gp_cache.json" +# --------------------------------------------------------------------------- +# Ships (AIS + Carriers) +# --------------------------------------------------------------------------- +def fetch_ships(): + """Fetch real-time AIS vessel data and combine with OSINT carrier positions.""" + from services.ais_stream import get_ais_vessels + from services.carrier_tracker import get_carrier_positions -def _load_sat_cache(): - """Load satellite GP data from local disk cache.""" + ships = [] try: - if _SAT_CACHE_PATH.exists(): - import os - age_hours = (time.time() - os.path.getmtime(str(_SAT_CACHE_PATH))) / 3600 - if age_hours < 48: # Use cache if less than 48 hours old - with open(_SAT_CACHE_PATH, "r") as f: - data = json.load(f) - if isinstance(data, list) and len(data) > 10: - logger.info(f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)") - return data - else: - logger.info(f"Satellites: Disk cache is {age_hours:.0f}h old, will try fresh fetch") + carriers = get_carrier_positions() + ships.extend(carriers) except Exception as e: - logger.warning(f"Satellites: Failed to load disk cache: {e}") - return None + logger.error(f"Carrier tracker error (non-fatal): {e}") + carriers = [] -def _save_sat_cache(data): - """Save satellite GP data to local disk cache.""" try: - _SAT_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(_SAT_CACHE_PATH, "w") as f: - json.dump(data, f) - logger.info(f"Satellites: Saved {len(data)} records to disk cache") + ais_vessels = get_ais_vessels() + ships.extend(ais_vessels) except Exception as e: - logger.warning(f"Satellites: Failed to save disk cache: {e}") + logger.error(f"AIS stream error (non-fatal): {e}") + ais_vessels = [] -# Satellite intelligence classification database — module-level constant. -# Key: substring to match in OBJECT_NAME → {country, mission, sat_type, wiki} -_SAT_INTEL_DB = [ - # Military reconnaissance / imaging - ("USA 224", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), - ("USA 245", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), - ("USA 290", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), - ("USA 314", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), - ("USA 338", {"country": "USA", "mission": "military_recon", "sat_type": "Keyhole Successor", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), - ("TOPAZ", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}), - ("PERSONA", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}), - ("KONDOR", {"country": "Russia", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)"}), - ("BARS-M", {"country": "Russia", "mission": "military_recon", "sat_type": "Mapping Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Bars-M"}), - ("YAOGAN", {"country": "China", "mission": "military_recon", "sat_type": "Remote Sensing / ELINT", "wiki": "https://en.wikipedia.org/wiki/Yaogan"}), - ("GAOFEN", {"country": "China", "mission": "military_recon", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/Gaofen"}), - ("JILIN", {"country": "China", "mission": "commercial_imaging", "sat_type": "Video / Imaging", "wiki": "https://en.wikipedia.org/wiki/Jilin-1"}), - ("OFEK", {"country": "Israel", "mission": "military_recon", "sat_type": "Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Ofeq"}), - ("CSO", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)"}), - ("IGS", {"country": "Japan", "mission": "military_recon", "sat_type": "Intelligence Gathering", "wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite"}), - # SAR (Synthetic Aperture Radar) — can see through clouds - ("CAPELLA", {"country": "USA", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/Capella_Space"}), - ("ICEYE", {"country": "Finland", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/ICEYE"}), - ("COSMO-SKYMED", {"country": "Italy", "mission": "sar", "sat_type": "SAR Constellation", "wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed"}), - ("TANDEM", {"country": "Germany", "mission": "sar", "sat_type": "SAR Interferometry", "wiki": "https://en.wikipedia.org/wiki/TanDEM-X"}), - ("PAZ", {"country": "Spain", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)"}), - # Commercial imaging - ("WORLDVIEW", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar High-Res", "wiki": "https://en.wikipedia.org/wiki/WorldView-3"}), - ("GEOEYE", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Imaging", "wiki": "https://en.wikipedia.org/wiki/GeoEye-1"}), - ("PLEIADES", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Imaging", "wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)"}), - ("SPOT", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Medium-Res", "wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)"}), - ("PLANET", {"country": "USA", "mission": "commercial_imaging", "sat_type": "PlanetScope", "wiki": "https://en.wikipedia.org/wiki/Planet_Labs"}), - ("SKYSAT", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Planet Video", "wiki": "https://en.wikipedia.org/wiki/SkySat"}), - ("BLACKSKY", {"country": "USA", "mission": "commercial_imaging", "sat_type": "BlackSky Imaging", "wiki": "https://en.wikipedia.org/wiki/BlackSky"}), - # Signals intelligence / ELINT - ("NROL", {"country": "USA", "mission": "sigint", "sat_type": "Classified NRO", "wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office"}), - ("MENTOR", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT / ELINT", "wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)"}), - ("LUCH", {"country": "Russia", "mission": "sigint", "sat_type": "Relay / SIGINT", "wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)"}), - ("SHIJIAN", {"country": "China", "mission": "sigint", "sat_type": "ELINT / Tech Demo", "wiki": "https://en.wikipedia.org/wiki/Shijian"}), - # Navigation - ("NAVSTAR", {"country": "USA", "mission": "navigation", "sat_type": "GPS", "wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks"}), - ("GLONASS", {"country": "Russia", "mission": "navigation", "sat_type": "GLONASS", "wiki": "https://en.wikipedia.org/wiki/GLONASS"}), - ("BEIDOU", {"country": "China", "mission": "navigation", "sat_type": "BeiDou", "wiki": "https://en.wikipedia.org/wiki/BeiDou"}), - ("GALILEO", {"country": "EU", "mission": "navigation", "sat_type": "Galileo", "wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)"}), - # Early warning - ("SBIRS", {"country": "USA", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System"}), - ("TUNDRA", {"country": "Russia", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)"}), - # Space stations - ("ISS", {"country": "Intl", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/International_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) - - def _fetch_term(term): - """Fetch a single search term from TLE API.""" - results = [] - try: - url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json" - response = fetch_with_curl(url, timeout=8) - if response.status_code != 200: - return results - data = response.json() - for member in data.get("member", []): - gp = _parse_tle_to_gp( - member.get("name", "UNKNOWN"), - member.get("satelliteId"), - member.get("line1", ""), - member.get("line2", ""), - ) - if gp: - results.append(gp) - except Exception as e: - logger.debug(f"TLE fallback search '{term}' failed: {e}") - return results - - # Fetch ALL search terms in parallel (was sequential — 35+ requests taking forever) - all_results = [] - seen_ids = set() - with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: - future_map = {executor.submit(_fetch_term, term): term for term in search_terms} - for future in concurrent.futures.as_completed(future_map): - for gp in future.result(): - sat_id = gp.get("NORAD_CAT_ID") - if sat_id not in seen_ids: - seen_ids.add(sat_id) - all_results.append(gp) - - return all_results - - -def fetch_satellites(): - sats = [] - try: - # Cache GP data from CelesTrak — only re-download every 30 minutes - # Positions are re-propagated from cached orbital elements each cycle - now_ts = time.time() - if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800: - # Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks - # Short timeout (5s) so we fail fast and hit the TLE fallback quickly - gp_urls = [ - "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json", - "https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json", - ] - for url in gp_urls: - try: - response = fetch_with_curl(url, timeout=5) - 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 - _sat_gp_cache["source"] = "celestrak" - _save_sat_cache(gp_data) - 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 1: TLE API (parallel fetch) - 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 - _sat_gp_cache["source"] = "tle_api" - _save_sat_cache(fallback_data) - 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}") - - # Fallback 2: local disk cache (survives API outages / rate limits) - if _sat_gp_cache["data"] is None: - disk_data = _load_sat_cache() - if disk_data: - _sat_gp_cache["data"] = disk_data - _sat_gp_cache["last_fetch"] = now_ts - 1500 # Mark as slightly stale so we retry sooner - _sat_gp_cache["source"] = "disk_cache" - - data = _sat_gp_cache["data"] - if not data: - logger.warning("No satellite GP data available from any source") - latest_data["satellites"] = sats - return - - # Only keep satellites matching the intel classification DB - # Skip re-classification if TLEs haven't changed (saves O(n*m) scan) - if _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"] and _sat_classified_cache["data"]: - classified = _sat_classified_cache["data"] - logger.info(f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)") - else: - classified = [] - for sat in data: - name = sat.get("OBJECT_NAME", "UNKNOWN").upper() - intel = None - for key, meta in _SAT_INTEL_DB: - if key.upper() in name: - intel = dict(meta) - break - if not intel: - continue # Skip junk, debris, CubeSats, bulk constellations - entry = { - "id": sat.get("NORAD_CAT_ID"), - "name": sat.get("OBJECT_NAME", "UNKNOWN"), - "MEAN_MOTION": sat.get("MEAN_MOTION"), - "ECCENTRICITY": sat.get("ECCENTRICITY"), - "INCLINATION": sat.get("INCLINATION"), - "RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"), - "ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"), - "MEAN_ANOMALY": sat.get("MEAN_ANOMALY"), - "BSTAR": sat.get("BSTAR"), - "EPOCH": sat.get("EPOCH"), - } - entry.update(intel) - classified.append(entry) - _sat_classified_cache["data"] = classified - _sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"] - logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog") - - all_sats = classified - - # Propagate orbital elements to get current lat/lng/alt using SGP4 - now = datetime.utcnow() - jd, fr = jday(now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6) - - for s in all_sats: - try: - mean_motion = s.get('MEAN_MOTION') - ecc = s.get('ECCENTRICITY') - incl = s.get('INCLINATION') - raan = s.get('RA_OF_ASC_NODE') - argp = s.get('ARG_OF_PERICENTER') - ma = s.get('MEAN_ANOMALY') - bstar = s.get('BSTAR', 0) - epoch_str = s.get('EPOCH') - norad_id = s.get('id', 0) - - if mean_motion is None or ecc is None or incl is None: - continue - - epoch_dt = datetime.strptime(epoch_str[:19], '%Y-%m-%dT%H:%M:%S') - epoch_jd, epoch_fr = jday(epoch_dt.year, epoch_dt.month, epoch_dt.day, - epoch_dt.hour, epoch_dt.minute, epoch_dt.second) - - sat_obj = Satrec() - sat_obj.sgp4init( - WGS72, 'i', norad_id, - (epoch_jd + epoch_fr) - 2433281.5, - bstar, 0.0, 0.0, ecc, - math.radians(argp), math.radians(incl), - math.radians(ma), - mean_motion * 2 * math.pi / 1440.0, - math.radians(raan) - ) - - e, r, v = sat_obj.sgp4(jd, fr) - if e != 0: - continue - - x, y, z = r - gmst = _gmst(jd + fr) - lng_rad = math.atan2(y, x) - gmst - lat_rad = math.atan2(z, math.sqrt(x*x + y*y)) - alt_km = math.sqrt(x*x + y*y + z*z) - 6371.0 - - s['lat'] = round(math.degrees(lat_rad), 4) - lng_deg = math.degrees(lng_rad) % 360 - s['lng'] = round(lng_deg - 360 if lng_deg > 180 else lng_deg, 4) - s['alt_km'] = round(alt_km, 1) - - # Compute ground speed and heading from ECI velocity vector - # v is in km/s in ECI frame; subtract Earth rotation to get ground-relative - vx, vy, vz = v - omega_e = 7.2921159e-5 # Earth rotation rate rad/s - # Ground-relative velocity (subtract Earth rotation) - vx_g = vx + omega_e * y # note: y from position, not vy - vy_g = vy - omega_e * x - vz_g = vz - # Convert ECI velocity to East/North/Up at satellite's geodetic position - cos_lat = math.cos(lat_rad) - sin_lat = math.sin(lat_rad) - cos_lng = math.cos(lng_rad + gmst) # need ECEF longitude - sin_lng = math.sin(lng_rad + gmst) - # East = -sin(lng)*vx + cos(lng)*vy - v_east = -sin_lng * vx_g + cos_lng * vy_g - # North = -sin(lat)*cos(lng)*vx - sin(lat)*sin(lng)*vy + cos(lat)*vz - v_north = -sin_lat * cos_lng * vx_g - sin_lat * sin_lng * vy_g + cos_lat * vz_g - # Ground speed in km/s → knots (1 km/s = 1943.84 knots) - ground_speed_kms = math.sqrt(v_east**2 + v_north**2) - s['speed_knots'] = round(ground_speed_kms * 1943.84, 1) - # Heading: angle from north, clockwise - heading_rad = math.atan2(v_east, v_north) - s['heading'] = round(math.degrees(heading_rad) % 360, 1) - # Wikipedia URL: USA-XXX satellites get their own article, - # all others keep the curated class/type URL from _SAT_INTEL_DB - sat_name = s.get('name', '') - usa_match = re.search(r'USA[\s\-]*(\d+)', sat_name) - if usa_match: - s['wiki'] = f"https://en.wikipedia.org/wiki/USA-{usa_match.group(1)}" - # Strip GP element fields to save bandwidth - for k in ('MEAN_MOTION', 'ECCENTRICITY', 'INCLINATION', - 'RA_OF_ASC_NODE', 'ARG_OF_PERICENTER', 'MEAN_ANOMALY', - 'BSTAR', 'EPOCH', 'tle1', 'tle2'): - s.pop(k, None) - sats.append(s) - except Exception: - continue - - logger.info(f"Satellites: {len(classified)} classified, {len(sats)} positioned") - except Exception as e: - logger.error(f"Error fetching satellites: {e}") - # Only overwrite if we got data — don't wipe the map on API timeout - if sats: - latest_data["satellites"] = sats - latest_data["satellite_source"] = _sat_gp_cache.get("source", "none") - _mark_fresh("satellites") - elif not latest_data.get("satellites"): - latest_data["satellites"] = [] - latest_data["satellite_source"] = "none" + logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels") + with _data_lock: + latest_data['ships'] = ships + _mark_fresh("ships") # --------------------------------------------------------------------------- -# Real UAV detection from ADS-B data — filters military drone transponders +# Airports # --------------------------------------------------------------------------- -_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"} -_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") -_UAV_WIKI = { - "RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk", - "RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk", - "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", - "MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper", - "MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle", - "MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle", - "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", - "PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator", - "HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900", - "HERON": "https://en.wikipedia.org/wiki/IAI_Heron", - "BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2", -} - -def _classify_uav(model: str, callsign: str): - """Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords. - Returns (is_uav, uav_type, wiki_url) or (False, None, None).""" - model_up = model.upper().replace(" ", "") - callsign_up = callsign.upper().strip() - - # Check ICAO type codes - if model_up in _UAV_TYPE_CODES: - uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR" - wiki = _UAV_WIKI.get(model_up, "") - return True, uav_type, wiki - - # Check callsign prefixes (must also have a military-ish model) - for prefix in _UAV_CALLSIGN_PREFIXES: - if callsign_up.startswith(prefix): - uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR" - wiki = _UAV_WIKI.get(prefix, "") - if prefix == "FORTE": - wiki = _UAV_WIKI["RQ4"] - elif prefix == "BAMS": - wiki = _UAV_WIKI["MQ4"] - return True, uav_type, wiki - - # Check model keywords - for kw in _UAV_MODEL_KEYWORDS: - if kw in model_up: - # Determine type from keyword - if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")): - return True, "HALE Surveillance", _UAV_WIKI.get(kw, "") - elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")): - return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "") - elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")): - return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "") - elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")): - return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "") - elif "BAYRAKTAR" in model_up or "TB2" in model_up: - return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "") - elif "HERMES" in model_up: - return True, "MALE ISR", _UAV_WIKI.get("HERMES", "") - elif "HERON" in model_up: - return True, "MALE ISR", _UAV_WIKI.get("HERON", "") - return True, "MALE ISR", _UAV_WIKI.get(kw, "") - - return False, None, None - cached_airports = [] -flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}} -_trails_lock = threading.Lock() -_MAX_TRACKED_TRAILS = 2000 # Global cap on number of aircraft trails in memory - -# (math imported at module top) def find_nearest_airport(lat, lng, max_distance_nm=200): - """Find the nearest large airport to a given lat/lng using haversine distance. - Returns dict with iata, name, lat, lng, distance_nm or None if no airport within range.""" + """Find the nearest large airport to a given lat/lng using haversine distance.""" if not cached_airports: return None - + best = None best_dist = float('inf') - lat_r = math.radians(lat) lng_r = math.radians(lng) - + for apt in cached_airports: apt_lat_r = math.radians(apt['lat']) apt_lng_r = math.radians(apt['lng']) - dlat = apt_lat_r - lat_r dlng = apt_lng_r - lng_r a = math.sin(dlat / 2) ** 2 + math.cos(lat_r) * math.cos(apt_lat_r) * math.sin(dlng / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - dist_nm = 3440.065 * c # Earth radius in nautical miles - + dist_nm = 3440.065 * c + if dist_nm < best_dist: best_dist = dist_nm best = apt - + if best and best_dist <= max_distance_nm: return { - "iata": best['iata'], - "name": best['name'], - "lat": best['lat'], - "lng": best['lng'], + "iata": best['iata'], "name": best['name'], + "lat": best['lat'], "lng": best['lng'], "distance_nm": round(best_dist, 1) } return None @@ -2268,12 +440,9 @@ def fetch_airports(): url = "https://ourairports.com/data/airports.csv" response = fetch_with_curl(url, timeout=15) if response.status_code == 200: - import csv - import io f = io.StringIO(response.text) reader = csv.DictReader(f) for row in reader: - # Filter to only large international hubs that have an IATA code assigned if row['type'] == 'large_airport' and row['iata_code']: cached_airports.append({ "id": row['ident'], @@ -2286,25 +455,43 @@ def fetch_airports(): logger.info(f"Loaded {len(cached_airports)} large airports into cache.") except Exception as e: logger.error(f"Error fetching airports: {e}") - - latest_data['airports'] = cached_airports + with _data_lock: + latest_data['airports'] = cached_airports + +# --------------------------------------------------------------------------- +# Geopolitics & Liveuamap +# --------------------------------------------------------------------------- from services.geopolitics import fetch_ukraine_frontlines, fetch_global_military_incidents -def fetch_geopolitics(): - logger.info("Fetching Geopolitics data...") +def fetch_frontlines(): + """Fetch Ukraine frontline data (fast — single GitHub API call).""" try: frontlines = fetch_ukraine_frontlines() if frontlines: - latest_data['frontlines'] = frontlines + with _data_lock: + latest_data['frontlines'] = frontlines _mark_fresh("frontlines") + except Exception as e: + logger.error(f"Error fetching frontlines: {e}") + +def fetch_gdelt(): + """Fetch GDELT global military incidents (slow — downloads 32 ZIP files).""" + try: gdelt = fetch_global_military_incidents() if gdelt is not None: - latest_data['gdelt'] = gdelt + with _data_lock: + latest_data['gdelt'] = gdelt _mark_fresh("gdelt") except Exception as e: - logger.error(f"Error fetching geopolitics: {e}") + logger.error(f"Error fetching GDELT: {e}") + + +def fetch_geopolitics(): + """Legacy wrapper — runs both sequentially. Used by recurring scheduler.""" + fetch_frontlines() + fetch_gdelt() def update_liveuamap(): logger.info("Running scheduled Liveuamap scraper...") @@ -2312,17 +499,21 @@ def update_liveuamap(): from services.liveuamap_scraper import fetch_liveuamap res = fetch_liveuamap() if res: - latest_data['liveuamap'] = res + with _data_lock: + latest_data['liveuamap'] = res _mark_fresh("liveuamap") except Exception as e: logger.error(f"Liveuamap scraper error: {e}") +# --------------------------------------------------------------------------- +# Scheduler & Orchestration +# --------------------------------------------------------------------------- def update_fast_data(): """Fast-tier: moving entities that need frequent updates (every 60s).""" logger.info("Fast-tier data update starting...") fast_funcs = [ fetch_flights, - fetch_military_flights, # Also detects UAVs from ADS-B + fetch_military_flights, fetch_ships, fetch_satellites, ] @@ -2334,7 +525,9 @@ def update_fast_data(): logger.info("Fast-tier update complete.") def update_slow_data(): - """Slow-tier: feeds that change infrequently (every 30min).""" + """Slow-tier: feeds that change infrequently (every 30min). + Each fetcher writes to latest_data independently as it finishes, + so the frontend sees results progressively — no all-or-nothing barrier.""" logger.info("Slow-tier data update starting...") slow_funcs = [ fetch_news, @@ -2343,7 +536,8 @@ def update_slow_data(): fetch_weather, fetch_cctv, fetch_earthquakes, - fetch_geopolitics, + fetch_frontlines, # fast — single GitHub API call + fetch_gdelt, # slow — 32 ZIP downloads (runs in parallel, won't block frontlines) fetch_kiwisdr, fetch_space_weather, fetch_internet_outages, @@ -2358,9 +552,8 @@ def update_slow_data(): def update_all_data(): """Full update — runs on startup. All tiers run IN PARALLEL for fastest startup.""" logger.info("Full data update starting (parallel)...") - # Run airports, fast, and slow ALL in parallel so the user sees data ASAP with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: - f0 = pool.submit(fetch_airports) # Cached after first download + f0 = pool.submit(fetch_airports) f1 = pool.submit(update_fast_data) f2 = pool.submit(update_slow_data) concurrent.futures.wait([f0, f1, f2]) @@ -2370,23 +563,18 @@ scheduler = BackgroundScheduler() def start_scheduler(): init_db() - - # Run full update once on startup - scheduler.add_job(update_all_data, 'date', run_date=datetime.now()) - - # Fast tier: every 60 seconds (flights, ships, military+UAVs, satellites) + + # NOTE: initial update_all_data() is called synchronously in main.py lifespan + # before start_scheduler(). These are only the RECURRING interval jobs. scheduler.add_job(update_fast_data, 'interval', seconds=60) - - # Slow tier: every 30 minutes (news, stocks, weather, geopolitics) scheduler.add_job(update_slow_data, 'interval', minutes=30) - - # CCTV pipeline has its own cadence + def update_cctvs(): logger.info("Running CCTV Pipeline Ingestion...") ingestors = [ - TFLJamCamIngestor, - LTASingaporeIngestor, - AustinTXIngestor, + TFLJamCamIngestor, + LTASingaporeIngestor, + AustinTXIngestor, NYCDOTIngestor ] for ingestor in ingestors: @@ -2395,17 +583,11 @@ def start_scheduler(): except Exception as e: logger.error(f"Failed {ingestor.__name__} cctv ingest: {e}") fetch_cctv() - - scheduler.add_job(update_cctvs, 'date', run_date=datetime.now()) + scheduler.add_job(update_cctvs, 'interval', minutes=1) - - # Liveuamap: startup + every 12 hours - scheduler.add_job(update_liveuamap, 'date', run_date=datetime.now()) + scheduler.add_job(update_liveuamap, 'interval', hours=12) - - # Geopolitics (frontlines) aligned with slow-data tier - scheduler.add_job(fetch_geopolitics, 'interval', minutes=30) - + scheduler.start() def stop_scheduler(): @@ -2414,4 +596,3 @@ def stop_scheduler(): def get_latest_data(): with _data_lock: return dict(latest_data) - diff --git a/backend/services/fetchers/__init__.py b/backend/services/fetchers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/fetchers/_store.py b/backend/services/fetchers/_store.py new file mode 100644 index 0000000..dc52fd9 --- /dev/null +++ b/backend/services/fetchers/_store.py @@ -0,0 +1,46 @@ +"""Shared in-memory data store for all fetcher modules. + +Central location for latest_data, source_timestamps, and the data lock. +Every fetcher imports from here instead of maintaining its own copy. +""" +import threading +import logging +from datetime import datetime + +logger = logging.getLogger("services.data_fetcher") + +# In-memory store +latest_data = { + "last_updated": None, + "news": [], + "stocks": {}, + "oil": {}, + "flights": [], + "ships": [], + "military_flights": [], + "tracked_flights": [], + "cctv": [], + "weather": None, + "earthquakes": [], + "uavs": [], + "frontlines": None, + "gdelt": [], + "liveuamap": [], + "kiwisdr": [], + "space_weather": None, + "internet_outages": [], + "firms_fires": [], + "datacenters": [] +} + +# Per-source freshness timestamps +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 +_data_lock = threading.Lock() diff --git a/backend/services/fetchers/flights.py b/backend/services/fetchers/flights.py new file mode 100644 index 0000000..34a5723 --- /dev/null +++ b/backend/services/fetchers/flights.py @@ -0,0 +1,721 @@ +"""Commercial flight fetching — ADS-B, OpenSky, supplemental sources, routes, +trail accumulation, GPS jamming detection, and holding pattern detection.""" +import re +import os +import time +import math +import logging +import threading +import concurrent.futures +import requests +from datetime import datetime +from cachetools import TTLCache +from services.network_utils import fetch_with_curl +from services.fetchers._store import latest_data, _data_lock, _mark_fresh +from services.fetchers.plane_alert import enrich_with_plane_alert, enrich_with_tracked_names + +logger = logging.getLogger("services.data_fetcher") + +# Pre-compiled regex patterns for airline code extraction (used in hot loop) +_RE_AIRLINE_CODE_1 = re.compile(r'^([A-Z]{3})\d') +_RE_AIRLINE_CODE_2 = re.compile(r'^([A-Z]{3})[A-Z\d]') + +# --------------------------------------------------------------------------- +# OpenSky Network API Client (OAuth2) +# --------------------------------------------------------------------------- +class OpenSkyClient: + def __init__(self, client_id, client_secret): + self.client_id = client_id + self.client_secret = client_secret + self.token = None + self.expires_at = 0 + + def get_token(self): + if self.token and time.time() < self.expires_at - 60: + return self.token + url = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token" + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret + } + try: + r = requests.post(url, data=data, timeout=10) + if r.status_code == 200: + res = r.json() + self.token = res.get("access_token") + self.expires_at = time.time() + res.get("expires_in", 1800) + logger.info("OpenSky OAuth2 token refreshed.") + return self.token + else: + logger.error(f"OpenSky Auth Failed: {r.status_code} {r.text}") + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: + logger.error(f"OpenSky Auth Exception: {e}") + return None + +opensky_client = OpenSkyClient( + client_id=os.environ.get("OPENSKY_CLIENT_ID", ""), + client_secret=os.environ.get("OPENSKY_CLIENT_SECRET", "") +) + +# Throttling and caching for OpenSky (400 req/day limit) +last_opensky_fetch = 0 +cached_opensky_flights = [] + +# --------------------------------------------------------------------------- +# Supplemental ADS-B sources for blind-spot gap-filling +# --------------------------------------------------------------------------- +_BLIND_SPOT_REGIONS = [ + {"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250}, + {"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250}, + {"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250}, + {"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250}, + {"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250}, + {"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250}, + {"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250}, + {"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250}, +] +_SUPPLEMENTAL_FETCH_INTERVAL = 120 +last_supplemental_fetch = 0 +cached_supplemental_flights = [] + +# Helicopter type codes (backend classification) +_HELI_TYPES_BACKEND = { + "R22", "R44", "R66", "B06", "B06T", "B204", "B205", "B206", "B212", "B222", "B230", + "B407", "B412", "B427", "B429", "B430", "B505", "B525", + "AS32", "AS35", "AS50", "AS55", "AS65", + "EC20", "EC25", "EC30", "EC35", "EC45", "EC55", "EC75", + "H125", "H130", "H135", "H145", "H155", "H160", "H175", "H215", "H225", + "S55", "S58", "S61", "S64", "S70", "S76", "S92", + "A109", "A119", "A139", "A169", "A189", "AW09", + "MD52", "MD60", "MDHI", "MD90", "NOTR", + "B47G", "HUEY", "GAMA", "CABR", "EXE", +} + +# Private jet ICAO type designator codes +PRIVATE_JET_TYPES = { + "G150", "G200", "G280", "GLEX", "G500", "G550", "G600", "G650", "G700", + "GLF2", "GLF3", "GLF4", "GLF5", "GLF6", "GL5T", "GL7T", "GV", "GIV", + "CL30", "CL35", "CL60", "BD70", "BD10", "GL5T", "GL7T", + "CRJ1", "CRJ2", + "C25A", "C25B", "C25C", "C500", "C501", "C510", "C525", "C526", + "C550", "C560", "C56X", "C680", "C68A", "C700", "C750", + "FA10", "FA20", "FA50", "FA7X", "FA8X", "F900", "F2TH", "ASTR", + "E35L", "E545", "E550", "E55P", "LEGA", "PH10", "PH30", + "LJ23", "LJ24", "LJ25", "LJ28", "LJ31", "LJ35", "LJ36", + "LJ40", "LJ45", "LJ55", "LJ60", "LJ70", "LJ75", + "H25A", "H25B", "H25C", "HA4T", "BE40", "PRM1", + "HDJT", "PC24", "EA50", "SF50", "GALX", +} + +# Flight trails state +flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: ts}} +_trails_lock = threading.Lock() +_MAX_TRACKED_TRAILS = 2000 + +# Routes cache +dynamic_routes_cache = TTLCache(maxsize=5000, ttl=7200) +routes_fetch_in_progress = False +_routes_lock = threading.Lock() + + +def _fetch_supplemental_sources(seen_hex: set) -> list: + """Fetch from airplanes.live and adsb.fi to fill blind-spot gaps.""" + global last_supplemental_fetch, cached_supplemental_flights + + now = time.time() + if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL: + return [f for f in cached_supplemental_flights + if f.get("hex", "").lower().strip() not in seen_hex] + + new_supplemental = [] + supplemental_hex = set() + + def _fetch_airplaneslive(region): + try: + url = (f"https://api.airplanes.live/v2/point/" + f"{region['lat']}/{region['lon']}/{region['radius_nm']}") + res = fetch_with_curl(url, timeout=10) + if res.status_code == 200: + data = res.json() + return data.get("ac", []) + except Exception as e: + logger.debug(f"airplanes.live {region['name']} failed: {e}") + return [] + + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(_fetch_airplaneslive, _BLIND_SPOT_REGIONS)) + for region_flights in results: + for f in region_flights: + h = f.get("hex", "").lower().strip() + if h and h not in seen_hex and h not in supplemental_hex: + f["supplemental_source"] = "airplanes.live" + new_supplemental.append(f) + supplemental_hex.add(h) + except Exception as e: + logger.warning(f"airplanes.live supplemental fetch failed: {e}") + + ap_count = len(new_supplemental) + + try: + for region in _BLIND_SPOT_REGIONS: + try: + url = (f"https://opendata.adsb.fi/api/v3/lat/" + f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}") + res = fetch_with_curl(url, timeout=10) + if res.status_code == 200: + data = res.json() + for f in data.get("ac", []): + h = f.get("hex", "").lower().strip() + if h and h not in seen_hex and h not in supplemental_hex: + f["supplemental_source"] = "adsb.fi" + new_supplemental.append(f) + supplemental_hex.add(h) + except Exception as e: + logger.debug(f"adsb.fi {region['name']} failed: {e}") + time.sleep(1.1) + except Exception as e: + logger.warning(f"adsb.fi supplemental fetch failed: {e}") + + fi_count = len(new_supplemental) - ap_count + + cached_supplemental_flights = new_supplemental + last_supplemental_fetch = now + if new_supplemental: + _mark_fresh("supplemental_flights") + + logger.info(f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot " + f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})") + return new_supplemental + + +def fetch_routes_background(sampled): + global routes_fetch_in_progress + with _routes_lock: + if routes_fetch_in_progress: + return + routes_fetch_in_progress = True + + try: + callsigns_to_query = [] + for f in sampled: + c_sign = str(f.get("flight", "")).strip() + if c_sign and c_sign != "UNKNOWN": + callsigns_to_query.append({ + "callsign": c_sign, + "lat": f.get("lat", 0), + "lng": f.get("lon", 0) + }) + + batch_size = 100 + batches = [callsigns_to_query[i:i+batch_size] for i in range(0, len(callsigns_to_query), batch_size)] + + for batch in batches: + try: + r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": batch}, timeout=15) + if r.status_code == 200: + route_data = r.json() + route_list = [] + if isinstance(route_data, dict): + route_list = route_data.get("value", []) + elif isinstance(route_data, list): + route_list = route_data + + for route in route_list: + callsign = route.get("callsign", "") + airports = route.get("_airports", []) + if airports and len(airports) >= 2: + orig_apt = airports[0] + dest_apt = airports[-1] + with _routes_lock: + dynamic_routes_cache[callsign] = { + "orig_name": f"{orig_apt.get('iata', '')}: {orig_apt.get('name', 'Unknown')}", + "dest_name": f"{dest_apt.get('iata', '')}: {dest_apt.get('name', 'Unknown')}", + "orig_loc": [orig_apt.get("lon", 0), orig_apt.get("lat", 0)], + "dest_loc": [dest_apt.get("lon", 0), dest_apt.get("lat", 0)], + } + time.sleep(0.25) + except Exception: + logger.debug("Route batch request failed") + finally: + with _routes_lock: + routes_fetch_in_progress = False + + +def _classify_and_publish(all_adsb_flights): + """Shared pipeline: normalize raw ADS-B data → classify → merge → publish to latest_data. + + Called once immediately after adsb.lol returns (fast path, ~3-5s), + then again after OpenSky + supplemental gap-fill enrichment. + """ + flights = [] + + if not all_adsb_flights: + return + + with _routes_lock: + already_running = routes_fetch_in_progress + if not already_running: + threading.Thread(target=fetch_routes_background, args=(all_adsb_flights,), daemon=True).start() + + for f in all_adsb_flights: + try: + lat = f.get("lat") + lng = f.get("lon") + heading = f.get("track") or 0 + + if lat is None or lng is None: + continue + + flight_str = str(f.get("flight", "UNKNOWN")).strip() + if not flight_str or flight_str == "UNKNOWN": + flight_str = str(f.get("hex", "Unknown")) + + origin_loc = None + dest_loc = None + origin_name = "UNKNOWN" + dest_name = "UNKNOWN" + + with _routes_lock: + cached_route = dynamic_routes_cache.get(flight_str) + if cached_route: + origin_name = cached_route["orig_name"] + dest_name = cached_route["dest_name"] + origin_loc = cached_route["orig_loc"] + dest_loc = cached_route["dest_loc"] + + airline_code = "" + match = _RE_AIRLINE_CODE_1.match(flight_str) + if not match: + match = _RE_AIRLINE_CODE_2.match(flight_str) + if match: + airline_code = match.group(1) + + alt_raw = f.get("alt_baro") + alt_value = 0 + if isinstance(alt_raw, (int, float)): + alt_value = alt_raw * 0.3048 + + gs_knots = f.get("gs") + speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None + + model_upper = f.get("t", "").upper() + if model_upper == "TWR": + continue + + ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane" + + flights.append({ + "callsign": flight_str, + "country": f.get("r", "N/A"), + "lng": float(lng), + "lat": float(lat), + "alt": alt_value, + "heading": heading, + "type": "flight", + "origin_loc": origin_loc, + "dest_loc": dest_loc, + "origin_name": origin_name, + "dest_name": dest_name, + "registration": f.get("r", "N/A"), + "model": f.get("t", "Unknown"), + "icao24": f.get("hex", ""), + "speed_knots": speed_knots, + "squawk": f.get("squawk", ""), + "airline_code": airline_code, + "aircraft_category": ac_category, + "nac_p": f.get("nac_p") + }) + except Exception as loop_e: + logger.error(f"Flight interpolation error: {loop_e}") + continue + + # --- Classification --- + commercial = [] + private_jets = [] + private_ga = [] + tracked = [] + + for f in flights: + enrich_with_plane_alert(f) + enrich_with_tracked_names(f) + + callsign = f.get('callsign', '').strip().upper() + is_commercial_format = bool(re.match(r'^[A-Z]{3}\d{1,4}[A-Z]{0,2}$', callsign)) + + if f.get('alert_category'): + f['type'] = 'tracked_flight' + tracked.append(f) + elif f.get('airline_code') or is_commercial_format: + f['type'] = 'commercial_flight' + commercial.append(f) + elif f.get('model', '').upper() in PRIVATE_JET_TYPES: + f['type'] = 'private_jet' + private_jets.append(f) + else: + f['type'] = 'private_ga' + private_ga.append(f) + + # --- Smart merge: protect against partial API failures --- + prev_commercial_count = len(latest_data.get('commercial_flights', [])) + prev_total = prev_commercial_count + len(latest_data.get('private_jets', [])) + len(latest_data.get('private_flights', [])) + new_total = len(commercial) + len(private_jets) + len(private_ga) + + if new_total == 0: + logger.warning("No civilian flights found! Skipping overwrite to prevent clearing the map.") + elif prev_total > 100 and new_total < prev_total * 0.5: + logger.warning(f"Flight count dropped from {prev_total} to {new_total} (>50% loss). Keeping previous data to prevent flicker.") + else: + _now = time.time() + + def _merge_category(new_list, old_list, max_stale_s=120): + by_icao = {} + for f in old_list: + icao = f.get('icao24', '') + if icao: + f.setdefault('_seen_at', _now) + if (_now - f.get('_seen_at', _now)) < max_stale_s: + by_icao[icao] = f + for f in new_list: + icao = f.get('icao24', '') + if icao: + f['_seen_at'] = _now + by_icao[icao] = f + else: + continue + return list(by_icao.values()) + + with _data_lock: + latest_data['commercial_flights'] = _merge_category(commercial, latest_data.get('commercial_flights', [])) + latest_data['private_jets'] = _merge_category(private_jets, latest_data.get('private_jets', [])) + latest_data['private_flights'] = _merge_category(private_ga, latest_data.get('private_flights', [])) + + _mark_fresh("commercial_flights", "private_jets", "private_flights") + + with _data_lock: + if flights: + latest_data['flights'] = flights + + # Merge tracked civilian flights with tracked military flights + with _data_lock: + existing_tracked = list(latest_data.get('tracked_flights', [])) + + fresh_tracked_map = {} + for t in tracked: + icao = t.get('icao24', '').upper() + if icao: + fresh_tracked_map[icao] = t + + merged_tracked = [] + seen_icaos = set() + for old_t in existing_tracked: + icao = old_t.get('icao24', '').upper() + if icao in fresh_tracked_map: + fresh = fresh_tracked_map[icao] + for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'): + if key in old_t and key not in fresh: + fresh[key] = old_t[key] + merged_tracked.append(fresh) + seen_icaos.add(icao) + else: + merged_tracked.append(old_t) + seen_icaos.add(icao) + + for icao, t in fresh_tracked_map.items(): + if icao not in seen_icaos: + merged_tracked.append(t) + + with _data_lock: + latest_data['tracked_flights'] = merged_tracked + logger.info(f"Tracked flights: {len(merged_tracked)} total ({len(fresh_tracked_map)} fresh from civilian)") + + # --- Trail Accumulation --- + def _accumulate_trail(f, now_ts, check_route=True): + hex_id = f.get('icao24', '').lower() + if not hex_id: + return 0, None + if check_route and f.get('origin_name', 'UNKNOWN') != 'UNKNOWN': + f['trail'] = [] + return 0, hex_id + lat, lng, alt = f.get('lat'), f.get('lng'), f.get('alt', 0) + if lat is None or lng is None: + f['trail'] = flight_trails.get(hex_id, {}).get('points', []) + return 0, hex_id + point = [round(lat, 5), round(lng, 5), round(alt, 1), round(now_ts)] + if hex_id not in flight_trails: + flight_trails[hex_id] = {'points': [], 'last_seen': now_ts} + trail_data = flight_trails[hex_id] + if trail_data['points'] and trail_data['points'][-1][0] == point[0] and trail_data['points'][-1][1] == point[1]: + trail_data['last_seen'] = now_ts + else: + trail_data['points'].append(point) + trail_data['last_seen'] = now_ts + if len(trail_data['points']) > 200: + trail_data['points'] = trail_data['points'][-200:] + f['trail'] = trail_data['points'] + return 1, hex_id + + now_ts = datetime.utcnow().timestamp() + all_lists = [commercial, private_jets, private_ga, existing_tracked] + seen_hexes = set() + trail_count = 0 + with _trails_lock: + for flist in all_lists: + for f in flist: + count, hex_id = _accumulate_trail(f, now_ts, check_route=True) + trail_count += count + if hex_id: + seen_hexes.add(hex_id) + + for mf in latest_data.get('military_flights', []): + count, hex_id = _accumulate_trail(mf, now_ts, check_route=False) + trail_count += count + if hex_id: + seen_hexes.add(hex_id) + + tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])} + stale_keys = [] + for k, v in flight_trails.items(): + cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 300 + if v['last_seen'] < cutoff: + stale_keys.append(k) + for k in stale_keys: + del flight_trails[k] + + if len(flight_trails) > _MAX_TRACKED_TRAILS: + sorted_keys = sorted(flight_trails.keys(), key=lambda k: flight_trails[k]['last_seen']) + evict_count = len(flight_trails) - _MAX_TRACKED_TRAILS + for k in sorted_keys[:evict_count]: + del flight_trails[k] + + logger.info(f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total") + + # --- GPS Jamming Detection --- + try: + jamming_grid = {} + raw_flights = latest_data.get('flights', []) + for rf in raw_flights: + rlat = rf.get('lat') + rlng = rf.get('lng') or rf.get('lon') + if rlat is None or rlng is None: + continue + nacp = rf.get('nac_p') + if nacp is None: + continue + grid_key = f"{int(rlat)},{int(rlng)}" + if grid_key not in jamming_grid: + jamming_grid[grid_key] = {"degraded": 0, "total": 0} + jamming_grid[grid_key]["total"] += 1 + if nacp < 8: + jamming_grid[grid_key]["degraded"] += 1 + + jamming_zones = [] + for gk, counts in jamming_grid.items(): + if counts["total"] < 3: + continue + ratio = counts["degraded"] / counts["total"] + if ratio > 0.25: + lat_i, lng_i = gk.split(",") + severity = "low" if ratio < 0.5 else "medium" if ratio < 0.75 else "high" + jamming_zones.append({ + "lat": int(lat_i) + 0.5, + "lng": int(lng_i) + 0.5, + "severity": severity, + "ratio": round(ratio, 2), + "degraded": counts["degraded"], + "total": counts["total"] + }) + with _data_lock: + latest_data['gps_jamming'] = jamming_zones + if jamming_zones: + logger.info(f"GPS Jamming: {len(jamming_zones)} interference zones detected") + except Exception as e: + logger.error(f"GPS Jamming detection error: {e}") + with _data_lock: + latest_data['gps_jamming'] = [] + + # --- Holding Pattern Detection --- + try: + holding_count = 0 + all_flight_lists = [commercial, private_jets, private_ga, + latest_data.get('tracked_flights', []), + latest_data.get('military_flights', [])] + with _trails_lock: + trails_snapshot = {k: v.get('points', [])[:] for k, v in flight_trails.items()} + for flist in all_flight_lists: + for f in flist: + hex_id = f.get('icao24', '').lower() + trail = trails_snapshot.get(hex_id, []) + if len(trail) < 6: + f['holding'] = False + continue + pts = trail[-8:] + total_turn = 0.0 + prev_bearing = 0.0 + for i in range(1, len(pts)): + lat1, lng1 = math.radians(pts[i-1][0]), math.radians(pts[i-1][1]) + lat2, lng2 = math.radians(pts[i][0]), math.radians(pts[i][1]) + dlng = lng2 - lng1 + x = math.sin(dlng) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlng) + bearing = math.degrees(math.atan2(x, y)) % 360 + if i > 1: + delta = abs(bearing - prev_bearing) + if delta > 180: + delta = 360 - delta + total_turn += delta + prev_bearing = bearing + f['holding'] = total_turn > 300 + if f['holding']: + holding_count += 1 + if holding_count: + logger.info(f"Holding patterns: {holding_count} aircraft circling") + except Exception as e: + logger.error(f"Holding pattern detection error: {e}") + + with _data_lock: + latest_data['last_updated'] = datetime.utcnow().isoformat() + + +def _fetch_adsb_lol_regions(): + """Fetch all adsb.lol regions in parallel (~3-5s). Returns raw aircraft list.""" + regions = [ + {"lat": 39.8, "lon": -98.5, "dist": 2000}, + {"lat": 50.0, "lon": 15.0, "dist": 2000}, + {"lat": 35.0, "lon": 105.0, "dist": 2000}, + {"lat": -25.0, "lon": 133.0, "dist": 2000}, + {"lat": 0.0, "lon": 20.0, "dist": 2500}, + {"lat": -15.0, "lon": -60.0, "dist": 2000} + ] + + def _fetch_region(r): + url = f"https://api.adsb.lol/v2/lat/{r['lat']}/lon/{r['lon']}/dist/{r['dist']}" + try: + res = fetch_with_curl(url, timeout=10) + if res.status_code == 200: + data = res.json() + return data.get("ac", []) + except Exception as e: + logger.warning(f"Region fetch failed for lat={r['lat']}: {e}") + return [] + + all_flights = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: + results = pool.map(_fetch_region, regions) + for region_flights in results: + all_flights.extend(region_flights) + return all_flights + + +def _enrich_with_opensky_and_supplemental(adsb_flights): + """Slow enrichment: merge OpenSky gap-fill + supplemental sources, then re-publish. + + Runs in a background thread so the initial adsb.lol data is already visible. + """ + try: + seen_hex = set() + for f in adsb_flights: + h = f.get("hex") + if h: + seen_hex.add(h.lower().strip()) + + all_flights = list(adsb_flights) # copy to avoid mutating the original + + # OpenSky Regional Fallback + now = time.time() + global last_opensky_fetch, cached_opensky_flights + + if now - last_opensky_fetch > 300: + token = opensky_client.get_token() + if token: + opensky_regions = [ + {"name": "Africa", "bbox": {"lamin": -35.0, "lomin": -20.0, "lamax": 38.0, "lomax": 55.0}}, + {"name": "Asia", "bbox": {"lamin": 0.0, "lomin": 30.0, "lamax": 75.0, "lomax": 150.0}}, + {"name": "South America", "bbox": {"lamin": -60.0, "lomin": -95.0, "lamax": 15.0, "lomax": -30.0}} + ] + + new_opensky_flights = [] + for os_reg in opensky_regions: + try: + bb = os_reg["bbox"] + os_url = f"https://opensky-network.org/api/states/all?lamin={bb['lamin']}&lomin={bb['lomin']}&lamax={bb['lamax']}&lomax={bb['lomax']}" + headers = {"Authorization": f"Bearer {token}"} + os_res = requests.get(os_url, headers=headers, timeout=15) + + if os_res.status_code == 200: + os_data = os_res.json() + states = os_data.get("states") or [] + logger.info(f"OpenSky: Fetched {len(states)} states for {os_reg['name']}") + + for s in states: + new_opensky_flights.append({ + "hex": s[0], + "flight": s[1].strip() if s[1] else "UNKNOWN", + "r": s[2], + "lon": s[5], + "lat": s[6], + "alt_baro": (s[7] * 3.28084) if s[7] else 0, + "track": s[10] or 0, + "gs": (s[9] * 1.94384) if s[9] else 0, + "t": "Unknown", + "is_opensky": True + }) + else: + logger.warning(f"OpenSky API {os_reg['name']} failed: {os_res.status_code}") + except Exception as ex: + logger.error(f"OpenSky fetching error for {os_reg['name']}: {ex}") + + cached_opensky_flights = new_opensky_flights + last_opensky_fetch = now + + # Merge OpenSky (dedup by hex) + for osf in cached_opensky_flights: + h = osf.get("hex") + if h and h.lower().strip() not in seen_hex: + all_flights.append(osf) + seen_hex.add(h.lower().strip()) + + # Supplemental gap-fill + try: + gap_fill = _fetch_supplemental_sources(seen_hex) + for f in gap_fill: + all_flights.append(f) + h = f.get("hex", "").lower().strip() + if h: + seen_hex.add(h) + if gap_fill: + logger.info(f"Gap-fill: added {len(gap_fill)} aircraft to pipeline") + except Exception as e: + logger.warning(f"Supplemental source fetch failed (non-fatal): {e}") + + # Re-publish with enriched data + if len(all_flights) > len(adsb_flights): + logger.info(f"Enrichment: {len(all_flights) - len(adsb_flights)} additional aircraft from OpenSky + supplemental") + _classify_and_publish(all_flights) + except Exception as e: + logger.error(f"OpenSky/supplemental enrichment error: {e}") + + +def fetch_flights(): + """Two-phase flight fetching: + Phase 1 (fast): Fetch adsb.lol → classify → publish immediately (~3-5s) + Phase 2 (background): Merge OpenSky + supplemental → re-publish (~15-30s) + """ + try: + # Phase 1: adsb.lol — fast, parallel, publish immediately + adsb_flights = _fetch_adsb_lol_regions() + if adsb_flights: + logger.info(f"adsb.lol: {len(adsb_flights)} aircraft — publishing immediately") + _classify_and_publish(adsb_flights) + + # Phase 2: kick off slow enrichment in background + threading.Thread( + target=_enrich_with_opensky_and_supplemental, + args=(adsb_flights,), + daemon=True, + ).start() + else: + logger.warning("adsb.lol returned 0 aircraft") + except Exception as e: + logger.error(f"Error fetching flights: {e}") diff --git a/backend/services/fetchers/military.py b/backend/services/fetchers/military.py new file mode 100644 index 0000000..cdd08c1 --- /dev/null +++ b/backend/services/fetchers/military.py @@ -0,0 +1,218 @@ +"""Military flight tracking and UAV detection from ADS-B data.""" +import logging +from services.network_utils import fetch_with_curl +from services.fetchers._store import latest_data, _data_lock, _mark_fresh +from services.fetchers.plane_alert import enrich_with_plane_alert + +logger = logging.getLogger("services.data_fetcher") + +# --------------------------------------------------------------------------- +# UAV classification — filters military drone transponders +# --------------------------------------------------------------------------- +_UAV_TYPE_CODES = {"Q9", "R4", "TB2", "MALE", "HALE", "HERM", "HRON"} +_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") +_UAV_WIKI = { + "RQ4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk", + "RQ-4": "https://en.wikipedia.org/wiki/Northrop_Grumman_RQ-4_Global_Hawk", + "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", + "MQ-9": "https://en.wikipedia.org/wiki/General_Atomics_MQ-9_Reaper", + "MQ1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle", + "MQ-1": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1C_Gray_Eagle", + "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", + "PREDATOR": "https://en.wikipedia.org/wiki/General_Atomics_MQ-1_Predator", + "HERMES": "https://en.wikipedia.org/wiki/Elbit_Hermes_900", + "HERON": "https://en.wikipedia.org/wiki/IAI_Heron", + "BAYRAKTAR": "https://en.wikipedia.org/wiki/Bayraktar_TB2", +} + + +def _classify_uav(model: str, callsign: str): + """Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords. + Returns (is_uav, uav_type, wiki_url) or (False, None, None).""" + model_up = model.upper().replace(" ", "") + callsign_up = callsign.upper().strip() + + if model_up in _UAV_TYPE_CODES: + uav_type = "HALE Surveillance" if model_up in ("R4", "HALE") else "MALE ISR" + wiki = _UAV_WIKI.get(model_up, "") + return True, uav_type, wiki + + for prefix in _UAV_CALLSIGN_PREFIXES: + if callsign_up.startswith(prefix): + uav_type = "HALE Surveillance" if prefix in ("FORTE", "GHAWK", "BAMS") else "MALE ISR" + wiki = _UAV_WIKI.get(prefix, "") + if prefix == "FORTE": + wiki = _UAV_WIKI["RQ4"] + elif prefix == "BAMS": + wiki = _UAV_WIKI["MQ4"] + return True, uav_type, wiki + + for kw in _UAV_MODEL_KEYWORDS: + if kw in model_up: + if any(h in model_up for h in ("RQ4", "RQ-4", "GLOBALHAWK")): + return True, "HALE Surveillance", _UAV_WIKI.get(kw, "") + elif any(h in model_up for h in ("MQ4", "MQ-4", "TRITON")): + return True, "HALE Maritime Surveillance", _UAV_WIKI.get(kw, "") + elif any(h in model_up for h in ("MQ9", "MQ-9", "REAPER")): + return True, "MALE Strike/ISR", _UAV_WIKI.get(kw, "") + elif any(h in model_up for h in ("MQ1", "MQ-1", "PREDATOR")): + return True, "MALE ISR/Strike", _UAV_WIKI.get(kw, "") + elif "BAYRAKTAR" in model_up or "TB2" in model_up: + return True, "MALE Strike", _UAV_WIKI.get("BAYRAKTAR", "") + elif "HERMES" in model_up: + return True, "MALE ISR", _UAV_WIKI.get("HERMES", "") + elif "HERON" in model_up: + return True, "MALE ISR", _UAV_WIKI.get("HERON", "") + return True, "MALE ISR", _UAV_WIKI.get(kw, "") + + return False, None, None + + +def fetch_military_flights(): + military_flights = [] + detected_uavs = [] + try: + url = "https://api.adsb.lol/v2/mil" + response = fetch_with_curl(url, timeout=10) + if response.status_code == 200: + ac = response.json().get('ac', []) + for f in ac: + try: + lat = f.get("lat") + lng = f.get("lon") + heading = f.get("track") or 0 + + if lat is None or lng is None: + continue + + model = str(f.get("t", "UNKNOWN")).upper() + callsign = str(f.get("flight", "MIL-UNKN")).strip() + + 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 + + gs_knots = f.get("gs") + speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None + + 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("flag", "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 + + mil_cat = "default" + if "H" in model and any(c.isdigit() for c in model): + mil_cat = "heli" + elif any(k in model for k in ["K35", "K46", "A33"]): + mil_cat = "tanker" + elif any(k in model for k in ["F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10"]): + mil_cat = "fighter" + elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]): + mil_cat = "cargo" + elif any(k in model for k in ["P8", "E3", "E8", "U2"]): + mil_cat = "recon" + + military_flights.append({ + "callsign": callsign, + "country": f.get("flag", "Military Asset"), + "lng": float(lng), + "lat": float(lat), + "alt": alt_value, + "heading": heading, + "type": "military_flight", + "military_type": mil_cat, + "origin_loc": None, + "dest_loc": None, + "origin_name": "UNKNOWN", + "dest_name": "UNKNOWN", + "registration": f.get("r", "N/A"), + "model": f.get("t", "Unknown"), + "icao24": f.get("hex", ""), + "speed_knots": speed_knots, + "squawk": f.get("squawk", "") + }) + except Exception as loop_e: + logger.error(f"Mil flight interpolation error: {loop_e}") + continue + except Exception as e: + logger.error(f"Error fetching military flights: {e}") + + if not military_flights and not detected_uavs: + logger.warning("No military flights retrieved — keeping previous data if available") + with _data_lock: + if latest_data.get('military_flights'): + return + + with _data_lock: + 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 + tracked_mil = [] + remaining_mil = [] + for mf in military_flights: + enrich_with_plane_alert(mf) + if mf.get('alert_category'): + mf['type'] = 'tracked_flight' + tracked_mil.append(mf) + else: + remaining_mil.append(mf) + with _data_lock: + latest_data['military_flights'] = remaining_mil + + # Store tracked military flights — update positions for existing entries + with _data_lock: + existing_tracked = list(latest_data.get('tracked_flights', [])) + fresh_mil_map = {} + for t in tracked_mil: + icao = t.get('icao24', '').upper() + if icao: + fresh_mil_map[icao] = t + + updated_tracked = [] + seen_icaos = set() + for old_t in existing_tracked: + icao = old_t.get('icao24', '').upper() + if icao in fresh_mil_map: + fresh = fresh_mil_map[icao] + for key in ('alert_category', 'alert_operator', 'alert_special', 'alert_flag'): + if key in old_t and key not in fresh: + fresh[key] = old_t[key] + updated_tracked.append(fresh) + seen_icaos.add(icao) + else: + updated_tracked.append(old_t) + seen_icaos.add(icao) + for icao, t in fresh_mil_map.items(): + if icao not in seen_icaos: + updated_tracked.append(t) + with _data_lock: + latest_data['tracked_flights'] = updated_tracked + logger.info(f"Tracked flights: {len(updated_tracked)} total ({len(tracked_mil)} from military)") diff --git a/backend/services/fetchers/news.py b/backend/services/fetchers/news.py new file mode 100644 index 0000000..1f87123 --- /dev/null +++ b/backend/services/fetchers/news.py @@ -0,0 +1,220 @@ +"""News fetching, geocoding, clustering, and risk assessment.""" +import re +import logging +import concurrent.futures +import feedparser +from services.network_utils import fetch_with_curl +from services.fetchers._store import latest_data, _data_lock, _mark_fresh + +logger = logging.getLogger("services.data_fetcher") + + +# Keyword -> coordinate mapping for geocoding news articles +_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), + "tokyo": (35.676, 139.650), + "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), + "kenya": (-1.292, 36.821), + "libya": (26.335, 17.228), + "mali": (17.570, -3.996), + "niger": (17.607, 8.081), + "somalia": (5.152, 46.199), + "ethiopia": (9.145, 40.489), + "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), + "bangkok": (13.756, 100.501), + "jakarta": (-6.208, 106.845), +} + + +def fetch_news(): + from services.news_feed_config import get_feeds + feed_config = get_feeds() + feeds = {f["name"]: f["url"] for f in feed_config} + source_weights = {f["name"]: f["weight"] for f in feed_config} + + clusters = {} + _cluster_grid = {} + + def _fetch_feed(item): + source_name, url = item + try: + xml_data = fetch_with_curl(url, timeout=10).text + return source_name, feedparser.parse(xml_data) + except Exception as e: + logger.warning(f"Feed {source_name} failed: {e}") + return source_name, None + + with concurrent.futures.ThreadPoolExecutor(max_workers=len(feeds)) as pool: + feed_results = list(pool.map(_fetch_feed, feeds.items())) + + for source_name, feed in feed_results: + if not feed: + continue + for entry in feed.entries[:5]: + title = entry.get('title', '') + summary = entry.get('summary', '') + + _seismic_kw = ["earthquake", "seismic", "quake", "tremor", "magnitude", "richter"] + _text_lower = (title + " " + summary).lower() + if any(kw in _text_lower for kw in _seismic_kw): + continue + + if source_name == "GDACS": + alert_level = entry.get("gdacs_alertlevel", "Green") + if alert_level == "Red": risk_score = 10 + elif alert_level == "Orange": risk_score = 7 + else: risk_score = 4 + else: + risk_keywords = ['war', 'missile', 'strike', 'attack', 'crisis', 'tension', 'military', 'conflict', 'defense', 'clash', 'nuclear'] + text = (title + " " + summary).lower() + + risk_score = 1 + for kw in risk_keywords: + if kw in text: + risk_score += 2 + risk_score = min(10, risk_score) + + keyword_coords = _KEYWORD_COORDS + + lat, lng = None, None + + if 'georss_point' in entry: + geo_parts = entry['georss_point'].split() + if len(geo_parts) == 2: + lat, lng = float(geo_parts[0]), float(geo_parts[1]) + elif 'where' in entry and hasattr(entry['where'], 'coordinates'): + coords = entry['where'].coordinates + lat, lng = coords[1], coords[0] + + if lat is None: + # text may not be defined yet for GDACS path + text = (title + " " + summary).lower() + padded_text = f" {text} " + for kw, coords in keyword_coords.items(): + if kw.startswith(" ") or kw.endswith(" "): + if kw in padded_text: + lat, lng = coords + break + else: + if re.search(r'\b' + re.escape(kw) + r'\b', text): + lat, lng = coords + break + + if lat is not None: + key = None + cell_x, cell_y = int(lng // 4), int(lat // 4) + for dx in range(-1, 2): + for dy in range(-1, 2): + for ckey in _cluster_grid.get((cell_x + dx, cell_y + dy), []): + parts = ckey.split(",") + elat, elng = float(parts[0]), float(parts[1]) + if ((lat - elat)**2 + (lng - elng)**2)**0.5 < 4.0: + key = ckey + break + if key: + break + if key: + break + if key is None: + key = f"{lat},{lng}" + _cluster_grid.setdefault((cell_x, cell_y), []).append(key) + else: + key = title + + if key not in clusters: + clusters[key] = [] + + clusters[key].append({ + "title": title, + "link": entry.get('link', ''), + "published": entry.get('published', ''), + "source": source_name, + "risk_score": risk_score, + "coords": [lat, lng] if lat is not None else None + }) + + news_items = [] + for key, articles in clusters.items(): + articles.sort(key=lambda x: (x['risk_score'], source_weights.get(x["source"], 0)), reverse=True) + max_risk = articles[0]['risk_score'] + + top_article = articles[0] + news_items.append({ + "title": top_article["title"], + "link": top_article["link"], + "published": top_article["published"], + "source": top_article["source"], + "risk_score": max_risk, + "coords": top_article["coords"], + "cluster_count": len(articles), + "articles": articles, + "machine_assessment": None + }) + + news_items.sort(key=lambda x: x['risk_score'], reverse=True) + with _data_lock: + latest_data['news'] = news_items + _mark_fresh("news") diff --git a/backend/services/fetchers/plane_alert.py b/backend/services/fetchers/plane_alert.py new file mode 100644 index 0000000..715d2da --- /dev/null +++ b/backend/services/fetchers/plane_alert.py @@ -0,0 +1,205 @@ +"""Plane-Alert DB — load and enrich aircraft with tracked metadata.""" +import os +import json +import logging + +logger = logging.getLogger("services.data_fetcher") + +# Exact category -> color mapping for all 53 known categories. +# O(1) dict lookup — no keyword scanning, no false positives. +_CATEGORY_COLOR: dict[str, str] = { + # YELLOW — Military / Intelligence / Defense + "USAF": "yellow", + "Other Air Forces": "yellow", + "Toy Soldiers": "yellow", + "Oxcart": "yellow", + "United States Navy": "yellow", + "GAF": "yellow", + "Hired Gun": "yellow", + "United States Marine Corps": "yellow", + "Gunship": "yellow", + "RAF": "yellow", + "Other Navies": "yellow", + "Special Forces": "yellow", + "Zoomies": "yellow", + "Royal Navy Fleet Air Arm": "yellow", + "Army Air Corps": "yellow", + "Aerobatic Teams": "yellow", + "UAV": "yellow", + "Ukraine": "yellow", + "Nuclear": "yellow", + # LIME — Emergency / Medical / Rescue / Fire + "Flying Doctors": "#32cd32", + "Aerial Firefighter": "#32cd32", + "Coastguard": "#32cd32", + # BLUE — Government / Law Enforcement / Civil + "Police Forces": "blue", + "Governments": "blue", + "Quango": "blue", + "UK National Police Air Service": "blue", + "CAP": "blue", + # BLACK — Privacy / PIA + "PIA": "black", + # RED — Dictator / Oligarch + "Dictator Alert": "red", + "Da Comrade": "red", + "Oligarch": "red", + # HOT PINK — High Value Assets / VIP / Celebrity + "Head of State": "#ff1493", + "Royal Aircraft": "#ff1493", + "Don't you know who I am?": "#ff1493", + "As Seen on TV": "#ff1493", + "Bizjets": "#ff1493", + "Vanity Plate": "#ff1493", + "Football": "#ff1493", + # ORANGE — Joe Cool + "Joe Cool": "orange", + # WHITE — Climate Crisis + "Climate Crisis": "white", + # PURPLE — General Tracked / Other Notable + "Historic": "purple", + "Jump Johnny Jump": "purple", + "Ptolemy would be proud": "purple", + "Distinctive": "purple", + "Dogs with Jobs": "purple", + "You came here in that thing?": "purple", + "Big Hello": "purple", + "Watch Me Fly": "purple", + "Perfectly Serviceable Aircraft": "purple", + "Jesus he Knows me": "purple", + "Gas Bags": "purple", + "Radiohead": "purple", +} + +def _category_to_color(cat: str) -> str: + """O(1) exact lookup. Unknown categories default to purple.""" + return _CATEGORY_COLOR.get(cat, "purple") + +_PLANE_ALERT_DB: dict = {} + +# --------------------------------------------------------------------------- +# POTUS Fleet — override colors and operator names for presidential aircraft. +# --------------------------------------------------------------------------- +_POTUS_FLEET: dict[str, dict] = { + "ADFDF8": {"color": "#ff1493", "operator": "Air Force One (82-8000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"}, + "ADFDF9": {"color": "#ff1493", "operator": "Air Force One (92-9000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"}, + "ADFEB7": {"color": "blue", "operator": "Air Force Two (98-0001)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "ADFEB8": {"color": "blue", "operator": "Air Force Two (98-0002)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "ADFEB9": {"color": "blue", "operator": "Air Force Two (99-0003)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "ADFEBA": {"color": "blue", "operator": "Air Force Two (99-0004)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "AE4AE6": {"color": "blue", "operator": "Air Force Two (09-0015)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "AE4AE8": {"color": "blue", "operator": "Air Force Two (09-0016)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "AE4AEA": {"color": "blue", "operator": "Air Force Two (09-0017)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "AE4AEC": {"color": "blue", "operator": "Air Force Two (19-0018)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"}, + "AE0865": {"color": "#ff1493", "operator": "Marine One (VH-3D)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, + "AE5E76": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, + "AE5E77": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, + "AE5E79": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"}, +} + +def _load_plane_alert_db(): + """Load plane_alert_db.json (exported from SQLite) into memory.""" + global _PLANE_ALERT_DB + json_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "plane_alert_db.json" + ) + if not os.path.exists(json_path): + logger.warning(f"Plane-Alert DB not found at {json_path}") + return + try: + with open(json_path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + for icao_hex, info in raw.items(): + info["color"] = _category_to_color(info.get("category", "")) + override = _POTUS_FLEET.get(icao_hex) + if override: + info["color"] = override["color"] + info["operator"] = override["operator"] + info["category"] = override["category"] + info["wiki"] = override.get("wiki", "") + info["potus_fleet"] = override.get("fleet", "") + _PLANE_ALERT_DB[icao_hex] = info + logger.info(f"Plane-Alert DB loaded: {len(_PLANE_ALERT_DB)} aircraft") + except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e: + logger.error(f"Failed to load Plane-Alert DB: {e}") + +_load_plane_alert_db() + +def enrich_with_plane_alert(flight: dict) -> dict: + """If flight's icao24 is in the Plane-Alert DB, add alert metadata.""" + icao = flight.get("icao24", "").strip().upper() + if icao and icao in _PLANE_ALERT_DB: + info = _PLANE_ALERT_DB[icao] + flight["alert_category"] = info["category"] + flight["alert_color"] = info["color"] + flight["alert_operator"] = info["operator"] + flight["alert_type"] = info["ac_type"] + flight["alert_tags"] = info["tags"] + flight["alert_link"] = info["link"] + if info.get("wiki"): + flight["alert_wiki"] = info["wiki"] + if info.get("potus_fleet"): + flight["potus_fleet"] = info["potus_fleet"] + if info["registration"]: + flight["registration"] = info["registration"] + return flight + +_TRACKED_NAMES_DB: dict = {} + +def _load_tracked_names(): + global _TRACKED_NAMES_DB + json_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "data", "tracked_names.json" + ) + if not os.path.exists(json_path): + return + try: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + for name, info in data.get("details", {}).items(): + cat = info.get("category", "Other") + for reg in info.get("registrations", []): + reg_clean = reg.strip().upper() + if reg_clean: + _TRACKED_NAMES_DB[reg_clean] = {"name": name, "category": cat} + logger.info(f"Tracked Names DB loaded: {len(_TRACKED_NAMES_DB)} registrations") + except (IOError, OSError, json.JSONDecodeError, ValueError, KeyError) as e: + logger.error(f"Failed to load Tracked Names DB: {e}") + +_load_tracked_names() + +def enrich_with_tracked_names(flight: dict) -> dict: + """If flight's registration matches our Excel extraction, tag it as tracked.""" + icao = flight.get("icao24", "").strip().upper() + if icao in _POTUS_FLEET: + return flight + + reg = flight.get("registration", "").strip().upper() + callsign = flight.get("callsign", "").strip().upper() + + match = None + if reg and reg in _TRACKED_NAMES_DB: + match = _TRACKED_NAMES_DB[reg] + elif callsign and callsign in _TRACKED_NAMES_DB: + match = _TRACKED_NAMES_DB[callsign] + + if match: + name = match["name"] + flight["alert_operator"] = name + flight["alert_category"] = match["category"] + + name_lower = name.lower() + is_gov = any(w in name_lower for w in ['state of ', 'government', 'republic', 'ministry', 'department', 'federal', 'cia']) + is_law = any(w in name_lower for w in ['police', 'marshal', 'sheriff', 'douane', 'customs', 'patrol', 'gendarmerie', 'guardia', 'law enforcement']) + is_med = any(w in name_lower for w in ['fire', 'bomberos', 'ambulance', 'paramedic', 'medevac', 'rescue', 'hospital', 'medical', 'lifeflight']) + + if is_gov or is_law: + flight["alert_color"] = "blue" + elif is_med: + flight["alert_color"] = "#32cd32" + elif "alert_color" not in flight: + flight["alert_color"] = "pink" + + return flight diff --git a/backend/services/fetchers/satellites.py b/backend/services/fetchers/satellites.py new file mode 100644 index 0000000..886a446 --- /dev/null +++ b/backend/services/fetchers/satellites.py @@ -0,0 +1,354 @@ +"""Satellite tracking — CelesTrak/TLE fetch, SGP4 propagation, intel classification.""" +import math +import time +import json +import re +import logging +import concurrent.futures +from pathlib import Path +from datetime import datetime, timedelta +from sgp4.api import Satrec, WGS72, jday +from services.network_utils import fetch_with_curl +from services.fetchers._store import latest_data, _data_lock, _mark_fresh + +logger = logging.getLogger("services.data_fetcher") + + +def _gmst(jd_ut1): + """Greenwich Mean Sidereal Time in radians from Julian Date.""" + t = (jd_ut1 - 2451545.0) / 36525.0 + gmst_sec = 67310.54841 + (876600.0 * 3600 + 8640184.812866) * t + 0.093104 * t * t - 6.2e-6 * t * t * t + gmst_rad = (gmst_sec % 86400) / 86400.0 * 2 * math.pi + return gmst_rad + + +# Satellite GP data cache +_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none"} +_sat_classified_cache = {"data": None, "gp_fetch_ts": 0} +_SAT_CACHE_PATH = Path(__file__).parent.parent.parent / "data" / "sat_gp_cache.json" + +def _load_sat_cache(): + """Load satellite GP data from local disk cache.""" + try: + if _SAT_CACHE_PATH.exists(): + import os + age_hours = (time.time() - os.path.getmtime(str(_SAT_CACHE_PATH))) / 3600 + if age_hours < 48: + with open(_SAT_CACHE_PATH, "r") as f: + data = json.load(f) + if isinstance(data, list) and len(data) > 10: + logger.info(f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)") + return data + else: + logger.info(f"Satellites: Disk cache is {age_hours:.0f}h old, will try fresh fetch") + except Exception as e: + logger.warning(f"Satellites: Failed to load disk cache: {e}") + return None + +def _save_sat_cache(data): + """Save satellite GP data to local disk cache.""" + try: + _SAT_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(_SAT_CACHE_PATH, "w") as f: + json.dump(data, f) + logger.info(f"Satellites: Saved {len(data)} records to disk cache") + except Exception as e: + logger.warning(f"Satellites: Failed to save disk cache: {e}") + + +# Satellite intelligence classification database +_SAT_INTEL_DB = [ + ("USA 224", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), + ("USA 245", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), + ("USA 290", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), + ("USA 314", {"country": "USA", "mission": "military_recon", "sat_type": "KH-11 Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), + ("USA 338", {"country": "USA", "mission": "military_recon", "sat_type": "Keyhole Successor", "wiki": "https://en.wikipedia.org/wiki/KH-11_KENNEN"}), + ("TOPAZ", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}), + ("PERSONA", {"country": "Russia", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Persona_(satellite)"}), + ("KONDOR", {"country": "Russia", "mission": "military_sar", "sat_type": "SAR Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Kondor_(satellite)"}), + ("BARS-M", {"country": "Russia", "mission": "military_recon", "sat_type": "Mapping Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Bars-M"}), + ("YAOGAN", {"country": "China", "mission": "military_recon", "sat_type": "Remote Sensing / ELINT", "wiki": "https://en.wikipedia.org/wiki/Yaogan"}), + ("GAOFEN", {"country": "China", "mission": "military_recon", "sat_type": "High-Res Imaging", "wiki": "https://en.wikipedia.org/wiki/Gaofen"}), + ("JILIN", {"country": "China", "mission": "commercial_imaging", "sat_type": "Video / Imaging", "wiki": "https://en.wikipedia.org/wiki/Jilin-1"}), + ("OFEK", {"country": "Israel", "mission": "military_recon", "sat_type": "Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/Ofeq"}), + ("CSO", {"country": "France", "mission": "military_recon", "sat_type": "Optical Reconnaissance", "wiki": "https://en.wikipedia.org/wiki/CSO_(satellite)"}), + ("IGS", {"country": "Japan", "mission": "military_recon", "sat_type": "Intelligence Gathering", "wiki": "https://en.wikipedia.org/wiki/Information_Gathering_Satellite"}), + ("CAPELLA", {"country": "USA", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/Capella_Space"}), + ("ICEYE", {"country": "Finland", "mission": "sar", "sat_type": "SAR Microsatellite", "wiki": "https://en.wikipedia.org/wiki/ICEYE"}), + ("COSMO-SKYMED", {"country": "Italy", "mission": "sar", "sat_type": "SAR Constellation", "wiki": "https://en.wikipedia.org/wiki/COSMO-SkyMed"}), + ("TANDEM", {"country": "Germany", "mission": "sar", "sat_type": "SAR Interferometry", "wiki": "https://en.wikipedia.org/wiki/TanDEM-X"}), + ("PAZ", {"country": "Spain", "mission": "sar", "sat_type": "SAR Imaging", "wiki": "https://en.wikipedia.org/wiki/PAZ_(satellite)"}), + ("WORLDVIEW", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar High-Res", "wiki": "https://en.wikipedia.org/wiki/WorldView-3"}), + ("GEOEYE", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Maxar Imaging", "wiki": "https://en.wikipedia.org/wiki/GeoEye-1"}), + ("PLEIADES", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Imaging", "wiki": "https://en.wikipedia.org/wiki/Pl%C3%A9iades_(satellite)"}), + ("SPOT", {"country": "France", "mission": "commercial_imaging", "sat_type": "Airbus Medium-Res", "wiki": "https://en.wikipedia.org/wiki/SPOT_(satellite)"}), + ("PLANET", {"country": "USA", "mission": "commercial_imaging", "sat_type": "PlanetScope", "wiki": "https://en.wikipedia.org/wiki/Planet_Labs"}), + ("SKYSAT", {"country": "USA", "mission": "commercial_imaging", "sat_type": "Planet Video", "wiki": "https://en.wikipedia.org/wiki/SkySat"}), + ("BLACKSKY", {"country": "USA", "mission": "commercial_imaging", "sat_type": "BlackSky Imaging", "wiki": "https://en.wikipedia.org/wiki/BlackSky"}), + ("NROL", {"country": "USA", "mission": "sigint", "sat_type": "Classified NRO", "wiki": "https://en.wikipedia.org/wiki/National_Reconnaissance_Office"}), + ("MENTOR", {"country": "USA", "mission": "sigint", "sat_type": "SIGINT / ELINT", "wiki": "https://en.wikipedia.org/wiki/Mentor_(satellite)"}), + ("LUCH", {"country": "Russia", "mission": "sigint", "sat_type": "Relay / SIGINT", "wiki": "https://en.wikipedia.org/wiki/Luch_(satellite)"}), + ("SHIJIAN", {"country": "China", "mission": "sigint", "sat_type": "ELINT / Tech Demo", "wiki": "https://en.wikipedia.org/wiki/Shijian"}), + ("NAVSTAR", {"country": "USA", "mission": "navigation", "sat_type": "GPS", "wiki": "https://en.wikipedia.org/wiki/GPS_satellite_blocks"}), + ("GLONASS", {"country": "Russia", "mission": "navigation", "sat_type": "GLONASS", "wiki": "https://en.wikipedia.org/wiki/GLONASS"}), + ("BEIDOU", {"country": "China", "mission": "navigation", "sat_type": "BeiDou", "wiki": "https://en.wikipedia.org/wiki/BeiDou"}), + ("GALILEO", {"country": "EU", "mission": "navigation", "sat_type": "Galileo", "wiki": "https://en.wikipedia.org/wiki/Galileo_(satellite_navigation)"}), + ("SBIRS", {"country": "USA", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Space-Based_Infrared_System"}), + ("TUNDRA", {"country": "Russia", "mission": "early_warning", "sat_type": "Missile Warning", "wiki": "https://en.wikipedia.org/wiki/Tundra_(satellite)"}), + ("ISS", {"country": "Intl", "mission": "space_station", "sat_type": "Space Station", "wiki": "https://en.wikipedia.org/wiki/International_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.""" + try: + 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()) + 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 + 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 + 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.""" + search_terms = set() + for key, _ in _SAT_INTEL_DB: + term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key + search_terms.add(term) + + def _fetch_term(term): + results = [] + try: + url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json" + response = fetch_with_curl(url, timeout=8) + if response.status_code != 200: + return results + data = response.json() + for member in data.get("member", []): + gp = _parse_tle_to_gp( + member.get("name", "UNKNOWN"), + member.get("satelliteId"), + member.get("line1", ""), + member.get("line2", ""), + ) + if gp: + results.append(gp) + except Exception as e: + logger.debug(f"TLE fallback search '{term}' failed: {e}") + return results + + all_results = [] + seen_ids = set() + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + future_map = {executor.submit(_fetch_term, term): term for term in search_terms} + for future in concurrent.futures.as_completed(future_map): + for gp in future.result(): + sat_id = gp.get("NORAD_CAT_ID") + if sat_id not in seen_ids: + seen_ids.add(sat_id) + all_results.append(gp) + + return all_results + + +def fetch_satellites(): + sats = [] + try: + now_ts = time.time() + if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800: + gp_urls = [ + "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json", + "https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json", + ] + for url in gp_urls: + try: + response = fetch_with_curl(url, timeout=5) + 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 + _sat_gp_cache["source"] = "celestrak" + _save_sat_cache(gp_data) + 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 + + 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 + _sat_gp_cache["source"] = "tle_api" + _save_sat_cache(fallback_data) + 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}") + + if _sat_gp_cache["data"] is None: + disk_data = _load_sat_cache() + if disk_data: + _sat_gp_cache["data"] = disk_data + _sat_gp_cache["last_fetch"] = now_ts - 1500 + _sat_gp_cache["source"] = "disk_cache" + + data = _sat_gp_cache["data"] + if not data: + logger.warning("No satellite GP data available from any source") + with _data_lock: + latest_data["satellites"] = sats + return + + if _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"] and _sat_classified_cache["data"]: + classified = _sat_classified_cache["data"] + logger.info(f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)") + else: + classified = [] + for sat in data: + name = sat.get("OBJECT_NAME", "UNKNOWN").upper() + intel = None + for key, meta in _SAT_INTEL_DB: + if key.upper() in name: + intel = dict(meta) + break + if not intel: + continue + entry = { + "id": sat.get("NORAD_CAT_ID"), + "name": sat.get("OBJECT_NAME", "UNKNOWN"), + "MEAN_MOTION": sat.get("MEAN_MOTION"), + "ECCENTRICITY": sat.get("ECCENTRICITY"), + "INCLINATION": sat.get("INCLINATION"), + "RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"), + "ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"), + "MEAN_ANOMALY": sat.get("MEAN_ANOMALY"), + "BSTAR": sat.get("BSTAR"), + "EPOCH": sat.get("EPOCH"), + } + entry.update(intel) + classified.append(entry) + _sat_classified_cache["data"] = classified + _sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"] + logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog") + + all_sats = classified + + now = datetime.utcnow() + jd, fr = jday(now.year, now.month, now.day, now.hour, now.minute, now.second + now.microsecond / 1e6) + + for s in all_sats: + try: + mean_motion = s.get('MEAN_MOTION') + ecc = s.get('ECCENTRICITY') + incl = s.get('INCLINATION') + raan = s.get('RA_OF_ASC_NODE') + argp = s.get('ARG_OF_PERICENTER') + ma = s.get('MEAN_ANOMALY') + bstar = s.get('BSTAR', 0) + epoch_str = s.get('EPOCH') + norad_id = s.get('id', 0) + + if mean_motion is None or ecc is None or incl is None: + continue + + epoch_dt = datetime.strptime(epoch_str[:19], '%Y-%m-%dT%H:%M:%S') + epoch_jd, epoch_fr = jday(epoch_dt.year, epoch_dt.month, epoch_dt.day, + epoch_dt.hour, epoch_dt.minute, epoch_dt.second) + + sat_obj = Satrec() + sat_obj.sgp4init( + WGS72, 'i', norad_id, + (epoch_jd + epoch_fr) - 2433281.5, + bstar, 0.0, 0.0, ecc, + math.radians(argp), math.radians(incl), + math.radians(ma), + mean_motion * 2 * math.pi / 1440.0, + math.radians(raan) + ) + + e, r, v = sat_obj.sgp4(jd, fr) + if e != 0: + continue + + x, y, z = r + gmst = _gmst(jd + fr) + lng_rad = math.atan2(y, x) - gmst + lat_rad = math.atan2(z, math.sqrt(x*x + y*y)) + alt_km = math.sqrt(x*x + y*y + z*z) - 6371.0 + + s['lat'] = round(math.degrees(lat_rad), 4) + lng_deg = math.degrees(lng_rad) % 360 + s['lng'] = round(lng_deg - 360 if lng_deg > 180 else lng_deg, 4) + s['alt_km'] = round(alt_km, 1) + + vx, vy, vz = v + omega_e = 7.2921159e-5 + vx_g = vx + omega_e * y + vy_g = vy - omega_e * x + vz_g = vz + cos_lat = math.cos(lat_rad) + sin_lat = math.sin(lat_rad) + cos_lng = math.cos(lng_rad + gmst) + sin_lng = math.sin(lng_rad + gmst) + v_east = -sin_lng * vx_g + cos_lng * vy_g + v_north = -sin_lat * cos_lng * vx_g - sin_lat * sin_lng * vy_g + cos_lat * vz_g + ground_speed_kms = math.sqrt(v_east**2 + v_north**2) + s['speed_knots'] = round(ground_speed_kms * 1943.84, 1) + heading_rad = math.atan2(v_east, v_north) + s['heading'] = round(math.degrees(heading_rad) % 360, 1) + sat_name = s.get('name', '') + usa_match = re.search(r'USA[\s\-]*(\d+)', sat_name) + if usa_match: + s['wiki'] = f"https://en.wikipedia.org/wiki/USA-{usa_match.group(1)}" + for k in ('MEAN_MOTION', 'ECCENTRICITY', 'INCLINATION', + 'RA_OF_ASC_NODE', 'ARG_OF_PERICENTER', 'MEAN_ANOMALY', + 'BSTAR', 'EPOCH', 'tle1', 'tle2'): + s.pop(k, None) + sats.append(s) + except Exception: + continue + + logger.info(f"Satellites: {len(classified)} classified, {len(sats)} positioned") + except Exception as e: + logger.error(f"Error fetching satellites: {e}") + if sats: + with _data_lock: + latest_data["satellites"] = sats + latest_data["satellite_source"] = _sat_gp_cache.get("source", "none") + _mark_fresh("satellites") + else: + with _data_lock: + if not latest_data.get("satellites"): + latest_data["satellites"] = [] + latest_data["satellite_source"] = "none" diff --git a/backend/services/geopolitics.py b/backend/services/geopolitics.py index aebfb7d..32cf50f 100644 --- a/backend/services/geopolitics.py +++ b/backend/services/geopolitics.py @@ -1,5 +1,6 @@ import requests import logging +import zipfile from cachetools import cached, TTLCache from datetime import datetime from services.network_utils import fetch_with_curl @@ -65,7 +66,7 @@ def fetch_ukraine_frontlines(): logger.error(f"Failed to fetch parsed Github Raw GeoJSON: {res_geo.status_code}") else: logger.error(f"Failed to fetch Github Tree for Deepstatemap: {res_tree.status_code}") - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: logger.error(f"Error fetching DeepStateMap: {e}") return None @@ -81,7 +82,7 @@ def _extract_domain(url): if host.startswith('www.'): host = host[4:] return host - except Exception: + except (ValueError, AttributeError, KeyError): # non-critical return url[:40] def _url_to_headline(url): @@ -137,7 +138,7 @@ def _url_to_headline(url): if len(headline) > 90: headline = headline[:87] + '...' return headline - except Exception: + except (ValueError, AttributeError, KeyError): # non-critical return url[:60] @@ -226,7 +227,7 @@ def _fetch_article_title(url): _article_title_cache[url] = None return None - except Exception: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, AttributeError): # non-critical _article_title_cache[url] = None return None @@ -242,7 +243,7 @@ def _batch_fetch_titles(urls): url = futures[future] try: results[url] = future.result() - except Exception: + except Exception: # non-critical: optional title enrichment results[url] = None return results @@ -308,7 +309,7 @@ def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_ }) except (ValueError, IndexError): continue - except Exception as e: + except (IOError, OSError, ValueError, KeyError, zipfile.BadZipFile) as e: logger.warning(f"Failed to parse GDELT export zip: {e}") def _download_gdelt_export(url): @@ -317,16 +318,72 @@ def _download_gdelt_export(url): res = fetch_with_curl(url, timeout=15) if res.status_code == 200: return res.content - except Exception: + except (ConnectionError, TimeoutError, OSError): # non-critical pass return None -@cached(gdelt_cache) +def _build_feature_html(features, fetched_titles=None): + """Build URL + headline arrays for frontend rendering. + Uses fetched_titles (real article titles) when available, falls back to URL slug parsing.""" + import html as html_mod + for f in features: + urls = f["properties"].pop("_urls", []) + f["properties"].pop("_domains", None) + headlines = [] + for u in urls: + real_title = fetched_titles.get(u) if fetched_titles else None + headlines.append(real_title if real_title else _url_to_headline(u)) + f["properties"]["_urls_list"] = urls + f["properties"]["_headlines_list"] = headlines + if urls: + links = [] + for u, h in zip(urls, headlines): + safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank' + safe_h = html_mod.escape(h) + links.append(f'
{safe_h}
') + f["properties"]["html"] = ''.join(links) + else: + f["properties"]["html"] = html_mod.escape(f["properties"]["name"]) + f.pop("_loc_key", None) + + +def _enrich_gdelt_titles_background(features, all_article_urls): + """Background thread: fetch real article titles then update features in-place.""" + import html as html_mod + try: + logger.info(f"[BG] Fetching real article titles for {len(all_article_urls)} URLs...") + fetched_titles = _batch_fetch_titles(all_article_urls) + fetched_count = sum(1 for v in fetched_titles.values() if v) + logger.info(f"[BG] Resolved {fetched_count}/{len(all_article_urls)} article titles") + + # Update features in-place with real titles + for f in features: + urls = f["properties"].get("_urls_list", []) + if not urls: + continue + headlines = [] + for u in urls: + real_title = fetched_titles.get(u) + headlines.append(real_title if real_title else _url_to_headline(u)) + f["properties"]["_headlines_list"] = headlines + links = [] + for u, h in zip(urls, headlines): + safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank' + safe_h = html_mod.escape(h) + links.append(f'
{safe_h}
') + f["properties"]["html"] = ''.join(links) + logger.info(f"[BG] GDELT title enrichment complete") + except Exception as e: + logger.error(f"[BG] GDELT title enrichment failed: {e}") + + def fetch_global_military_incidents(): """ Fetches global military/conflict incidents from GDELT Events Export files. Aggregates the last ~8 hours of 15-minute exports to build ~1000 incidents. + Returns immediately with URL-slug headlines; enriches with real titles in background. """ + import threading from datetime import timedelta from concurrent.futures import ThreadPoolExecutor @@ -388,45 +445,29 @@ def fetch_global_military_incidents(): if zip_bytes: _parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index) - # Collect all unique article URLs for batch title fetching + # Collect all unique article URLs all_article_urls = set() for f in features: for u in f["properties"].get("_urls", []): if u: all_article_urls.add(u) - - logger.info(f"Fetching real article titles for {len(all_article_urls)} unique URLs...") - fetched_titles = _batch_fetch_titles(all_article_urls) - fetched_count = sum(1 for v in fetched_titles.values() if v) - logger.info(f"Resolved {fetched_count}/{len(all_article_urls)} article titles from HTML") - # Build URL + headline arrays for frontend rendering - for f in features: - urls = f["properties"].pop("_urls", []) - f["properties"].pop("_domains", None) - headlines = [] - for u in urls: - # Try the real fetched title first, then fall back to URL slug parsing - real_title = fetched_titles.get(u) - headlines.append(real_title if real_title else _url_to_headline(u)) - f["properties"]["_urls_list"] = urls - f["properties"]["_headlines_list"] = headlines - import html - # Keep html as fallback - if urls: - links = [] - for u, h in zip(urls, headlines): - safe_url = u if u.startswith(('http://', 'https://')) else 'about:blank' - safe_h = html.escape(h) - links.append(f'
{safe_h}
') - f["properties"]["html"] = ''.join(links) - else: - f["properties"]["html"] = html.escape(f["properties"]["name"]) - f.pop("_loc_key", None) + # Build HTML immediately with URL-slug headlines (instant, no network) + _build_feature_html(features) + + logger.info(f"GDELT parsed: {len(features)} conflict locations from {successful} files (titles enriching in background)") + + # Kick off background thread to enrich with real article titles + # Features list is shared — background thread updates in-place + t = threading.Thread( + target=_enrich_gdelt_titles_background, + args=(features, all_article_urls), + daemon=True, + ) + t.start() - logger.info(f"GDELT multi-file parsed: {len(features)} conflict locations from {successful} files") return features - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e: logger.error(f"Error fetching GDELT data: {e}") return [] diff --git a/backend/services/kiwisdr_fetcher.py b/backend/services/kiwisdr_fetcher.py index 3939085..0f1fa9d 100644 --- a/backend/services/kiwisdr_fetcher.py +++ b/backend/services/kiwisdr_fetcher.py @@ -6,6 +6,7 @@ Data is embedded as HTML comments inside each entry div. import re import logging +import requests from cachetools import TTLCache, cached logger = logging.getLogger(__name__) @@ -92,6 +93,6 @@ def fetch_kiwisdr_nodes() -> list[dict]: logger.info(f"KiwiSDR: parsed {len(nodes)} online receivers") return nodes - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: logger.error(f"KiwiSDR fetch exception: {e}") return [] diff --git a/backend/services/liveuamap_scraper.py b/backend/services/liveuamap_scraper.py index da760c9..7f9c4de 100644 --- a/backend/services/liveuamap_scraper.py +++ b/backend/services/liveuamap_scraper.py @@ -23,7 +23,7 @@ def fetch_liveuamap(): with sync_playwright() as p: # Launching with a real user agent to bypass Turnstile - browser = p.chromium.launch(headless=False, args=["--disable-blink-features=AutomationControlled"]) + browser = p.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"]) context = browser.new_context( 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", viewport={"width": 1920, "height": 1080}, @@ -40,7 +40,7 @@ def fetch_liveuamap(): # Wait for the map canvas or markers script to load, max 10s wait try: page.wait_for_timeout(5000) - except: + except (TimeoutError, OSError): # non-critical: page load delay pass html = page.content() @@ -56,8 +56,8 @@ def fetch_liveuamap(): # process below html = f"var ovens={ovens_json};" m = re.search(r"var\s+ovens=(.*?);", html, re.DOTALL) - except: - pass + except (ValueError, KeyError, OSError) as e: # non-critical: JS eval fallback + logger.debug(f"Could not evaluate ovens JS variable for {region['name']}: {e}") if m: json_str = m.group(1).strip() @@ -81,7 +81,7 @@ def fetch_liveuamap(): "link": marker.get("link", region["url"]), "region": region["name"] }) - except Exception as e: + except (json.JSONDecodeError, ValueError, KeyError) as e: logger.error(f"Error parsing JSON for {region['name']}: {e}") except Exception as e: diff --git a/backend/services/network_utils.py b/backend/services/network_utils.py index 10ad6fc..e2f7e81 100644 --- a/backend/services/network_utils.py +++ b/backend/services/network_utils.py @@ -10,9 +10,10 @@ from urllib3.util.retry import Retry logger = logging.getLogger(__name__) -# Reusable session with connection pooling and retry logic +# Reusable session with connection pooling and retry logic. +# Only retry once (total=1) to fail fast — the curl fallback is the real safety net. _session = requests.Session() -_retry = Retry(total=2, backoff_factor=0.5, status_forcelist=[502, 503, 504]) +_retry = Retry(total=1, backoff_factor=0.3, 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)) @@ -68,16 +69,19 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None) pass # Fall through to curl below else: try: + # Use a short connect timeout (3s) so firewall blocks fail fast, + # but allow the full timeout for reading the response body. + req_timeout = (min(3, timeout), timeout) if method == "POST": - res = _session.post(url, json=json_data, timeout=timeout, headers=default_headers) + res = _session.post(url, json=json_data, timeout=req_timeout, headers=default_headers) else: - res = _session.get(url, timeout=timeout, headers=default_headers) + res = _session.get(url, timeout=req_timeout, headers=default_headers) res.raise_for_status() # Clear failure caches on success _domain_fail_cache.pop(domain, None) _circuit_breaker.pop(domain, None) return res - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, OSError) as e: logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...") _domain_fail_cache[domain] = time.time() @@ -109,7 +113,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None) logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}") _circuit_breaker[domain] = time.time() return _DummyResponse(500, "") - except Exception as curl_e: + except (subprocess.SubprocessError, ConnectionError, TimeoutError, OSError) as curl_e: logger.error(f"bash curl fallback exception: {curl_e}") _circuit_breaker[domain] = time.time() return _DummyResponse(500, "") diff --git a/backend/services/news_feed_config.py b/backend/services/news_feed_config.py index 33346c2..b26ba1e 100644 --- a/backend/services/news_feed_config.py +++ b/backend/services/news_feed_config.py @@ -31,7 +31,7 @@ def get_feeds() -> list[dict]: feeds = data.get("feeds", []) if isinstance(data, dict) else data if isinstance(feeds, list) and len(feeds) > 0: return feeds - except Exception as e: + except (IOError, OSError, json.JSONDecodeError, ValueError) as e: logger.warning(f"Failed to read news feed config: {e}") return list(DEFAULT_FEEDS) @@ -64,7 +64,7 @@ def save_feeds(feeds: list[dict]) -> bool: encoding="utf-8", ) return True - except Exception as e: + except (IOError, OSError) as e: logger.error(f"Failed to write news feed config: {e}") return False diff --git a/backend/services/radio_intercept.py b/backend/services/radio_intercept.py index cba2656..66bbe1b 100644 --- a/backend/services/radio_intercept.py +++ b/backend/services/radio_intercept.py @@ -72,7 +72,7 @@ def get_top_broadcastify_feeds(): logger.info(f"Successfully scraped {len(feeds)} top feeds from Broadcastify.") return feeds - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: logger.error(f"Broadcastify Scrape Exception: {e}") return [] @@ -92,7 +92,7 @@ def get_openmhz_systems(): # Return list of systems return data.get('systems', []) if isinstance(data, dict) else [] return [] - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: logger.error(f"OpenMHZ Systems Scrape Exception: {e}") return [] @@ -112,7 +112,7 @@ def get_recent_openmhz_calls(sys_name: str): data = res.json() return data.get('calls', []) if isinstance(data, dict) else [] return [] - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError, KeyError) as e: logger.error(f"OpenMHZ Calls Scrape Exception ({sys_name}): {e}") return [] diff --git a/backend/services/region_dossier.py b/backend/services/region_dossier.py index 48474c9..b3f7017 100644 --- a/backend/services/region_dossier.py +++ b/backend/services/region_dossier.py @@ -50,7 +50,7 @@ def _reverse_geocode(lat: float, lng: float) -> dict: continue else: logger.warning(f"Nominatim returned {res.status_code}") - except Exception as e: + except (_requests.RequestException, ConnectionError, TimeoutError, OSError) as e: logger.warning(f"Reverse geocode failed: {e}") return {} @@ -66,7 +66,7 @@ def _fetch_country_data(country_code: str) -> dict: res = fetch_with_curl(url, timeout=10) if res.status_code == 200: return res.json() - except Exception as e: + except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e: logger.warning(f"RestCountries failed for {country_code}: {e}") return {} @@ -96,7 +96,7 @@ def _fetch_wikidata_leader(country_name: str) -> dict: "leader": r.get("leaderLabel", {}).get("value", "Unknown"), "government_type": r.get("govTypeLabel", {}).get("value", "Unknown"), } - except Exception as e: + except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e: logger.warning(f"Wikidata SPARQL failed for {country_name}: {e}") return {"leader": "Unknown", "government_type": "Unknown"} @@ -122,7 +122,7 @@ def _fetch_local_wiki_summary(place_name: str, country_name: str = "") -> dict: "extract": data.get("extract", ""), "thumbnail": data.get("thumbnail", {}).get("source", ""), } - except Exception: + except (ConnectionError, TimeoutError, ValueError, KeyError, OSError): # Intentional: optional enrichment continue return {} @@ -158,22 +158,22 @@ def get_region_dossier(lat: float, lng: float) -> dict: try: country_data = country_fut.result(timeout=12) - except Exception: + except Exception: # Intentional: optional enrichment logger.warning("Country data fetch timed out or failed") country_data = {} try: leader_data = leader_fut.result(timeout=12) - except Exception: + except Exception: # Intentional: optional enrichment logger.warning("Leader data fetch timed out or failed") leader_data = {"leader": "Unknown", "government_type": "Unknown"} try: local_data = local_fut.result(timeout=12) - except Exception: + except Exception: # Intentional: optional enrichment logger.warning("Local wiki fetch timed out or failed") local_data = {} try: country_wiki_data = country_wiki_fut.result(timeout=12) - except Exception: + except Exception: # Intentional: optional enrichment country_wiki_data = {} # If no local data but we have country wiki summary, use that diff --git a/backend/services/sentinel_search.py b/backend/services/sentinel_search.py index ab5130d..741fcec 100644 --- a/backend/services/sentinel_search.py +++ b/backend/services/sentinel_search.py @@ -4,6 +4,7 @@ Free, keyless search for metadata + thumbnails. Used in the right-click dossier. """ import logging +import requests from datetime import datetime, timedelta from cachetools import TTLCache @@ -48,7 +49,7 @@ def search_sentinel2_scene(lat: float, lng: float) -> dict: item = planetary_computer.sign_item(item) except ImportError: pass # planetary_computer not installed, try unsigned URLs - except Exception as e: + except (ConnectionError, TimeoutError, ValueError) as e: logger.warning(f"Sentinel-2 signing failed: {e}") # Get the rendered_preview (full-res PNG) and thumbnail separately @@ -76,6 +77,6 @@ def search_sentinel2_scene(lat: float, lng: float) -> dict: except ImportError: logger.warning("pystac-client not installed — Sentinel-2 search unavailable") return {"found": False, "error": "pystac-client not installed"} - except Exception as e: + except (requests.RequestException, ConnectionError, TimeoutError, ValueError) as e: logger.error(f"Sentinel-2 search failed for ({lat}, {lng}): {e}") return {"found": False, "error": str(e)} diff --git a/backend/services/updater.py b/backend/services/updater.py new file mode 100644 index 0000000..fe6e537 --- /dev/null +++ b/backend/services/updater.py @@ -0,0 +1,257 @@ +"""Self-update module — downloads latest GitHub release, backs up current files, +extracts the update over the project, and restarts the app. + +Public API: + perform_update(project_root) -> dict (download + backup + extract) + schedule_restart(project_root) (spawn detached start script, then exit) +""" +import os +import sys +import logging +import shutil +import subprocess +import tempfile +import time +import zipfile +from datetime import datetime +from pathlib import Path + +import requests + +logger = logging.getLogger(__name__) + +GITHUB_RELEASES_URL = "https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest" + +# --------------------------------------------------------------------------- +# Protected patterns — files/dirs that must NEVER be overwritten during update +# --------------------------------------------------------------------------- +_PROTECTED_DIRS = {"venv", "node_modules", ".next", "__pycache__", ".git"} +_PROTECTED_EXTENSIONS = {".db", ".sqlite"} +_PROTECTED_NAMES = { + ".env", + "ais_cache.json", + "carrier_cache.json", + "geocode_cache.json", +} + + +def _is_protected(rel_path: str) -> bool: + """Return True if *rel_path* (forward-slash separated) should be skipped.""" + parts = rel_path.replace("\\", "/").split("/") + name = parts[-1] + + # Check directory components + for part in parts[:-1]: + if part in _PROTECTED_DIRS: + return True + + # Check filename + if name in _PROTECTED_NAMES: + return True + _, ext = os.path.splitext(name) + if ext.lower() in _PROTECTED_EXTENSIONS: + return True + + return False + + +# --------------------------------------------------------------------------- +# Download +# --------------------------------------------------------------------------- +def _download_release(temp_dir: str) -> tuple: + """Fetch latest release info and download the zip asset. + Returns (zip_path, version_tag, download_url). + """ + logger.info("Fetching latest release info from GitHub...") + resp = requests.get(GITHUB_RELEASES_URL, timeout=15) + resp.raise_for_status() + release = resp.json() + + tag = release.get("tag_name", "unknown") + assets = release.get("assets", []) + + # Find the .zip asset + zip_url = None + for asset in assets: + url = asset.get("browser_download_url", "") + if url.endswith(".zip"): + zip_url = url + break + + if not zip_url: + raise RuntimeError("No .zip asset found in the latest release") + + logger.info(f"Downloading {zip_url} ...") + zip_path = os.path.join(temp_dir, "update.zip") + with requests.get(zip_url, stream=True, timeout=120) as dl: + dl.raise_for_status() + with open(zip_path, "wb") as f: + for chunk in dl.iter_content(chunk_size=1024 * 64): + f.write(chunk) + + if not zipfile.is_zipfile(zip_path): + raise RuntimeError("Downloaded file is not a valid ZIP archive") + + size_mb = os.path.getsize(zip_path) / (1024 * 1024) + logger.info(f"Downloaded {size_mb:.1f} MB — ZIP validated OK") + return zip_path, tag, zip_url + + +# --------------------------------------------------------------------------- +# Backup +# --------------------------------------------------------------------------- +def _backup_current(project_root: str, temp_dir: str) -> str: + """Create a backup zip of backend/ and frontend/ in temp_dir.""" + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = os.path.join(temp_dir, f"backup_{stamp}.zip") + logger.info(f"Backing up current files to {backup_path} ...") + + dirs_to_backup = ["backend", "frontend"] + count = 0 + + with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zf: + for dir_name in dirs_to_backup: + dir_path = os.path.join(project_root, dir_name) + if not os.path.isdir(dir_path): + continue + for root, dirs, files in os.walk(dir_path): + # Prune protected directories from walk + dirs[:] = [d for d in dirs if d not in _PROTECTED_DIRS] + for fname in files: + full = os.path.join(root, fname) + rel = os.path.relpath(full, project_root) + if _is_protected(rel): + continue + try: + zf.write(full, rel) + count += 1 + except (PermissionError, OSError) as e: + logger.warning(f"Backup skip (locked): {rel} — {e}") + + logger.info(f"Backup complete: {count} files archived") + return backup_path + + +# --------------------------------------------------------------------------- +# Extract & Copy +# --------------------------------------------------------------------------- +def _extract_and_copy(zip_path: str, project_root: str, temp_dir: str) -> int: + """Extract the update zip and copy files over the project, skipping protected files. + Returns count of files copied. + """ + extract_dir = os.path.join(temp_dir, "extracted") + logger.info("Extracting update zip...") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + + # Detect wrapper folder: if extracted root has a single directory that + # itself contains frontend/ or backend/, use it as the real base. + base = extract_dir + entries = [e for e in os.listdir(base) if not e.startswith(".")] + if len(entries) == 1: + candidate = os.path.join(base, entries[0]) + if os.path.isdir(candidate): + sub = os.listdir(candidate) + if "frontend" in sub or "backend" in sub: + base = candidate + logger.info(f"Detected wrapper folder: {entries[0]}") + + copied = 0 + skipped = 0 + + for root, _dirs, files in os.walk(base): + for fname in files: + src = os.path.join(root, fname) + rel = os.path.relpath(src, base).replace("\\", "/") + + if _is_protected(rel): + skipped += 1 + continue + + dst = os.path.join(project_root, rel) + os.makedirs(os.path.dirname(dst), exist_ok=True) + try: + shutil.copy2(src, dst) + copied += 1 + except (PermissionError, OSError) as e: + logger.warning(f"Copy failed (skipping): {rel} — {e}") + skipped += 1 + + logger.info(f"Update applied: {copied} files copied, {skipped} skipped/protected") + return copied + + +# --------------------------------------------------------------------------- +# Restart +# --------------------------------------------------------------------------- +def schedule_restart(project_root: str): + """Spawn a detached process that re-runs start.bat / start.sh after a short + delay, then forcefully exit the current Python process.""" + tmp = tempfile.mkdtemp(prefix="sb_restart_") + + if sys.platform == "win32": + script = os.path.join(tmp, "restart.bat") + with open(script, "w") as f: + f.write("@echo off\n") + f.write("timeout /t 3 /nobreak >nul\n") + f.write(f'cd /d "{project_root}"\n') + f.write("call start.bat\n") + + CREATE_NEW_PROCESS_GROUP = 0x00000200 + DETACHED_PROCESS = 0x00000008 + subprocess.Popen( + ["cmd", "/c", script], + creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, + close_fds=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + script = os.path.join(tmp, "restart.sh") + with open(script, "w") as f: + f.write("#!/bin/bash\n") + f.write("sleep 3\n") + f.write(f'cd "{project_root}"\n') + f.write("bash start.sh\n") + os.chmod(script, 0o755) + subprocess.Popen( + ["bash", script], + start_new_session=True, + close_fds=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + logger.info("Restart script spawned — exiting current process") + os._exit(0) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- +def perform_update(project_root: str) -> dict: + """Download the latest release, back up current files, and extract the update. + + Returns a dict with status info on success, or {"status": "error", "message": ...} + on failure. Does NOT trigger restart — caller should call schedule_restart() + separately after the HTTP response has been sent. + """ + temp_dir = tempfile.mkdtemp(prefix="sb_update_") + try: + zip_path, version, url = _download_release(temp_dir) + backup_path = _backup_current(project_root, temp_dir) + copied = _extract_and_copy(zip_path, project_root, temp_dir) + + return { + "status": "ok", + "version": version, + "files_updated": copied, + "backup_path": backup_path, + "message": f"Updated to {version} — {copied} files replaced. Restarting...", + } + except Exception as e: + logger.error(f"Update failed: {e}", exc_info=True) + return { + "status": "error", + "message": str(e), + } diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..909bd76 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import patch, MagicMock + + +@pytest.fixture(autouse=True) +def _suppress_background_services(): + """Prevent real scheduler/stream/tracker from starting during tests.""" + with patch("services.data_fetcher.start_scheduler"), \ + patch("services.data_fetcher.stop_scheduler"), \ + patch("services.ais_stream.start_ais_stream"), \ + patch("services.ais_stream.stop_ais_stream"), \ + patch("services.carrier_tracker.start_carrier_tracker"), \ + patch("services.carrier_tracker.stop_carrier_tracker"): + yield + + +@pytest.fixture() +def client(_suppress_background_services): + """HTTPX test client against the FastAPI app (no real network).""" + from httpx import ASGITransport, AsyncClient + from main import app + import asyncio + + transport = ASGITransport(app=app) + + async def _make_client(): + async with AsyncClient(transport=transport, base_url="http://test") as ac: + return ac + + # Return a sync-usable wrapper + class SyncClient: + def __init__(self): + self._loop = asyncio.new_event_loop() + self._transport = ASGITransport(app=app) + + def get(self, url, **kw): + return self._loop.run_until_complete(self._get(url, **kw)) + + async def _get(self, url, **kw): + async with AsyncClient(transport=self._transport, base_url="http://test") as ac: + return await ac.get(url, **kw) + + def put(self, url, **kw): + return self._loop.run_until_complete(self._put(url, **kw)) + + async def _put(self, url, **kw): + async with AsyncClient(transport=self._transport, base_url="http://test") as ac: + return await ac.put(url, **kw) + + return SyncClient() diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py new file mode 100644 index 0000000..1ec510c --- /dev/null +++ b/backend/tests/test_api_smoke.py @@ -0,0 +1,114 @@ +"""Smoke tests for all API endpoints — verifies routes exist and return valid responses.""" +import pytest + + +class TestHealthEndpoint: + def test_health_returns_200(self, client): + r = client.get("/api/health") + assert r.status_code == 200 + data = r.json() + assert data["status"] == "ok" + assert "sources" in data + assert "freshness" in data + + def test_health_has_uptime(self, client): + r = client.get("/api/health") + data = r.json() + assert "uptime_seconds" in data + assert isinstance(data["uptime_seconds"], (int, float)) + + +class TestLiveDataEndpoints: + def test_live_data_returns_200(self, client): + r = client.get("/api/live-data") + assert r.status_code == 200 + + def test_live_data_fast_returns_200_or_304(self, client): + r = client.get("/api/live-data/fast") + assert r.status_code in (200, 304) + if r.status_code == 200: + data = r.json() + assert "freshness" in data + + def test_live_data_slow_returns_200_or_304(self, client): + r = client.get("/api/live-data/slow") + assert r.status_code in (200, 304) + if r.status_code == 200: + data = r.json() + assert "freshness" in data + + def test_fast_has_expected_keys(self, client): + r = client.get("/api/live-data/fast") + if r.status_code == 200: + data = r.json() + for key in ("commercial_flights", "military_flights", "ships", "satellites"): + assert key in data, f"Missing key: {key}" + + def test_slow_has_expected_keys(self, client): + r = client.get("/api/live-data/slow") + if r.status_code == 200: + data = r.json() + for key in ("news", "stocks", "weather", "earthquakes"): + assert key in data, f"Missing key: {key}" + + +class TestDebugEndpoint: + def test_debug_latest_returns_list(self, client): + r = client.get("/api/debug-latest") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + + +class TestSettingsEndpoints: + def test_get_api_keys(self, client): + r = client.get("/api/settings/api-keys") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + + def test_get_news_feeds(self, client): + r = client.get("/api/settings/news-feeds") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + + +class TestRadioEndpoints: + def test_radio_top_returns_200(self, client): + r = client.get("/api/radio/top") + assert r.status_code == 200 + + def test_radio_openmhz_systems(self, client): + r = client.get("/api/radio/openmhz/systems") + assert r.status_code == 200 + + +class TestQueryValidation: + def test_region_dossier_rejects_invalid_lat(self, client): + r = client.get("/api/region-dossier?lat=999&lng=0") + assert r.status_code == 422 + + def test_region_dossier_rejects_invalid_lng(self, client): + r = client.get("/api/region-dossier?lat=0&lng=999") + assert r.status_code == 422 + + def test_sentinel_rejects_invalid_coords(self, client): + r = client.get("/api/sentinel2/search?lat=-100&lng=0") + assert r.status_code == 422 + + def test_radio_nearest_rejects_invalid_lat(self, client): + r = client.get("/api/radio/nearest?lat=91&lng=0") + assert r.status_code == 422 + + +class TestETagBehavior: + def test_fast_returns_etag_header(self, client): + r = client.get("/api/live-data/fast") + if r.status_code == 200: + assert "etag" in r.headers + + def test_slow_returns_etag_header(self, client): + r = client.get("/api/live-data/slow") + if r.status_code == 200: + assert "etag" in r.headers diff --git a/docker-compose.yml b/docker-compose.yml index 585024d..e48a410 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,6 @@ -version: '3.8' - services: backend: - build: + build: context: ./backend container_name: shadowbroker-backend ports: @@ -17,6 +15,17 @@ services: volumes: - backend_data:/app/data restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/live-data/fast"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + deploy: + resources: + limits: + memory: 2G + cpus: '2' frontend: build: @@ -29,8 +38,20 @@ services: # Change this if your backend runs on a different host or port. - BACKEND_URL=http://backend:8000 depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + deploy: + resources: + limits: + memory: 512M + cpus: '1' volumes: backend_data: diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 6741f71..6e52e10 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -8,12 +8,6 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'], output: "standalone", - typescript: { - ignoreBuildErrors: true, - }, - eslint: { - ignoreDuringBuilds: true, - }, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 321da81..0e89414 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.3.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.3.0", + "version": "0.8.0", "dependencies": { "@mapbox/point-geometry": "^1.1.0", "framer-motion": "^12.34.3", @@ -21,7 +21,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/mapbox__point-geometry": "^1.0.87", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/package.json b/frontend/package.json index 010291e..045d3ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.8.0", + "version": "0.9.0", "private": true, "scripts": { "dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"", @@ -24,7 +24,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/mapbox__point-geometry": "^1.0.87", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4a73fc5..1160c10 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -72,6 +72,36 @@ body { scrollbar-width: thin; } +/* Map popup shared utilities */ +.map-popup { + background: rgba(10, 14, 26, 0.95); + border-radius: 6px; + padding: 10px 14px; + color: #e0e6f0; + font-family: monospace; + font-size: 11px; + min-width: 220px; + max-width: 320px; +} + +.map-popup-title { + font-weight: 700; + font-size: 13px; + margin-bottom: 6px; + letter-spacing: 1px; +} + +.map-popup-row { + margin-bottom: 4px; +} + +.map-popup-subtitle { + font-size: 9px; + margin-bottom: 6px; + letter-spacing: 1.5px; + text-transform: uppercase; +} + /* MapLibre Popup Overrides */ .maplibregl-popup-content { background: transparent !important; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 612e10d..a5075e2 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -25,10 +25,7 @@ export default function RootLayout({ }>) { return ( - - - - +
{/* MAPLIBRE WEBGL OVERLAY */} @@ -435,10 +438,14 @@ export default function Dashboard() { {/* LEFT HUD CONTAINER */}
{/* LEFT PANEL - DATA LAYERS */} - setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} /> + + setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} /> + {/* LEFT BOTTOM - DISPLAY CONFIG */} - + + +
{/* RIGHT HUD CONTAINER */} @@ -466,29 +473,37 @@ export default function Dashboard() { {/* TOP RIGHT - MARKETS */}
- + + +
{/* SIGINT & RADIO INTERCEPTS */}
- + + +
{/* DATA FILTERS */}
- + + +
{/* BOTTOM RIGHT - NEWS FEED (fills remaining space) */}
- + + +
@@ -589,10 +604,14 @@ export default function Dashboard() {
{/* SETTINGS PANEL */} - setSettingsOpen(false)} /> + + setSettingsOpen(false)} /> + {/* MAP LEGEND */} - setLegendOpen(false)} /> + + setLegendOpen(false)} /> + {/* ONBOARDING MODAL */} {showOnboarding && ( @@ -617,5 +636,6 @@ export default function Dashboard() { )}
+ ); } diff --git a/frontend/src/components/CesiumViewer.tsx b/frontend/src/components/CesiumViewer.tsx deleted file mode 100644 index f565164..0000000 --- a/frontend/src/components/CesiumViewer.tsx +++ /dev/null @@ -1,1813 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import * as satellite from 'satellite.js'; - -export default function CesiumViewer({ data, activeLayers, activeFilters, effects, onEntityClick, selectedEntity, flyToLocation, isEavesdropping, onEavesdropClick, onCameraMove }: { data: any, activeLayers: any, activeFilters?: Record, effects: any, onEntityClick?: any, selectedEntity?: any, flyToLocation?: { lat: number, lng: number, ts: number } | null, isEavesdropping?: boolean, onEavesdropClick?: (loc: { lat: number, lng: number }) => void, onCameraMove?: (loc: { lat: number, lng: number }) => void }) { - const cesiumContainer = useRef(null); - const viewerRef = useRef(null); - const flightBillboardsRef = useRef(null); - const flightLabelsRef = useRef(null); - const flightPrimitivesRef = useRef>(new Map()); - const shipDataSourceRef = useRef(null); - const cctvDataSourceRef = useRef(null); - const [cesiumLoaded, setCesiumLoaded] = useState(false); - const [popupPosition, setPopupPosition] = useState<{ x: number, y: number } | null>(null); - - // Fly camera to a specific location when triggered by Find/Locate - useEffect(() => { - if (!flyToLocation || !viewerRef.current) return; - const Cesium = (window as any).Cesium; - if (!Cesium) return; - viewerRef.current.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(flyToLocation.lng, flyToLocation.lat, 50000), - orientation: { - heading: 0, - pitch: Cesium.Math.toRadians(-45), - roll: 0, - }, - duration: 2.0, - }); - }, [flyToLocation]); - - // Poll for the CDN script to finish downloading - useEffect(() => { - const interval = setInterval(() => { - if (typeof window !== "undefined" && (window as any).Cesium) { - // Configure base URL before initialization - (window as any).CESIUM_BASE_URL = "https://cesium.com/downloads/cesiumjs/releases/1.115/Build/Cesium/"; - setCesiumLoaded(true); - clearInterval(interval); - } - }, 100); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (!cesiumLoaded || !cesiumContainer.current || viewerRef.current) return; - - const Cesium = (window as any).Cesium; - - // Allow Cesium to use default credentials for its Ion assets if needed (we'll mostly bypass) - // Cesium.Ion.defaultAccessToken = 'YOUR_EXPERIMENTAL_OR_FREE_TOKEN'; - - viewerRef.current = new Cesium.Viewer(cesiumContainer.current, { - animation: false, - baseLayerPicker: false, - fullscreenButton: false, - geocoder: false, - homeButton: false, - infoBox: false, - sceneModePicker: false, - selectionIndicator: false, - timeline: false, - navigationHelpButton: false, - navigationInstructionsInitiallyVisible: false, - scene3DOnly: true, - skyAtmosphere: false, - skyBox: false, - // Automatically render when changes occur - requestRenderMode: false, - }); - - // Remove the default cesium credit banner for tactical purity - const credit = viewerRef.current.bottomContainer; - if (credit) credit.style.display = "none"; - - const scene = viewerRef.current.scene; - scene.globe.baseColor = Cesium.Color.BLACK; - - // High-resolution Satellite layer via Mapbox for a realistic earth - const mapboxToken = "YOUR_MAPBOX_TOKEN_HERE"; - const baseImageryProvider = new Cesium.UrlTemplateImageryProvider({ - // Using satellite-streets-v12 gives us country/state borders baked into the map - url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}?access_token=${mapboxToken}`, - credit: "" - }); - viewerRef.current.imageryLayers.removeAll(); - viewerRef.current.imageryLayers.addImageryProvider(baseImageryProvider); - - // CartoDB Dark Matter LABELS overlay removed to prevent duplication with Mapbox Streets - - // Google Photorealistic 3D Tiles removed to fix loading errors on localhost - - // Set initial camera view - viewerRef.current.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees(-95.0, 39.0, 20000000.0) - }); - // Add Google Photorealistic 3D Tiles if available, otherwise fallback to base - // ── Primitive Collections for Fast Rendering ── - const Cesium2 = (window as any).Cesium; - - const flightBillboards = new Cesium2.BillboardCollection({ disableDepthTestDistance: 1000000.0 }); - const flightLabels = new Cesium2.LabelCollection({ disableDepthTestDistance: 1000000.0 }); - viewerRef.current.scene.primitives.add(flightBillboards); - viewerRef.current.scene.primitives.add(flightLabels); - flightBillboardsRef.current = flightBillboards; - flightLabelsRef.current = flightLabels; - - const shipDS = new Cesium2.CustomDataSource('ships'); - shipDS.clustering.enabled = true; - shipDS.clustering.pixelRange = 40; - shipDS.clustering.minimumClusterSize = 3; - viewerRef.current.dataSources.add(shipDS); - shipDataSourceRef.current = shipDS; - - shipDS.clustering.clusterEvent.addEventListener((clusteredEntities: any[], cluster: any) => { - const count = clusteredEntities.length; - const radius = Math.min(10 + Math.log2(count) * 4, 30); - cluster.billboard.show = false; - cluster.label.show = true; - cluster.label.text = String(count); - cluster.label.font = `bold ${Math.max(10, Math.min(radius, 14))}px monospace`; - cluster.label.fillColor = Cesium2.Color.WHITE; - cluster.label.outlineColor = Cesium2.Color.BLACK; - cluster.label.outlineWidth = 2; - cluster.label.style = Cesium2.LabelStyle.FILL_AND_OUTLINE; - cluster.label.horizontalOrigin = Cesium2.HorizontalOrigin.CENTER; - cluster.label.verticalOrigin = Cesium2.VerticalOrigin.CENTER; - cluster.label.disableDepthTestDistance = 1000000.0; - cluster.point.show = true; - cluster.point.pixelSize = radius; - cluster.point.color = Cesium2.Color.fromCssColorString('rgba(0, 100, 255, 0.7)'); - cluster.point.outlineColor = Cesium2.Color.fromCssColorString('rgba(100, 150, 255, 0.9)'); - cluster.point.outlineWidth = 2; - cluster.point.disableDepthTestDistance = 1000000.0; - }); - - // CCTV clustering - const cctvDS = new Cesium2.CustomDataSource('cctv'); - cctvDS.clustering.enabled = true; - cctvDS.clustering.pixelRange = 50; - cctvDS.clustering.minimumClusterSize = 5; - viewerRef.current.dataSources.add(cctvDS); - cctvDataSourceRef.current = cctvDS; - - cctvDS.clustering.clusterEvent.addEventListener((clusteredEntities: any[], cluster: any) => { - const count = clusteredEntities.length; - const radius = Math.min(10 + Math.log2(count) * 4, 35); - cluster.billboard.show = false; - cluster.label.show = true; - cluster.label.text = String(count); - cluster.label.font = `bold ${Math.max(9, Math.min(radius, 14))}px monospace`; - cluster.label.fillColor = Cesium2.Color.WHITE; - cluster.label.outlineColor = Cesium2.Color.BLACK; - cluster.label.outlineWidth = 2; - cluster.label.style = Cesium2.LabelStyle.FILL_AND_OUTLINE; - cluster.label.horizontalOrigin = Cesium2.HorizontalOrigin.CENTER; - cluster.label.verticalOrigin = Cesium2.VerticalOrigin.CENTER; - cluster.label.disableDepthTestDistance = 1000000.0; - cluster.point.show = true; - cluster.point.pixelSize = radius; - cluster.point.color = Cesium2.Color.fromCssColorString('rgba(0, 200, 50, 0.7)'); - cluster.point.outlineColor = Cesium2.Color.fromCssColorString('rgba(100, 255, 100, 0.9)'); - cluster.point.outlineWidth = 2; - cluster.point.disableDepthTestDistance = 1000000.0; - }); - - // Lighting and Bloom Settings - scene.globe.enableLighting = true; // Provides dynamic day/night terminator line - - if (scene.postProcessStages) { - const bloom = scene.postProcessStages.bloom; - // Disable bloom by default to prevent washed out continents - bloom.enabled = false; - bloom.uniforms.glowOnly = false; - bloom.uniforms.contrast = 120; - bloom.uniforms.brightness = -0.1; - bloom.uniforms.delta = 0.9; - bloom.uniforms.sigma = 1.5; - bloom.uniforms.stepSize = 0.5; - - const nvgShader = ` - uniform sampler2D colorTexture; - in vec2 v_textureCoordinates; - void main() { - vec4 color = texture(colorTexture, v_textureCoordinates); - float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - vec3 nvg = vec3(0.0, lum * 2.0, 0.0); - float dist = distance(v_textureCoordinates, vec2(0.5)); - nvg *= smoothstep(0.8, 0.2, dist); - out_FragColor = vec4(nvg, 1.0); - } - `; - const flirShader = ` - uniform sampler2D colorTexture; - in vec2 v_textureCoordinates; - void main() { - vec4 color = texture(colorTexture, v_textureCoordinates); - float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114)); - vec3 col; - if (lum < 0.25) col = mix(vec3(0, 0, 1), vec3(0, 1, 1), lum * 4.0); - else if (lum < 0.5) col = mix(vec3(0, 1, 1), vec3(0, 1, 0), (lum - 0.25) * 4.0); - else if (lum < 0.75) col = mix(vec3(0, 1, 0), vec3(1, 1, 0), (lum - 0.5) * 4.0); - else col = mix(vec3(1, 1, 0), vec3(1, 0, 0), (lum - 0.75) * 4.0); - out_FragColor = vec4(col, 1.0); - } - `; - const crtShader = ` - uniform sampler2D colorTexture; - in vec2 v_textureCoordinates; - void main() { - vec2 uv = v_textureCoordinates; - vec4 color = texture(colorTexture, uv); - color.rgb -= sin(uv.y * 800.0) * 0.05; - float r = texture(colorTexture, uv + vec2(0.002, 0.0)).r; - float b = texture(colorTexture, uv - vec2(0.002, 0.0)).b; - out_FragColor = vec4(r, color.g, b, 1.0); - } - `; - - viewerRef.current.customStages = { - NVG: new Cesium.PostProcessStage({ fragmentShader: nvgShader }), - FLIR: new Cesium.PostProcessStage({ fragmentShader: flirShader }), - CRT: new Cesium.PostProcessStage({ fragmentShader: crtShader }) - }; - scene.postProcessStages.add(viewerRef.current.customStages.NVG); - scene.postProcessStages.add(viewerRef.current.customStages.FLIR); - scene.postProcessStages.add(viewerRef.current.customStages.CRT); - viewerRef.current.customStages.NVG.enabled = false; - viewerRef.current.customStages.FLIR.enabled = false; - viewerRef.current.customStages.CRT.enabled = false; - } - - return () => { - // Cleanup on unmount (often skipped in dev hot-reload but good practice) - if (viewerRef.current && typeof window !== "undefined" && !(window as any).nextHotReload) { - viewerRef.current.destroy(); - viewerRef.current = null; - } - }; - }, [cesiumLoaded]); - - // Setup input handler for picking - useEffect(() => { - if (!viewerRef.current) return; - const Cesium = (window as any).Cesium; - const viewer = viewerRef.current; - - const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction((movement: any) => { - // Eavesdrop Mode: Intercept clicks on the globe to get Lat/Lng instead of picking entities - if (isEavesdropping && onEavesdropClick) { - const ray = viewer.camera.getPickRay(movement.position); - const earthPosition = viewer.scene.globe.pick(ray, viewer.scene); - if (earthPosition) { - const cartographic = Cesium.Cartographic.fromCartesian(earthPosition); - const lng = Cesium.Math.toDegrees(cartographic.longitude); - const lat = Cesium.Math.toDegrees(cartographic.latitude); - onEavesdropClick({ lat, lng }); - } - return; // Suppress normal entity selection during Eavesdrop - } - - const pickedObject = viewer.scene.pick(movement.position); - if (Cesium.defined(pickedObject) && pickedObject.id) { - const entityId = pickedObject.id.id || pickedObject.id; - if (typeof entityId === 'string') { - if (entityId.startsWith('news-')) { - const idx = parseInt(entityId.split('-')[1]); - onEntityClick?.({ type: 'news', id: idx, entityId: entityId }); - } else if (entityId.startsWith('gdelt-')) { - const idx = parseInt(entityId.split('-')[1]); - onEntityClick?.({ type: 'gdelt', id: idx, entityId: entityId }); - } else if (entityId.startsWith('private-jet-')) { - const icao = entityId.replace('private-jet-', ''); - const flight = data?.private_jets?.find((f: any) => (f.icao24 || f.registration || f.callsign) === icao); - if (flight) { - onEntityClick?.({ type: 'private_jet', id: flight.icao24 || icao, callsign: flight.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('private-flight-')) { - const icao = entityId.replace('private-flight-', ''); - const flight = data?.private_flights?.find((f: any) => (f.icao24 || f.registration || f.callsign) === icao); - if (flight) { - onEntityClick?.({ type: 'private_flight', id: flight.icao24 || icao, callsign: flight.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('flight-')) { - const icao = entityId.replace('flight-', ''); - const flight = data?.commercial_flights?.find((f: any) => (f.icao24 || f.registration || f.callsign) === icao); - if (flight) { - onEntityClick?.({ type: 'flight', id: flight.icao24 || icao, callsign: flight.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('mil-flight-')) { - const icao = entityId.replace('mil-flight-', ''); - const flight = data?.military_flights?.find((f: any) => (f.icao24 || f.registration || f.callsign) === icao); - if (flight) { - onEntityClick?.({ type: 'military_flight', id: flight.icao24 || icao, callsign: flight.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('tracked-')) { - const icao = entityId.replace('tracked-', ''); - const flight = data?.tracked_flights?.find((f: any) => (f.icao24 || f.registration || f.callsign) === icao); - if (flight) { - onEntityClick?.({ type: 'tracked_flight', id: flight.icao24 || icao, callsign: flight.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('uav-entity-')) { - const uavIcao = entityId.replace('uav-entity-', ''); - const uav = data?.uavs?.find((u: any) => u.icao24 === uavIcao); - if (uav) { - onEntityClick?.({ type: 'uav', id: uav.icao24 || uavIcao, callsign: uav.callsign, entityId: entityId }); - } - } else if (entityId.startsWith('ship-')) { - const shipKey = entityId.replace('ship-', ''); - const ship = data?.ships?.find((s: any) => String(s.mmsi) === shipKey); - if (ship) { - onEntityClick?.({ type: 'ship', id: ship.mmsi, name: ship.name, entityId: entityId }); - } - } else if (entityId.startsWith('cctv-')) { - const entity = viewer.entities.getById(entityId); - // The CCTV ID is the remaining part - const cctvId = entityId.replace('cctv-', ''); - // Find the camera in data - const cam = data?.cctv?.find((c: any) => String(c.id) === cctvId); - onEntityClick?.({ type: 'cctv', id: cctvId, name: cam?.name || cam?.direction_facing, media_url: cam?.media_url, entityId: entityId }); - } else if (entityId.startsWith('satellite-')) { - const satId = entityId.replace('satellite-', ''); - const sat = data?.satellites?.find((c: any) => String(c.id) === satId); - onEntityClick?.({ type: 'satellite', id: satId, name: sat?.name, tle1: sat?.tle1, tle2: sat?.tle2, entityId: entityId }); - } else if (entityId.startsWith('apt-')) { - const aptId = entityId.replace('apt-', ''); - const apt = data?.airports?.find((a: any) => String(a.id) === aptId); - onEntityClick?.({ type: 'airport', id: aptId, name: apt?.name, iata: apt?.iata, entityId: entityId }); - } else if (entityId.startsWith('gdelt-')) { - const idx = parseInt(entityId.replace('gdelt-', '')); - if (!isNaN(idx) && data?.gdelt?.[idx]) { - onEntityClick?.({ type: 'gdelt', id: idx, entityId: entityId }); - } - } else { - // Click on empty space or unhandled entity - onEntityClick?.(null); - } - } - } else { - onEntityClick?.(null); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - return () => { - handler.destroy(); - }; - }, [cesiumLoaded, data, onEntityClick, isEavesdropping, onEavesdropClick]); - - // Effect to track the selected entity's screen position - useEffect(() => { - if (!viewerRef.current || !selectedEntity || !selectedEntity.entityId) { - setPopupPosition(null); - return; - } - - const viewer = viewerRef.current; - const Cesium = (window as any).Cesium; - - const updatePopupPosition = () => { - let position: any = null; - - // 1. Search viewer.entities first - let entity = viewer.entities.getById(selectedEntity.entityId); - if (!entity && cctvDataSourceRef.current) entity = cctvDataSourceRef.current.entities.getById(selectedEntity.entityId); - if (!entity && shipDataSourceRef.current) entity = shipDataSourceRef.current.entities.getById(selectedEntity.entityId); - - if (entity && entity.position) { - const time = viewer.clock.currentTime; - position = entity.position.getValue(time); - } - - // 2. Search Primitives - if (!position && selectedEntity.entityId) { - const isFlight = selectedEntity.entityId.startsWith('flight-') || selectedEntity.entityId.startsWith('private-flight-') || selectedEntity.entityId.startsWith('private-jet-') || selectedEntity.entityId.startsWith('mil-flight-') || selectedEntity.entityId.startsWith('tracked-'); - if (isFlight) { - const uid = selectedEntity.entityId.split('-').slice(1).join('-'); // Handles 'flight-icao', 'private-flight-icao', etc. - position = flightPrimitivesRef.current.get(uid)?.billboard?.position; - } - } - - if (position) { - const canvasPosition = Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, position); - if (canvasPosition) { - setPopupPosition({ x: canvasPosition.x, y: canvasPosition.y }); - return; - } - } - - setPopupPosition(null); - }; - - let lastMoveReport = 0; - const updateCameraCenter = () => { - if (!onCameraMove) return; - const now = Date.now(); - if (now - lastMoveReport < 1000) return; // Throttle to 1s - - // Find center of screen - const canvas = viewer.scene.canvas; - const center = new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2); - const ray = viewer.camera.getPickRay(center); - const earthPosition = viewer.scene.globe.pick(ray, viewer.scene); - - if (earthPosition) { - const cartographic = Cesium.Cartographic.fromCartesian(earthPosition); - const lng = Cesium.Math.toDegrees(cartographic.longitude); - const lat = Cesium.Math.toDegrees(cartographic.latitude); - onCameraMove({ lat, lng }); - lastMoveReport = now; - } - }; - - // Initial update and attach to render loop - updatePopupPosition(); - viewer.scene.preRender.addEventListener(updatePopupPosition); - viewer.camera.changed.addEventListener(updateCameraCenter); - - return () => { - viewer.scene.preRender.removeEventListener(updatePopupPosition); - viewer.camera.changed.removeEventListener(updateCameraCenter); - }; - }, [selectedEntity, onCameraMove]); - - // Effect to update data entities and effects - useEffect(() => { - if (!viewerRef.current || !data) return; - const Cesium = (window as any).Cesium; - const viewer = viewerRef.current; - const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, viewer.camera.positionWC); - const cameraHeight = viewer.camera.positionCartographic.height; - - // Instead of removing all, we should manage entities by ID for performance - // For now, wipe and redraw as prototyping is small relative to Cesium engine - // Handle Entities gracefully to prevent stutter - viewer.entities.suspendEvents(); - const shipDS = shipDataSourceRef.current; - const cctvDS = cctvDataSourceRef.current; - if (shipDS) shipDS.entities.suspendEvents(); - if (cctvDS) cctvDS.entities.suspendEvents(); - - const touchedIds = new Set(); - const touchedCctvIds = new Set(); - const touchedShipIds = new Set(); // Added for ship cleanup - - 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.show !== undefined) { - existing.show = props.show; - } else { - existing.show = true; - } - - if (props.label && existing.label) { - existing.label.text = props.label.text; - if (props.label.show !== undefined) { - existing.label.show = props.label.show; - } else { - existing.label.show = true; - } - } - - 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; - } - - if (props.point && existing.point) { - if (props.point.show !== undefined) existing.point.show = props.point.show; - else existing.point.show = true; - if (props.point.distanceDisplayCondition !== undefined) existing.point.distanceDisplayCondition = props.point.distanceDisplayCondition; - } - - if (props.ellipse && existing.ellipse) { - if (props.ellipse.show !== undefined) existing.ellipse.show = props.ellipse.show; - else existing.ellipse.show = true; - if (props.ellipse.distanceDisplayCondition !== undefined) existing.ellipse.distanceDisplayCondition = props.ellipse.distanceDisplayCondition; - } - } else { - viewer.entities.add(props); - } - }; - - const updatePrimitive = (uid: string, props: any, mapRef: Map, billboardsRef: any, labelsRef: any) => { - let prims = mapRef.get(uid); - if (!props.show) { - if (prims) { - prims.billboard.show = false; - prims.label.show = false; - } - return; - } - - if (!prims) { - const b = billboardsRef.add({ ...props.billboard, position: props.position, id: props.id }); - const l = labelsRef.add({ ...props.label, position: props.position, id: props.id }); - prims = { billboard: b, label: l }; - mapRef.set(uid, prims); - } - - prims.billboard.position = props.position; - prims.billboard.show = props.show; - if (props.billboard.image !== undefined) prims.billboard.image = props.billboard.image; - if (props.billboard.rotation !== undefined) prims.billboard.rotation = props.billboard.rotation; - - prims.label.position = props.position; - prims.label.show = props.label.show !== false ? props.show : false; - if (props.label.text !== undefined) prims.label.text = props.label.text; - }; - - const svgToBase64 = (svg: string) => `data:image/svg+xml;base64,${btoa(svg)}`; - const svgPlaneCyan = svgToBase64(``); - const svgPlaneYellow = svgToBase64(``); - const svgPlaneOrange = svgToBase64(``); - const svgPlanePurple = svgToBase64(``); - - const svgFighter = svgToBase64(``); - const svgHeli = svgToBase64(``); - const svgHeliCyan = svgToBase64(``); - const svgHeliOrange = svgToBase64(``); - const svgHeliPurple = svgToBase64(``); - const svgTanker = svgToBase64(``); - const svgRecon = svgToBase64(``); - - const milIconMap: any = { - 'fighter': svgFighter, - 'heli': svgHeli, - 'tanker': svgTanker, - 'cargo': svgPlaneYellow, - 'recon': svgRecon, - 'default': svgPlaneYellow - }; - - // Tracked aircraft SVGs (Plane-Alert DB) - const svgPlanePink = svgToBase64(``); - const svgPlaneAlertRed = svgToBase64(``); - const svgPlaneDarkBlue = svgToBase64(``); - const svgPlaneWhiteAlert = svgToBase64(``); - - const svgHeliPink = svgToBase64(``); - const svgHeliAlertRed = svgToBase64(``); - const svgHeliDarkBlue = svgToBase64(``); - const svgHeliWhiteAlert = svgToBase64(``); - - const trackedPlaneIcons: any = { - 'pink': svgPlanePink, 'red': svgPlaneAlertRed, 'darkblue': svgPlaneDarkBlue, 'white': svgPlaneWhiteAlert - }; - const trackedHeliIcons: any = { - 'pink': svgHeliPink, 'red': svgHeliAlertRed, 'darkblue': svgHeliDarkBlue, 'white': svgHeliWhiteAlert - }; - const trackedColorMap: any = { - 'pink': Cesium.Color.fromCssColorString('#FF1493'), - 'red': Cesium.Color.fromCssColorString('#FF2020'), - 'darkblue': Cesium.Color.fromCssColorString('#1A3A8A'), - 'white': Cesium.Color.WHITE - }; - - // Parked/landed aircraft SVGs (dark/black) - const svgPlaneBlack = svgToBase64(``); - const svgHeliBlack = svgToBase64(``); - const COLOR_BLACK = Cesium.Color.fromCssColorString('#222222'); - - // Detect parked/landed aircraft: altitude near 0 AND low ground speed - const isOnGround = (f: any) => { - const alt = f.alt || 0; // already in meters - const spd = f.speed_knots || 0; - return alt <= 500 && spd < 30; - }; - - // Render accumulated flight trail as a polyline - const renderTrail = (f: any, uid: string, trailColor: any, show: boolean) => { - const trail = f.trail; - if (!trail || trail.length < 2) return; - - const trailId = `trail-${uid}`; - // Build positions array from trail points [lat, lng, alt, ts] - const positions = trail.map((p: number[]) => - Cesium.Cartesian3.fromDegrees(p[1], p[0], Math.max(p[2] || 0, 100)) - ); - // Add current position as final point - positions.push(Cesium.Cartesian3.fromDegrees(f.lng, f.lat, Math.max(f.alt || 0, 100))); - - addOrUpdate({ - id: trailId, - show: show, - polyline: { - positions: positions, - width: 2, - material: new Cesium.PolylineGlowMaterialProperty({ - glowPower: 0.15, - color: trailColor - }), - disableDepthTestDistance: 1000000.0 - } - }); - }; - - // Filter matching helpers (multi-select: OR logic across selected values) - const filters = activeFilters || {}; - const matchesAny = (value: string, selectedValues: string[]) => { - if (!selectedValues || selectedValues.length === 0) return true; - const v = (value || '').toLowerCase(); - return selectedValues.some(sv => v.includes(sv.toLowerCase())); - }; - const matchesCommercialFilter = (f: any) => { - if (filters.commercial_departure?.length) { - if (!matchesAny(f.origin_name, filters.commercial_departure)) return false; - } - if (filters.commercial_arrival?.length) { - if (!matchesAny(f.dest_name, filters.commercial_arrival)) return false; - } - if (filters.commercial_airline?.length) { - if (!matchesAny(f.airline_code, filters.commercial_airline)) return false; - } - return true; - }; - const matchesPrivateFilter = (f: any) => { - if (filters.private_callsign?.length) { - const cs = (f.callsign || '').toLowerCase(); - const reg = (f.registration || '').toLowerCase(); - if (!filters.private_callsign.some(sv => { - const q = sv.toLowerCase(); - return cs.includes(q) || reg.includes(q); - })) return false; - } - if (filters.private_aircraft_type?.length) { - if (!matchesAny(f.model, filters.private_aircraft_type)) return false; - } - return true; - }; - const matchesMilitaryFilter = (f: any) => { - if (filters.military_country?.length) { - const reg = (f.registration || '').toLowerCase(); - const country = (f.country || '').toLowerCase(); - if (!filters.military_country.some(sv => { - const q = sv.toLowerCase(); - return reg.includes(q) || country.includes(q); - })) return false; - } - if (filters.military_aircraft_type?.length) { - if (!matchesAny(f.military_type, filters.military_aircraft_type)) return false; - } - return true; - }; - const matchesTrackedFilter = (f: any) => { - if (filters.tracked_category?.length) { - if (!matchesAny(f.alert_category, filters.tracked_category)) return false; - } - if (filters.tracked_owner?.length) { - const op = (f.alert_operator || '').toLowerCase(); - const tags = (f.alert_tags || '').toLowerCase(); - const cs = (f.callsign || '').toLowerCase(); - if (!filters.tracked_owner.some(sv => { - const q = sv.toLowerCase(); - return op.includes(q) || tags.includes(q) || cs.includes(q); - })) return false; - } - return true; - }; - const matchesShipFilter = (f: any) => { - if (filters.ship_name?.length) { - if (!matchesAny(f.name, filters.ship_name)) return false; - } - if (filters.ship_type?.length) { - if (!matchesAny(f.type, filters.ship_type)) return false; - } - return true; - }; - - // ── Cross-category filter hiding ── - // When ANY air filter is active, categories WITHOUT their own filters should hide. - // This ensures filtering Lufthansa hides all private/military/tracked unless they also have filters. - const hasCommercialFilter = !!(filters.commercial_departure?.length || filters.commercial_arrival?.length || filters.commercial_airline?.length); - const hasPrivateFilter = !!(filters.private_callsign?.length || filters.private_aircraft_type?.length); - const hasMilitaryFilter = !!(filters.military_country?.length || filters.military_aircraft_type?.length); - const hasTrackedFilter = !!(filters.tracked_category?.length || filters.tracked_owner?.length); - const hasShipFilter = !!(filters.ship_name?.length || filters.ship_type?.length); - const hasAnyAirFilter = hasCommercialFilter || hasPrivateFilter || hasMilitaryFilter || hasTrackedFilter; - const hasAnyFilter = hasAnyAirFilter || hasShipFilter; - const svgDrone = svgToBase64(``); - const svgShipGray = svgToBase64(``); - const svgShipRed = svgToBase64(``); - const svgShipYellow = svgToBase64(``); - const svgShipBlue = svgToBase64(``); - const svgShipWhite = svgToBase64(``); - const svgCarrier = svgToBase64(``); - const svgCctv = svgToBase64(``); - const svgWarning = svgToBase64(``); - - // Apply Post-Process Effects - if (viewer.scene.postProcessStages) { - viewer.scene.postProcessStages.bloom.enabled = effects?.bloom ?? true; - if (viewer.customStages) { - viewer.customStages.NVG.enabled = effects?.style === 'NVG'; - viewer.customStages.FLIR.enabled = effects?.style === 'FLIR'; - viewer.customStages.CRT.enabled = effects?.style === 'CRT'; - } - } - - // Apply Traffic Layer visibility - if (viewer.trafficLayer) { - viewer.trafficLayer.show = activeLayers?.traffic !== false; - } - - // Process DeepStateMap Ukraine Frontlines (GeoJSON parsing) - const frontlineId = "deepstate-frontline"; - if (data.frontlines && activeLayers?.ukraine_frontline !== false) { - // Check if we already loaded it so we don't spam the Cesium entity system with huge polygons - if (!viewer.dataSources.getByName(frontlineId).length) { - // GeoJSON processing - Cesium.GeoJsonDataSource.load(data.frontlines, { - stroke: Cesium.Color.RED, - fill: Cesium.Color.RED.withAlpha(0.2), - strokeWidth: 2 - }).then((dataSource: any) => { - dataSource.name = frontlineId; - viewer.dataSources.add(dataSource); - - const entities = dataSource.entities.values; - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - const status = entity.properties?.status?.getValue(); // 1=Liberated, 2=Occupied, 3=Contested (approximation) - let polyColor = Cesium.Color.RED.withAlpha(0.3); // Default occupied - let outlineColor = Cesium.Color.RED; - - if (status === 1) { - // Liberated - polyColor = Cesium.Color.GREEN.withAlpha(0.2); - outlineColor = Cesium.Color.GREEN; - } else if (status === 3) { - // Contested - polyColor = Cesium.Color.ORANGE.withAlpha(0.2); - outlineColor = Cesium.Color.ORANGE; - } - - if (entity.polygon) { - entity.polygon.material = polyColor; - entity.polygon.outlineColor = outlineColor; - entity.polygon.outlineWidth = 2; - } - } - }); - } else { - // Make sure it's visible if already loaded - const ds = viewer.dataSources.getByName(frontlineId)[0]; - if (ds && !ds.show) ds.show = true; - } - } else { - // Hide it if turned off - const ds = viewer.dataSources.getByName(frontlineId)[0]; - if (ds && ds.show) ds.show = false; - } - - // Process GDELT Global Military Incidents - if (data.gdelt && activeLayers?.global_incidents !== false) { - data.gdelt.forEach((incident: any, idx: number) => { - const geom = incident.geometry; - if (!geom || geom.type !== 'Point' || !geom.coordinates) return; - - const lng = geom.coordinates[0]; - const lat = geom.coordinates[1]; - const id = `gdelt-${idx}`; - const isSelected = selectedEntity?.id === id; - - addOrUpdate({ - id: id, - position: Cesium.Cartesian3.fromDegrees(lng, lat, 0), - point: { - pixelSize: 8, - color: Cesium.Color.ORANGE, - outlineColor: Cesium.Color.RED, - outlineWidth: 2, - disableDepthTestDistance: 4000000.0, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 5000000.0) - }, - ellipse: { - semiMinorAxis: 15000, - semiMajorAxis: 15000, - material: Cesium.Color.RED.withAlpha(0.3), - outline: true, - outlineColor: Cesium.Color.ORANGE, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 5000000.0) - } - }); - }); - } - - // Process News Alerts (Risk Coordinates) - if (data.news) { - data.news.forEach((n: any, idx: number) => { - if (n.coords && n.coords.length === 2) { - let riskColorHex = '#22c55e'; // Green (1-3) - if (n.risk_score >= 9) riskColorHex = '#ef4444'; // Red (9-10) - else if (n.risk_score >= 7) riskColorHex = '#f97316'; // Orange (7-8) - else if (n.risk_score >= 4) riskColorHex = '#eab308'; // Yellow (4-6) - const currentPos = Cesium.Cartesian3.fromDegrees(n.coords[1], n.coords[0], 0); - // Cull if on the other side of the planet - if (!occluder.isPointVisible(currentPos)) return; - - const color = Cesium.Color.fromCssColorString(riskColorHex); - - addOrUpdate({ - id: `news-${idx}`, - position: currentPos, - point: { - pixelSize: n.risk_score >= 8 ? 16 : 8, - color: Cesium.Color.fromCssColorString('rgba(0,0,0,0)'), - outlineColor: color, - outlineWidth: 3 - }, - ellipse: { - semiMinorAxis: n.risk_score * 40000, - semiMajorAxis: n.risk_score * 40000, - material: color.withAlpha(0.2), - outline: true, - outlineColor: color - }, - label: { - text: n.cluster_count > 1 ? `!! ALERT LVL ${n.risk_score} !!\n${n.title.substring(0, 30)}...\n[+${n.cluster_count - 1} MORE]` : `!! ALERT LVL ${n.risk_score}!!\n${n.title.substring(0, 30)}...`, - font: 'bold 10px monospace', - fillColor: color, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -16), - // Pushes it forward from planes, but not through the earth - eyeOffset: new Cesium.Cartesian3(0, 0, -100000), - disableDepthTestDistance: Number.POSITIVE_INFINITY // Always overlays over planes now that backface culling handles planet occlusion - } - }); - } - }); - } - - // Process Commercial Flights (teal) - if (data.commercial_flights && activeLayers?.flights !== false) { - const anyFlightSelected = selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet'; - const selectedFlightIdx = selectedEntity?.type === 'flight' ? String(selectedEntity.entityId).replace('flight-', '') : null; - const seenIds = new Set(); - - data.commercial_flights.forEach((f: any, idx: number) => { - if (hasAnyAirFilter && !hasCommercialFilter) return; - if (!matchesCommercialFilter(f)) return; - const uid = f.icao24 || f.registration || f.callsign || `unk-${idx}`; - const currentPos = Cesium.Cartesian3.fromDegrees(f.lng, f.lat, f.alt || 5000); - - // Culling: Skip rendering heavily if planes are behind the planet - if (!occluder.isPointVisible(currentPos)) return; - - const id = `flight-${uid}`; - const isSelected = selectedFlightIdx === String(uid); - const showEntity = !anyFlightSelected || isSelected; - seenIds.add(uid); - - updatePrimitive(uid, { - id: id, - show: showEntity, - position: currentPos, - billboard: { - image: isOnGround(f) ? (f.aircraft_category === 'heli' ? svgHeliBlack : svgPlaneBlack) : (f.aircraft_category === 'heli' ? svgHeliCyan : svgPlaneCyan), - width: 14, height: 14, - rotation: Cesium.Math.toRadians(-f.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - show: isSelected, - text: `[${f.callsign || 'FLT'} ]`, - font: '10px monospace', - fillColor: Cesium.Color.CYAN, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -12), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 10000000.0), - disableDepthTestDistance: 1000000.0 - } - }, flightPrimitivesRef.current, flightBillboardsRef.current, flightLabelsRef.current); - - // Draw accumulated trail for unrouted flights ONLY if selected to avoid crashing the 16-bit WebGL array limits - if (isSelected) renderTrail(f, uid, Cesium.Color.CYAN.withAlpha(0.5), isSelected); - }); - - // Cull disappeared flights - for (const [uid, prims] of Array.from(flightPrimitivesRef.current.entries())) { - if (!seenIds.has(uid) && prims.billboard.id.startsWith('flight-')) { - flightBillboardsRef.current.remove(prims.billboard); - flightLabelsRef.current.remove(prims.label); - flightPrimitivesRef.current.delete(uid); - } - } - } else if (flightPrimitivesRef.current.size > 0) { - flightBillboardsRef.current.removeAll(); - flightLabelsRef.current.removeAll(); - flightPrimitivesRef.current.clear(); - } - - // Process Private Flights (orange) - if (data.private_flights && activeLayers?.private !== false) { - const now = Cesium.JulianDate.now(); - const future = Cesium.JulianDate.addSeconds(now, 30, new Cesium.JulianDate()); - - const anyFlightSelected = selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet'; - const selectedPrivateIdx = selectedEntity?.type === 'private_flight' ? String(selectedEntity.entityId).replace('private-flight-', '') : null; - const seenIds = new Set(); - - const orangeColor = Cesium.Color.fromCssColorString('#FF8C00'); - - data.private_flights.forEach((f: any, idx: number) => { - if (hasAnyAirFilter && !hasPrivateFilter) return; - if (!matchesPrivateFilter(f)) return; - const uid = f.icao24 || f.registration || f.callsign || `unk-${idx}`; - const currentPos = Cesium.Cartesian3.fromDegrees(f.lng, f.lat, f.alt || 3000); - if (!occluder.isPointVisible(currentPos)) return; - - const id = `private-flight-${uid}`; - const isSelected = selectedPrivateIdx === String(uid); - const showEntity = !anyFlightSelected || isSelected; - seenIds.add(uid); - - updatePrimitive(uid, { - id: id, - show: showEntity, - position: currentPos, - billboard: { - image: isOnGround(f) ? (f.aircraft_category === 'heli' ? svgHeliBlack : svgPlaneBlack) - : f.aircraft_category === 'heli' ? svgHeliOrange - : svgPlaneOrange, - width: 12, height: 12, - rotation: Cesium.Math.toRadians(-f.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - show: isSelected, - text: `[${f.callsign || 'PVT'} ]`, - font: '10px monospace', - fillColor: orangeColor, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -12), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 10000000.0), - disableDepthTestDistance: 1000000.0 - } - }, flightPrimitivesRef.current, flightBillboardsRef.current, flightLabelsRef.current); - if (isSelected) renderTrail(f, uid, Cesium.Color.fromCssColorString('#FF8C00').withAlpha(0.5), isSelected); - }); - // Cull disappeared flights - for (const [uid, prims] of Array.from(flightPrimitivesRef.current.entries())) { - if (!seenIds.has(uid) && prims.billboard.id.startsWith('private-flight-')) { // Only remove if it's a private flight and not seen - flightBillboardsRef.current.remove(prims.billboard); - flightLabelsRef.current.remove(prims.label); - flightPrimitivesRef.current.delete(uid); - } - } - } - - // Process Private Jets (purple) - if (data.private_jets && activeLayers?.jets !== false) { - const now = Cesium.JulianDate.now(); - const future = Cesium.JulianDate.addSeconds(now, 30, new Cesium.JulianDate()); - - const anyFlightSelected = selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet'; - const selectedJetIdx = selectedEntity?.type === 'private_jet' ? String(selectedEntity.entityId).replace('private-jet-', '') : null; - const seenIds = new Set(); - - const purpleColor = Cesium.Color.fromCssColorString('#9B59B6'); - - data.private_jets.forEach((f: any, idx: number) => { - if (hasAnyAirFilter && !hasPrivateFilter) return; - if (!matchesPrivateFilter(f)) return; - const uid = f.icao24 || f.registration || f.callsign || `unk-${idx}`; - const currentPos = Cesium.Cartesian3.fromDegrees(f.lng, f.lat, f.alt || 8000); - if (!occluder.isPointVisible(currentPos)) return; - - const id = `private-jet-${uid}`; - const isSelected = selectedJetIdx === String(uid); - const showEntity = !anyFlightSelected || isSelected; - seenIds.add(uid); - - updatePrimitive(uid, { - id: id, - show: showEntity, - position: currentPos, - billboard: { - image: isOnGround(f) ? (f.aircraft_category === 'heli' ? svgHeliBlack : svgPlaneBlack) - : f.aircraft_category === 'heli' ? svgHeliPurple - : svgPlanePurple, - width: 14, height: 14, - rotation: Cesium.Math.toRadians(-f.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - show: isSelected, - text: `[${f.callsign || 'JET'} ]`, - font: '10px monospace', - fillColor: purpleColor, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -12), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 10000000.0), - disableDepthTestDistance: 1000000.0 - } - }, flightPrimitivesRef.current, flightBillboardsRef.current, flightLabelsRef.current); - if (isSelected) renderTrail(f, uid, Cesium.Color.fromCssColorString('#9B59B6').withAlpha(0.5), isSelected); - }); - // Cull disappeared flights - for (const [uid, prims] of Array.from(flightPrimitivesRef.current.entries())) { - if (!seenIds.has(uid) && prims.billboard.id.startsWith('private-jet-')) { // Only remove if it's a private jet and not seen - flightBillboardsRef.current.remove(prims.billboard); - flightLabelsRef.current.remove(prims.label); - flightPrimitivesRef.current.delete(uid); - } - } - } - - // Process Military Flights - if (data.military_flights && activeLayers?.military !== false) { - const now = Cesium.JulianDate.now(); - const future = Cesium.JulianDate.addSeconds(now, 30, new Cesium.JulianDate()); - - const anyFlightSelected = selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight'; - const selectedMilFlightIdx = selectedEntity?.type === 'military_flight' ? String(selectedEntity.id) : null; - const seenIds = new Set(); - - data.military_flights.forEach((f: any, idx: number) => { - if (hasAnyAirFilter && !hasMilitaryFilter) return; - if (!matchesMilitaryFilter(f)) return; - const uid = f.icao24 || f.registration || f.callsign || `unk-${idx}`; - const startPos = Cesium.Cartesian3.fromDegrees(f.lng, f.lat, f.alt || 8000); - if (!occluder.isPointVisible(startPos)) return; - - const id = `mil-flight-${uid}`; - const isSelected = selectedMilFlightIdx === String(idx); - const showEntity = !anyFlightSelected || isSelected; - seenIds.add(uid); - - let positionProp = viewer.entities.getById(id)?.position; - - if (!positionProp || !(positionProp instanceof Cesium.SampledPositionProperty)) { - positionProp = new Cesium.SampledPositionProperty(); - positionProp.forwardExtrapolationType = Cesium.ExtrapolationType.EXTRAPOLATE; - positionProp.backwardExtrapolationType = Cesium.ExtrapolationType.HOLD; - } - - (positionProp as any).addSample(now, startPos); - - // Use actual ground speed from ADS-B (knots → m/s) or fallback - const speedMps = f.speed_knots ? f.speed_knots * 0.514444 : 400; - const distanceMeters = speedMps * 30; - - const R = 6371e3; - const lat1 = f.lat * Math.PI / 180; - const lon1 = f.lng * Math.PI / 180; - const brng = f.heading * Math.PI / 180; - - const lat2 = Math.asin(Math.sin(lat1) * Math.cos(distanceMeters / R) + - Math.cos(lat1) * Math.sin(distanceMeters / R) * Math.cos(brng)); - const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(distanceMeters / R) * Math.cos(lat1), - Math.cos(distanceMeters / R) - Math.sin(lat1) * Math.sin(lat2)); - - const endPos = Cesium.Cartesian3.fromDegrees(lon2 * 180 / Math.PI, lat2 * 180 / Math.PI, f.alt || 8000); - (positionProp as any).addSample(future, endPos); - - updatePrimitive(uid, { - id: id, - show: showEntity, - position: positionProp, - billboard: { - image: isOnGround(f) ? svgPlaneBlack : (milIconMap[f.military_type || 'default'] || svgPlaneYellow), - width: 18, - height: 18, - rotation: Cesium.Math.toRadians(-f.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - show: isSelected, // Only show label when isolated - text: `[${f.callsign} ]`, - font: 'bold 10px monospace', - fillColor: Cesium.Color.YELLOW, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -14), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 12000000.0), - disableDepthTestDistance: 1000000.0 - } - }, flightPrimitivesRef.current, flightBillboardsRef.current, flightLabelsRef.current); - if (isSelected) renderTrail(f, uid, Cesium.Color.YELLOW.withAlpha(0.5), isSelected); - }); - // Cull disappeared flights - for (const [uid, prims] of Array.from(flightPrimitivesRef.current.entries())) { - if (!seenIds.has(uid) && prims.billboard.id.startsWith('mil-flight-')) { // Only remove if it's a military flight and not seen - flightBillboardsRef.current.remove(prims.billboard); - flightLabelsRef.current.remove(prims.label); - flightPrimitivesRef.current.delete(uid); - } - } - } - - // Process Tracked/Alert Flights (Plane-Alert DB) - if (data.tracked_flights && activeLayers?.tracked !== false) { - const now = Cesium.JulianDate.now(); - const future = Cesium.JulianDate.addSeconds(now, 30, new Cesium.JulianDate()); - - const anyFlightSelected = selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet' || selectedEntity?.type === 'tracked_flight'; - const selectedTrackedIdx = selectedEntity?.type === 'tracked_flight' ? String(selectedEntity.entityId).replace('tracked-', '') : null; - const seenIds = new Set(); - - data.tracked_flights.forEach((f: any, idx: number) => { - if (hasAnyAirFilter && !hasTrackedFilter) return; - if (!matchesTrackedFilter(f)) return; - const uid = f.icao24 || f.registration || f.callsign || `unk-${idx}`; - const currentPos = Cesium.Cartesian3.fromDegrees(f.lng, f.lat, f.alt || 5000); - if (!occluder.isPointVisible(currentPos)) return; - - const id = `tracked-${uid}`; - const isSelected = selectedTrackedIdx === String(uid); - const showEntity = !anyFlightSelected || isSelected; - seenIds.add(uid); - - const alertColor = f.alert_color || 'white'; - const cesiumColor = trackedColorMap[alertColor] || Cesium.Color.WHITE; - const planeIcon = f.aircraft_category === 'heli' - ? (trackedHeliIcons[alertColor] || svgHeliWhiteAlert) - : (trackedPlaneIcons[alertColor] || svgPlaneWhiteAlert); - - updatePrimitive(uid, { - id: id, - show: showEntity, - position: currentPos, - billboard: { - image: isOnGround(f) ? (f.aircraft_category === 'heli' ? svgHeliBlack : svgPlaneBlack) : planeIcon, - width: 16, height: 16, - rotation: Cesium.Math.toRadians(-f.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - show: isSelected, - text: `⚠ ${f.alert_operator || f.callsign || 'TRACKED'}`, - font: 'bold 10px monospace', - fillColor: cesiumColor, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.85)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -14), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 12000000.0), - disableDepthTestDistance: 1000000.0 - } - }, flightPrimitivesRef.current, flightBillboardsRef.current, flightLabelsRef.current); - const trailAlertColor = f.alert_color || 'white'; - const trailClr = trackedColorMap[trailAlertColor] || Cesium.Color.WHITE; - if (isSelected) renderTrail(f, uid, trailClr.withAlpha(0.5), isSelected); - }); - // Cull disappeared flights - for (const [uid, prims] of Array.from(flightPrimitivesRef.current.entries())) { - if (!seenIds.has(uid) && prims.billboard.id.startsWith('tracked-')) { // Only remove if it's a tracked flight and not seen - flightBillboardsRef.current.remove(prims.billboard); - flightLabelsRef.current.remove(prims.label); - flightPrimitivesRef.current.delete(uid); - } - } - } - - // Process UAV Loitering Patterns - if (data.uavs && activeLayers?.military !== false) { - data.uavs.forEach((uav: any, idx: number) => { - // Drone entity - addOrUpdate({ - id: `uav-entity-${idx}`, - position: Cesium.Cartesian3.fromDegrees(uav.lng, uav.lat, uav.alt || 10000), - point: { - pixelSize: 6, - color: Cesium.Color.ORANGE, - disableDepthTestDistance: 1000000.0 - }, - billboard: { - image: svgDrone, - width: 18, - height: 18, - rotation: Cesium.Math.toRadians(-uav.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - text: `[UAV: ${uav.callsign} ]`, - font: 'bold 10px monospace', - fillColor: Cesium.Color.ORANGE, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -14), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 15000000.0), - disableDepthTestDistance: 1000000.0 - } - }); - - // Loitering Orbit Ring - addOrUpdate({ - id: `uav-orbit-${idx}`, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArrayHeights(uav.path), - width: 1, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.ORANGE.withAlpha(0.3), - dashLength: 8.0 - }) - } - }); - - // Tracked Center Point (Area of Interest) - addOrUpdate({ - id: `uav-target-${idx}`, - position: Cesium.Cartesian3.fromDegrees(uav.center[1], uav.center[0], 0), - point: { - pixelSize: 4, - color: Cesium.Color.RED.withAlpha(0.5), - outlineColor: Cesium.Color.RED, - outlineWidth: 1, - disableDepthTestDistance: 1000000.0 - } - }); - }); - } - - // Project Triangulated Flight Paths - if (selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet' || selectedEntity?.type === 'tracked_flight') { - const fList = selectedEntity.type === 'flight' ? data.commercial_flights - : selectedEntity.type === 'private_flight' ? data.private_flights - : selectedEntity.type === 'private_jet' ? data.private_jets - : selectedEntity.type === 'tracked_flight' ? data.tracked_flights - : data.military_flights; - const flight = fList?.find((f: any) => f.icao24 === selectedEntity.id); - if (flight && flight.origin_loc && flight.dest_loc) { - const color = selectedEntity.type === 'flight' ? Cesium.Color.CYAN - : selectedEntity.type === 'private_flight' ? Cesium.Color.fromCssColorString('#FF8C00') - : selectedEntity.type === 'private_jet' ? Cesium.Color.fromCssColorString('#9B59B6') - : selectedEntity.type === 'tracked_flight' ? (trackedColorMap[flight.alert_color] || Cesium.Color.WHITE) - : Cesium.Color.YELLOW; - - // Add Polyline Arc from origin, through current position, to destination - addOrUpdate({ - id: `sel-poly-${selectedEntity.entityId}`, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArrayHeights([ - flight.origin_loc[0], flight.origin_loc[1], 0, - flight.lng, flight.lat, flight.alt || 5000, - flight.dest_loc[0], flight.dest_loc[1], 0 - ]), - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ - color: color, - dashLength: 16.0 - }) - } - }); - } - } - - // Project Holographic 3D CCTV Video - if (selectedEntity?.type === 'cctv') { - const cam = data?.cctv?.find((c: any) => String(c.id) === String(selectedEntity.id)); - if (cam && !cam.media_url?.includes('embed')) { - const isVideo = cam.media_url?.includes('.mp4'); - let material: any = Cesium.Color.LIME.withAlpha(0.2); - const lng = cam.lng !== undefined ? cam.lng : cam.lon; - - try { - if (isVideo) { - const videoElement = document.createElement('video'); - videoElement.crossOrigin = 'anonymous'; - videoElement.src = cam.media_url; - videoElement.autoplay = true; - videoElement.loop = true; - videoElement.muted = true; - videoElement.play().catch(() => { }); // Catch autoplay errors silently - - material = new Cesium.ImageMaterialProperty({ - image: videoElement, - color: Cesium.Color.WHITE.withAlpha(0.9) - }); - } else { - material = new Cesium.ImageMaterialProperty({ - image: cam.media_url, - color: Cesium.Color.WHITE.withAlpha(0.9) - }); - } - - // A wall stands vertical. Calculate a 400m wide line facing mostly south for visibility. - // To maintain aspect ratio and prevent severe distortion, use a smaller width. - // ~200 meters wide, centered on the camera. - const widthOffset = 0.001; - - addOrUpdate({ - id: `holo-cctv-${cam.id}`, - wall: { - positions: Cesium.Cartesian3.fromDegreesArrayHeights([ - lng - widthOffset, cam.lat, 200, - lng + widthOffset, cam.lat, 200 - ]), - maximumHeights: [600, 600], - minimumHeights: [200, 200], - material: material, - outline: true, - outlineColor: Cesium.Color.LIME - } - }); - } catch (e) { } - } - } - - // Process Ships and Carriers - if (data.ships) { - const importantTypes = new Set(['carrier', 'military_vessel', 'tanker', 'cargo']); - data.ships.forEach((s: any, idx: number) => { - if (hasShipFilter && !matchesShipFilter(s)) return; - const isImportant = importantTypes.has(s.type); - const isPassenger = s.type === 'passenger'; - - // Category-based filtering - if (s.type === 'carrier' && activeLayers?.satellites === false) return; - if (isImportant && s.type !== 'carrier' && activeLayers?.ships_important === false) return; - if (isPassenger && activeLayers?.ships_passenger === false) return; - if (!isImportant && !isPassenger && activeLayers?.ships_civilian === false) return; - - let svg = svgShipWhite; - let color = Cesium.Color.WHITE; - let width = 10, height = 10; - if (s.type === 'carrier') { - svg = svgCarrier; - color = Cesium.Color.ORANGE; - width = 24; - height = 24; - } else if (s.type === 'tanker' || s.type === 'cargo') { - svg = svgShipRed; - color = Cesium.Color.RED; - width = 12; - height = 12; - } else if (s.type === 'yacht') { - svg = svgShipWhite; - color = Cesium.Color.WHITE; - width = 12; - height = 12; - } else if (s.type === 'military_vessel') { - svg = svgShipYellow; - color = Cesium.Color.YELLOW; - width = 14; - height = 14; - } else if (s.type === 'passenger') { - svg = svgShipWhite; - color = Cesium.Color.WHITE; - width = 14; - height = 14; - } - - const currentPos = Cesium.Cartesian3.fromDegrees(s.lng, s.lat, 0); - if (!occluder.isPointVisible(currentPos)) return; - - const shipId = s.mmsi ? `ship-${s.mmsi}` : `ship-${idx}`; - - let entity = shipDS.entities.getById(shipId); - if (!entity) { - shipDS.entities.add({ - id: shipId, - position: currentPos, - billboard: { - image: svg, - width: width, height: height, - rotation: Cesium.Math.toRadians(-s.heading || 0), - disableDepthTestDistance: 1000000.0 - }, - label: { - text: s.type === 'carrier' ? `[[${s.name}]]` : `[${s.name} ]`, - font: s.type === 'carrier' ? 'bold 12px monospace' : '9px monospace', - fillColor: color, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -(height / 2 + 6)), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, s.type === 'carrier' ? 12000000.0 : 3000000.0), - disableDepthTestDistance: 1000000.0 - } - }); - } else { - entity.position = currentPos as any; - if (entity.billboard) { - entity.billboard.rotation = Cesium.Math.toRadians(-s.heading || 0) as any; - } - } - - touchedShipIds.add(shipId); - }); - } - - // Process Earthquakes - if (data.earthquakes && activeLayers?.earthquakes !== false) { - data.earthquakes.forEach((q: any) => { - const color = q.mag > 5 ? Cesium.Color.RED : Cesium.Color.ORANGE; - addOrUpdate({ - id: `quake - ${q.id}`, - position: Cesium.Cartesian3.fromDegrees(q.lng, q.lat, 0), - point: { - pixelSize: q.mag * 3, - color: Cesium.Color.fromCssColorString('rgba(0,0,0,0)'), - outlineColor: color, - outlineWidth: 2 - }, - label: { - text: `[M${q.mag.toFixed(1)} ]\n${q.place.substring(0, 20)}`, - font: 'bold 9px monospace', - fillColor: color, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -10), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 5000000.0) - } - }); - }); - } - - // 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); - } - - // Process Airports - if (data.airports) { - const findByIcao = (list: any[]) => list?.find((f: any) => f.icao24 === selectedEntity?.id); - const selectedFlight = (selectedEntity?.type === 'flight' || selectedEntity?.type === 'military_flight' || selectedEntity?.type === 'private_flight' || selectedEntity?.type === 'private_jet' || selectedEntity?.type === 'tracked_flight') - ? (selectedEntity?.type === 'flight' ? findByIcao(data.commercial_flights) - : selectedEntity?.type === 'private_flight' ? findByIcao(data.private_flights) - : selectedEntity?.type === 'private_jet' ? findByIcao(data.private_jets) - : selectedEntity?.type === 'tracked_flight' ? findByIcao(data.tracked_flights) - : findByIcao(data.military_flights)) - : null; - - data.airports.forEach((apt: any) => { - const isExplicitlySelected = selectedEntity?.type === 'airport' && String(selectedEntity.id) === String(apt.id); - const isOrigin = selectedFlight && selectedFlight.origin_name && selectedFlight.origin_name.startsWith(apt.iata); - const isDest = selectedFlight && selectedFlight.dest_name && selectedFlight.dest_name.startsWith(apt.iata); - - const showAirport = isExplicitlySelected || isOrigin || isDest; - - addOrUpdate({ - id: `apt-${apt.id}`, - show: showAirport, - position: Cesium.Cartesian3.fromDegrees(apt.lng, apt.lat, 0), - point: { - pixelSize: 6, - color: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: 1000000.0 - }, - label: { - show: showAirport, - text: `(${apt.iata}: ${apt.name})`, - font: 'bold 10px monospace', - fillColor: Cesium.Color.WHITE, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -10), - // Only show labels when reasonably zoomed in to prevent map clutter (approx 5M meters) - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 5000000.0) - } - }); - }); - } - - // Process CCTV (clustered) - if (data.cctv && activeLayers?.cctv !== false && cctvDS) { - data.cctv.forEach((cam: any) => { - const lng = cam.lon !== undefined ? cam.lon : cam.lng; - if (lng === undefined || cam.lat === undefined) return; - - const cctvId = `cctv-${cam.id}`; - touchedCctvIds.add(cctvId); - - const existing = cctvDS.entities.getById(cctvId); - if (existing) { - existing.position = Cesium.Cartesian3.fromDegrees(lng, cam.lat, 0); - } else { - cctvDS.entities.add({ - id: cctvId, - position: Cesium.Cartesian3.fromDegrees(lng, cam.lat, 0), - point: { - pixelSize: 8, - color: Cesium.Color.LIME, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: 1000000.0 - }, - label: { - text: `[CCTV: ${cam.direction_facing ? cam.direction_facing.substring(0, 15) : 'Camera'}... ]`, - font: 'bold 10px monospace', - fillColor: Cesium.Color.LIME, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -10), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 500000.0) - } - }); - } - }); - } else if (cctvDS) { - // Layer toggled off — remove all CCTV entities - cctvDS.entities.removeAll(); - } - - // Bikeshare removed per user request - - - // Process Traffic Accidents/Signs removed per User request to declutter CCTV - - // Process Satellites - if (data.satellites && activeLayers?.satellites !== false) { - const date = new Date(); - data.satellites.forEach((sat: any, idx: number) => { - try { - const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); - const positionAndVelocity = satellite.propagate(satrec, date); - const gmst = satellite.gstime(date); - if (positionAndVelocity && (positionAndVelocity as any).position && typeof (positionAndVelocity as any).position !== 'boolean') { - const positionGd = satellite.eciToGeodetic((positionAndVelocity as any).position, gmst); - const longitude = satellite.degreesLong(positionGd.longitude); - const latitude = satellite.degreesLat(positionGd.latitude); - const height = positionGd.height * 1000; - - addOrUpdate({ - id: `satellite-${sat.id}`, - position: Cesium.Cartesian3.fromDegrees(longitude, latitude, height), - point: { - pixelSize: 6, - color: Cesium.Color.AQUA, - disableDepthTestDistance: 1000000.0 - }, - label: { - text: `[ SAT: ${sat.name} ]`, - font: 'bold 10px monospace', - fillColor: Cesium.Color.AQUA, - backgroundColor: Cesium.Color.fromCssColorString('rgba(0,0,0,0.8)'), - showBackground: true, - style: Cesium.LabelStyle.FILL, - verticalOrigin: Cesium.VerticalOrigin.BOTTOM, - pixelOffset: new Cesium.Cartesian2(0, -10), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, 10000000.0), - disableDepthTestDistance: 1000000.0 - } - }); - - // Add Orbital Path (sampled for performance) - if (idx % 20 === 0) { // Only show paths for every 20th satellite - const orbitPoints = []; - for (let i = 0; i < 90; i += 5) { - const futureDate = new Date(date.getTime() + i * 60000); - const fPV = satellite.propagate(satrec, futureDate); - const fGmst = satellite.gstime(futureDate); - if (fPV && (fPV as any).position && typeof (fPV as any).position !== 'boolean') { - const fGd = satellite.eciToGeodetic((fPV as any).position, fGmst); - orbitPoints.push(satellite.degreesLong(fGd.longitude)); - orbitPoints.push(satellite.degreesLat(fGd.latitude)); - orbitPoints.push(fGd.height * 1000); - } - } - if (orbitPoints.length > 3) { - addOrUpdate({ - polyline: { - positions: Cesium.Cartesian3.fromDegreesArrayHeights(orbitPoints), - width: 1, - material: new Cesium.PolylineGlowMaterialProperty({ - glowPower: 0.1, - color: Cesium.Color.AQUA.withAlpha(0.3) - }) - } - }); - } - } - } - } catch (e) { } - }); - } - - // Draw orbital path if satellite selected - if (selectedEntity && selectedEntity.type === 'satellite') { - try { - const satrec = satellite.twoline2satrec(selectedEntity.tle1, selectedEntity.tle2); - const positions = []; - const date = new Date(); - // 100 minutes forward for LEO orbit track - for (let i = 0; i <= 100; i++) { - const d = new Date(date.getTime() + i * 60000); - const p = satellite.propagate(satrec, d); - if (p && (p as any).position && typeof (p as any).position !== 'boolean') { - const gmst = satellite.gstime(d); - const posGd = satellite.eciToGeodetic((p as any).position, gmst); - positions.push(satellite.degreesLong(posGd.longitude)); - positions.push(satellite.degreesLat(posGd.latitude)); - positions.push(posGd.height * 1000); - } - } - - if (positions.length > 0) { - addOrUpdate({ - id: `orbit - ${selectedEntity.entityId}`, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArrayHeights(positions), - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.CYAN, - dashLength: 16.0 - }) - } - }); - } - } catch (e) { } - } - - // Prune unused entities from viewer.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(); - - // Prune stale ships from clustered data source - if (shipDS) { - const entities = shipDS.entities.values; - for (let i = entities.length - 1; i >= 0; i--) { - const id = entities[i].id; - if (!touchedShipIds.has(id)) { - shipDS.entities.removeById(id); - } - } - shipDS.entities.resumeEvents(); - } - - // Prune stale CCTV from clustered data source - if (cctvDS) { - const entities = cctvDS.entities.values; - for (let i = entities.length - 1; i >= 0; i--) { - const id = entities[i].id; - if (!touchedCctvIds.has(id)) { - cctvDS.entities.removeById(id); - } - } - cctvDS.entities.resumeEvents(); - } - }, [data, activeLayers, effects, selectedEntity]); - - return ( -
- {/* IN-MAP CONTEXT OVERLAYS */} - {selectedEntity && selectedEntity.type === 'news' && popupPosition && ( -
-
- {(() => { - const cluster = data?.news?.[selectedEntity.id as number]; - if (!cluster) return null; - return ( -
- {cluster.machine_assessment && ( -
-
- >_ SYS.ANALYSIS: - {cluster.machine_assessment} -
- )} - -
- {cluster.articles && cluster.articles.map((item: any, idx: number) => { - const isHigh = item.risk_score >= 5; - const titleClass = isHigh ? "text-red-300 font-bold" : "text-cyan-300 font-medium"; - return ( -
-
- - >_ {item.source} - - LVL: {item.risk_score}/10 -
- - {item.title} - -
- ); - })} -
-
- ); - })()} -
-
-
- )} - - {selectedEntity && selectedEntity.type === 'cctv' && popupPosition && ( -
-
- {(() => { - const cam = data?.cctv?.find((c: any) => String(c.id) === String(selectedEntity.id)); - if (!cam) return null; - return ( -
-
- - >_ {cam.source_agency || 'INTERCEPT'} - - LIVE -
-
-
- [ FEED DIVERTED TO HOLOGRAPHIC MESH ] -
-
- REC // {cam.id} -
-
-
- {cam.direction_facing || 'UNKNOWN MOUNT'} -
-
- ); - })()} - {/* Connecting line to the marker */} -
-
-
- )} - - {selectedEntity && selectedEntity.type === 'gdelt' && popupPosition && ( -
-
- {(() => { - const incident = data?.gdelt?.[selectedEntity.id as number]; - if (!incident) return null; - const props = incident.properties || {}; - // Use regex to strip GDELT's inline a-tags so we can render cleanly or just render dangerously - return ( -
-
- - >_ KINETIC EVENT - - MILITARY -
-
- {props.location || props.name || 'UNKNOWN LOCATION'} -
-
-
- ); - })()} -
-
-
- )} -
- ); -} diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index 9bc38da..6b42a99 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -2,59 +2,56 @@ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { X, Zap, Shield, Satellite, MapPin, Palette, ToggleRight, Bug, Heart } from "lucide-react"; +import { X, Zap, Ship, Download, Shield, Bug, Heart } from "lucide-react"; -const CURRENT_VERSION = "0.8"; +const CURRENT_VERSION = "0.9"; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; const NEW_FEATURES = [ { - icon: , - title: "POTUS Fleet Tracking", - desc: "Air Force One, Air Force Two, and Marine One aircraft now display with oversized hot-pink icons and a gold dashed halo ring — instantly recognizable on the map.", - color: "pink", - }, - { - icon: , - title: "Full Aircraft Color-Coding", - desc: "9-color system: military (yellow), medical/rescue (lime), police/government (blue), privacy (black), VIPs (hot pink), dictators/oligarchs (red), and more — all enriched from plane_alert_db.", - color: "yellow", - }, - { - icon: , - title: "Sentinel-2 Satellite Overhaul", - desc: "Replaced the tiny satellite popup with a fullscreen image overlay. Added Download, Copy to Clipboard, and Open Full Res buttons. Green dossier-themed UI.", - color: "green", - }, - { - icon: , - title: "Region Dossier & Carrier Fidelity", - desc: "Fixed Nominatim 429 rate-limit errors with retry/backoff. Carriers at shared homeports now dock at distinct pier positions instead of stacking.", - color: "blue", - }, - { - icon: , - title: "Overhauled Map Legend & Controls", - desc: "Full 9-color aircraft legend with POTUS fleet, wildfires, and infrastructure sections. New version badge, update checker, and Discussions shortcut in the UI.", + icon: , + title: "In-App Auto-Updater", + desc: "One-click updates directly from the dashboard. Downloads the latest release, backs up your files, extracts over the project, and auto-restarts. Manual download fallback included if anything goes wrong.", color: "cyan", }, { - icon: , - title: "Toggle All Data Layers", - desc: "One-click button to enable/disable all data layers at once. Turns cyan when active. MODIS Terra excluded from bulk toggle to prevent accidental imagery load.", - color: "purple", + icon: , + title: "Granular Ship Layer Controls", + desc: "Ships split into 4 independent toggles: Military/Carriers, Cargo/Tankers, Civilian Vessels, and Cruise/Passenger. Each shows its own live count in the sidebar.", + color: "blue", + }, + { + icon: , + title: "Stable Entity Selection", + desc: "Ship and flight markers now use MMSI/callsign IDs instead of volatile array indices. Selecting a ship or plane stays locked on even when data refreshes every 60 seconds.", + color: "green", + }, + { + icon: , + title: "Dismissible Threat Alerts", + desc: "Click the X on any threat alert bubble to dismiss it for the session. Uses stable content hashing so dismissed alerts stay hidden across 60-second data refreshes.", + color: "red", + }, + { + icon: , + title: "Faster Data Loading", + desc: "GDELT military incidents now load instantly with background title enrichment instead of blocking for 2+ minutes. Eliminated duplicate startup fetch jobs for faster boot.", + color: "yellow", }, ]; const BUG_FIXES = [ - "POTUS fleet ICAO codes expanded — all Air Force Two (C-32A/B) airframes now correctly identified with gold halo", - "POTUS icon priority fixed — presidential aircraft always show the POTUS icon even when grounded", - "Sentinel-2 imagery no longer overlaps the bottom coordinate bar", - "Docker ENV format warnings resolved (legacy syntax → key=value)", - "Settings/Key/Version buttons now cyan in dark mode, grey only in light mode", + "Removed viewport bbox filtering that caused 20-second delays when panning between regions", + "Fixed carrier tracker crash on GDELT 429/TypeError responses", + "Removed fake intelligence assessment generator — all data is now real OSINT only", + "Docker healthcheck start_period increased to 90s to prevent false-negative restarts during data preload", + "ETag collision fix — full payload hash instead of first 256 chars", + "Concurrent /api/refresh guard prevents duplicate data fetches", ]; const CONTRIBUTORS = [ + { name: "@imqdcr", desc: "Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers" }, + { name: "@csysp", desc: "Dismissible threat alert bubbles with stable content hashing + stopPropagation crash fix", pr: "#48" }, { name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" }, ]; diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 1460e3c..5bb3578 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -5,284 +5,44 @@ import React, { useMemo, useState, useEffect, useCallback, useRef } from "react" import Map, { Source, Layer, MapRef, ViewState, Popup, Marker } from "react-map-gl/maplibre"; import "maplibre-gl/dist/maplibre-gl.css"; import { computeNightPolygon } from "@/utils/solarTerminator"; +import { interpolatePosition } from "@/utils/positioning"; +import { darkStyle, lightStyle } from "@/components/map/styles/mapStyles"; import ScaleBar from "@/components/ScaleBar"; import maplibregl from "maplibre-gl"; import { AlertTriangle } from "lucide-react"; import WikiImage from "@/components/WikiImage"; import { useTheme } from "@/lib/ThemeContext"; -const svgPlaneCyan = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneOrange = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlanePurple = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgFighter = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeli = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliCyan = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliOrange = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliPurple = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgTanker = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgRecon = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgWarning = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgThreat = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgTriangleYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgTriangleRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; - -// --- Aircraft type-specific SVG paths (top-down silhouettes) --- -// Airliner: wide swept wings with engine pods, narrow fuselage -const AIRLINER_PATH = "M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"; -// Turboprop: straight high wings, shorter body -const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"; -// Bizjet: sleek, small swept wings, T-tail -const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"; - -// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) --- -function makeFireSvg(fill: string, innerFill: string, size = 18) { - // Multi-forked flame: main body + left tongue + right tongue + inner glow - return `data:image/svg+xml;utf8,${encodeURIComponent( - `` + - // Main flame body (wide base, pointed top) - `` + - // Left tongue (forks out left from top) - `` + - // Right tongue (forks out right from top) - `` + - // Inner bright core - `` + - `` - )}`; -} -const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16); -const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18); -const svgFireRed = makeFireSvg('#ff2200', '#ff8800', 20); -const svgFireDarkRed = makeFireSvg('#cc0000', '#ff2200', 22); -// Larger fire icons for cluster markers (visually distinct from Global Incidents circles) -const svgFireClusterSmall = makeFireSvg('#ff6600', '#ffcc00', 32); -const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40); -const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48); -const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56); - -function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) { - const paths: Record = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" }; - const p = paths[type] || paths.generic; - // Airliner gets engine pod circles - const extras = type === 'airliner' ? `` : ''; - return `data:image/svg+xml;utf8,${encodeURIComponent(`${extras}`)}`; -} - -// POTUS fleet — oversized hot pink with yellow halo ring -const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; - -// POTUS fleet ICAO hex codes (verified FAA registry) -const POTUS_ICAOS = new Set([ - 'ADFDF8','ADFDF9', // Air Force One (VC-25A) - 'ADFEB7','ADFEB8','ADFEB9','ADFEBA', // Air Force Two (C-32A) - 'AE4AE6','AE4AE8','AE4AEA','AE4AEC', // Air Force Two (C-32B) - 'AE0865','AE5E76','AE5E77','AE5E79', // Marine One (VH-3D / VH-92A) -]); - -// Pre-built aircraft SVGs by type & color -const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan'); -const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00'); -const svgAirlinerPurple = makeAircraftSvg('airliner', '#9B59B6'); -const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow'); -const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22); -const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22); -const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22); -const svgAirlinerBlue = makeAircraftSvg('airliner', '#3b82f6', 'black', 22); -const svgAirlinerLime = makeAircraftSvg('airliner', '#32CD32', 'black', 22); -const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22); -const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22); - -const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan'); -const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00'); -const svgTurbopropPurple = makeAircraftSvg('turboprop', '#9B59B6'); -const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow'); -const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22); -const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22); -const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22); -const svgTurbopropBlue = makeAircraftSvg('turboprop', '#3b82f6', 'black', 22); -const svgTurbopropLime = makeAircraftSvg('turboprop', '#32CD32', 'black', 22); -const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22); -const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22); - -const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan'); -const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00'); -const svgBizjetPurple = makeAircraftSvg('bizjet', '#9B59B6'); -const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow'); -const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22); -const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22); -const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22); -const svgBizjetBlue = makeAircraftSvg('bizjet', '#3b82f6', 'black', 22); -const svgBizjetLime = makeAircraftSvg('bizjet', '#32CD32', 'black', 22); -const svgBizjetBlack = makeAircraftSvg('bizjet', '#222', '#555', 22); -const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#666', 22); - -// Grey variants for grounded/parked aircraft (altitude 0) -const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333'); -const svgTurbopropGrey = makeAircraftSvg('turboprop', '#555', '#333'); -const svgBizjetGrey = makeAircraftSvg('bizjet', '#555', '#333'); -const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; - -// Grey icon map for grounded aircraft -const GROUNDED_ICON_MAP: Record = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' }; - -// Per-layer color maps (module-level to avoid re-allocation every render tick) -const COLOR_MAP_COMMERCIAL: Record = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' }; -const COLOR_MAP_PRIVATE: Record = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' }; -const COLOR_MAP_JETS: Record = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' }; -const COLOR_MAP_MILITARY: Record = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' }; -const MIL_SPECIAL_MAP: Record = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' }; - -// ICAO type code -> aircraft shape classification -const HELI_TYPES = new Set(['R22', 'R44', 'R66', 'B06', 'B05', 'B47G', 'B105', 'B212', 'B222', 'B230', 'B407', 'B412', 'B429', 'B430', 'B505', 'BK17', 'S55', 'S58', 'S61', 'S64', 'S70', 'S76', 'S92', 'A109', 'A119', 'A139', 'A169', 'A189', 'AW09', 'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'EC75', 'H125', 'H130', 'H135', 'H145', 'H155', 'H160', 'H175', 'H215', 'H225', 'AS32', 'AS35', 'AS50', 'AS55', 'AS65', 'MD52', 'MD60', 'MDHI', 'MD90', 'NOTR', 'HUEY', 'GAMA', 'CABR', 'EXE', 'R300', 'R480', 'LAMA', 'ALLI', 'PUMA', 'NH90', 'CH47', 'UH1', 'UH60', 'AH64', 'MI8', 'MI24', 'MI26', 'MI28', 'KA52', 'K32', 'LYNX', 'WILD', 'MRLX', 'A149', 'A119']); -const TURBOPROP_TYPES = new Set(['AT43', 'AT45', 'AT72', 'AT73', 'AT75', 'AT76', 'B190', 'B350', 'BE20', 'BE30', 'BE40', 'BE9L', 'BE99', 'C130', 'C160', 'C208', 'C212', 'C295', 'CN35', 'D228', 'D328', 'DHC2', 'DHC3', 'DHC4', 'DHC5', 'DHC6', 'DHC7', 'DHC8', 'DO28', 'DH8A', 'DH8B', 'DH8C', 'DH8D', 'E110', 'E120', 'F27', 'F406', 'F50', 'G159', 'G73T', 'J328', 'JS31', 'JS32', 'JS41', 'L188', 'MA60', 'M28', 'N262', 'P68', 'P180', 'PA31', 'PA42', 'PC12', 'PC21', 'PC24', 'S2', 'S340', 'SF34', 'SF50', 'SW4', 'TRIS', 'TBM7', 'TBM8', 'TBM9', 'C30J', 'C5M', 'AN12', 'AN24', 'AN26', 'AN30', 'AN32', 'IL18', 'L410', 'Y12', 'BALL', 'AEST', 'AC68', 'AC80', 'AC90', 'AC95', 'AC11', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', 'C402', 'C414', 'C421', 'C425', 'C441', 'M20P', 'M20T', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'PA60', 'P28A', 'P28B', 'P28R', 'P32R', 'P46T', 'SR20', 'SR22', 'DA40', 'DA42', 'DA62', 'RV10', 'BE33', 'BE35', 'BE36', 'BE55', 'BE58', 'DR40', 'TB20', 'AA5']); -const BIZJET_TYPES = new Set(['ASTR', 'C25A', 'C25B', 'C25C', 'C25M', 'C500', 'C501', 'C510', 'C525', 'C526', 'C550', 'C551', 'C560', 'C56X', 'C650', 'C680', 'C700', 'C750', 'CL30', 'CL35', 'CL60', 'CONI', 'CRJX', 'E35L', 'E45X', 'E50P', 'E55P', 'F2TH', 'F900', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'G100', 'G150', 'G200', 'G280', 'GA5C', 'GA6C', 'GALX', 'GL5T', 'GL7T', 'GLEX', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'H25A', 'H25B', 'H25C', 'HA4T', 'HDJT', 'LJ23', 'LJ24', 'LJ25', 'LJ28', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60', 'LJ70', 'LJ75', 'MU30', 'PC24', 'PRM1', 'SBR1', 'SBR2', 'WW24', 'BE40', 'BLCF']); - -function classifyAircraft(model: string, category?: string): 'heli' | 'turboprop' | 'bizjet' | 'airliner' { - const m = (model || '').toUpperCase(); - if (category === 'heli' || HELI_TYPES.has(m)) return 'heli'; - if (BIZJET_TYPES.has(m)) return 'bizjet'; - if (TURBOPROP_TYPES.has(m)) return 'turboprop'; - return 'airliner'; -} - -// --- Smooth position interpolation helpers --- -// Given heading (degrees) and speed (knots), compute new lat/lng after dt seconds -function interpolatePosition(lat: number, lng: number, headingDeg: number, speedKnots: number, dtSeconds: number, maxDist = 0, maxDt = 65): [number, number] { - if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng]; - // Cap interpolation time to prevent runaway drift when data is stale - const clampedDt = Math.min(dtSeconds, maxDt); - // 1 knot = 1 nautical mile/hour = 1852 m/h - const speedMps = speedKnots * 0.5144; // meters per second - const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt; - const R = 6371000; // Earth radius in meters - const headingRad = (headingDeg * Math.PI) / 180; - const latRad = (lat * Math.PI) / 180; - const lngRad = (lng * Math.PI) / 180; - const newLatRad = Math.asin( - Math.sin(latRad) * Math.cos(dist / R) + - Math.cos(latRad) * Math.sin(dist / R) * Math.cos(headingRad) - ); - const newLngRad = lngRad + Math.atan2( - Math.sin(headingRad) * Math.sin(dist / R) * Math.cos(latRad), - Math.cos(dist / R) - Math.sin(latRad) * Math.sin(newLatRad) - ); - return [(newLatRad * 180) / Math.PI, (newLngRad * 180) / Math.PI]; -} - -const darkStyle = { - version: 8, - glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", - sources: { - 'carto-dark': { - type: 'raster', - tiles: [ - "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", - "https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", - "https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", - "https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png" - ], - tileSize: 256 - } - }, - layers: [ - { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } } - ] -}; - -const lightStyle = { - version: 8, - glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", - sources: { - 'carto-light': { - type: 'raster', - tiles: [ - "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", - "https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", - "https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", - "https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png" - ], - tileSize: 256 - } - }, - layers: [ - { id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 }, - { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } } - ] -}; - -// Satellite icon SVG builder — module-level constant (no re-creation per render) -const makeSatSvg = (color: string) => { - const svg = ` - - - - - - - `; - return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); -}; -const MISSION_COLORS: Record = { - 'military_recon': '#ff3333', 'military_sar': '#ff3333', - 'sar': '#00e5ff', 'sigint': '#ffffff', - 'navigation': '#4488ff', 'early_warning': '#ff00ff', - 'commercial_imaging': '#44ff44', 'space_station': '#ffdd00' -}; -const MISSION_ICON_MAP: Record = { - 'military_recon': 'sat-mil', 'military_sar': 'sat-mil', - 'sar': 'sat-sar', 'sigint': 'sat-sigint', - 'navigation': 'sat-nav', 'early_warning': 'sat-ew', - 'commercial_imaging': 'sat-com', 'space_station': 'sat-station' -}; - -// Empty GeoJSON constant — avoids recreating empty objects on every render -const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; - -// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation. -// This is critical for high-volume layers (flights, ships, satellites, fires) where -// React's prop diffing on thousands of coordinate arrays causes memory pressure. -function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) { - const timerRef = useRef | null>(null); - useEffect(() => { - if (!map) return; - const push = () => { - const src = map.getSource(sourceId) as any; - if (src && typeof src.setData === 'function') { - src.setData(geojson || EMPTY_FC); - } - }; - if (debounceMs > 0) { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(push, debounceMs); - return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - } - push(); - }, [map, sourceId, geojson, debounceMs]); -} +import { + svgPlaneCyan, svgPlaneYellow, svgPlaneOrange, svgPlanePurple, + svgFighter, svgHeli, svgHeliCyan, svgHeliOrange, svgHeliPurple, + svgTanker, svgRecon, svgPlanePink, svgPlaneAlertRed, svgPlaneDarkBlue, + svgPlaneWhiteAlert, svgHeliPink, svgHeliAlertRed, svgHeliDarkBlue, + svgHeliBlue, svgHeliLime, svgHeliWhiteAlert, svgPlaneBlack, svgHeliBlack, + svgDrone, svgDataCenter, svgShipGray, svgShipRed, svgShipYellow, + svgShipBlue, svgShipWhite, svgCarrier, svgCctv, svgWarning, svgThreat, + svgTriangleYellow, svgTriangleRed, + svgFireYellow, svgFireOrange, svgFireRed, svgFireDarkRed, + svgFireClusterSmall, svgFireClusterMed, svgFireClusterLarge, svgFireClusterXL, + svgPotusPlane, svgPotusHeli, POTUS_ICAOS, + svgAirlinerCyan, svgAirlinerOrange, svgAirlinerPurple, svgAirlinerYellow, + svgAirlinerPink, svgAirlinerRed, svgAirlinerDarkBlue, svgAirlinerBlue, + svgAirlinerLime, svgAirlinerBlack, svgAirlinerWhite, + svgTurbopropCyan, svgTurbopropOrange, svgTurbopropPurple, svgTurbopropYellow, + svgTurbopropPink, svgTurbopropRed, svgTurbopropDarkBlue, svgTurbopropBlue, + svgTurbopropLime, svgTurbopropBlack, svgTurbopropWhite, + svgBizjetCyan, svgBizjetOrange, svgBizjetPurple, svgBizjetYellow, + svgBizjetPink, svgBizjetRed, svgBizjetDarkBlue, svgBizjetBlue, + svgBizjetLime, svgBizjetBlack, svgBizjetWhite, + svgAirlinerGrey, svgTurbopropGrey, svgBizjetGrey, svgHeliGrey, + GROUNDED_ICON_MAP, COLOR_MAP_COMMERCIAL, COLOR_MAP_PRIVATE, + COLOR_MAP_JETS, COLOR_MAP_MILITARY, MIL_SPECIAL_MAP, +} from "@/components/map/icons/AircraftIcons"; +import { classifyAircraft } from "@/utils/aircraftClassification"; +import { makeSatSvg, MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons"; +import { EMPTY_FC } from "@/components/map/mapConstants"; +import { useImperativeSource } from "@/components/map/hooks/useImperativeSource"; +import { ClusterCountLabels, TrackedFlightLabels, CarrierLabels, UavLabels, EarthquakeLabels, ThreatMarkers } from "@/components/map/MapMarkers"; const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, selectedEntity, onMouseCoords, onRightClick, regionDossier, regionDossierLoading, onViewStateChange, measureMode, onMeasureClick, measurePoints, gibsDate, gibsOpacity }: any) => { const mapRef = useRef(null); @@ -799,7 +559,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele const [iLng, iLat] = interpFlight(f); return { type: 'Feature', - properties: { id: f.icao24 || i, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_COMMERCIAL[acType] }, + properties: { id: f.callsign || f.icao24 || `flight-${i}`, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_COMMERCIAL[acType] }, geometry: { type: 'Point', coordinates: [iLng, iLat] } }; }).filter(Boolean) @@ -819,7 +579,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele const [iLng, iLat] = interpFlight(f); return { type: 'Feature', - properties: { id: f.icao24 || i, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_PRIVATE[acType] }, + properties: { id: f.callsign || f.icao24 || `pflight-${i}`, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_PRIVATE[acType] }, geometry: { type: 'Point', coordinates: [iLng, iLat] } }; }).filter(Boolean) @@ -839,7 +599,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele const [iLng, iLat] = interpFlight(f); return { type: 'Feature', - properties: { id: f.icao24 || i, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_JETS[acType] }, + properties: { id: f.callsign || f.icao24 || `pjet-${i}`, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_JETS[acType] }, geometry: { type: 'Point', coordinates: [iLng, iLat] } }; }).filter(Boolean) @@ -867,7 +627,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele const [iLng, iLat] = interpFlight(f); return { type: 'Feature', - properties: { id: f.icao24 || i, type: 'military_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId }, + properties: { id: f.callsign || f.icao24 || `mflight-${i}`, type: 'military_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId }, geometry: { type: 'Point', coordinates: [iLng, iLat] } }; }).filter(Boolean) @@ -875,31 +635,37 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele }, [activeLayers.military, data?.military_flights, trackedIcaoSet, dtSeconds, inView]); const shipsGeoJSON = useMemo(() => { - if (!(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) || !data?.ships) return null; + if (!(activeLayers.ships_military || activeLayers.ships_cargo || activeLayers.ships_civilian || activeLayers.ships_passenger) || !data?.ships) return null; return { type: 'FeatureCollection', features: data.ships.map((s: any, i: number) => { if (s.lat == null || s.lng == null) return null; if (!inView(s.lat, s.lng)) return null; - const isImportant = s.type === 'carrier' || s.type === 'military_vessel' || s.type === 'tanker' || s.type === 'cargo'; + const isMilitary = s.type === 'carrier' || s.type === 'military_vessel'; + const isCargo = s.type === 'tanker' || s.type === 'cargo'; const isPassenger = s.type === 'passenger'; - if (s.type === 'carrier') return null; - if (isImportant && activeLayers?.ships_important === false) return null; + + if (s.type === 'carrier') return null; // Handled by carriersGeoJSON + + if (isMilitary && activeLayers?.ships_military === false) return null; + if (isCargo && activeLayers?.ships_cargo === false) return null; if (isPassenger && activeLayers?.ships_passenger === false) return null; - if (!isImportant && !isPassenger && activeLayers?.ships_civilian === false) return null; + if (!isMilitary && !isCargo && !isPassenger && activeLayers?.ships_civilian === false) return null; + let iconId = 'svgShipBlue'; - if (s.type === 'tanker' || s.type === 'cargo') iconId = 'svgShipRed'; - else if (s.type === 'yacht' || s.type === 'passenger') iconId = 'svgShipWhite'; - else if (s.type === 'military_vessel') iconId = 'svgShipYellow'; + if (isCargo) iconId = 'svgShipRed'; + else if (s.type === 'yacht' || isPassenger) iconId = 'svgShipWhite'; + else if (isMilitary) iconId = 'svgShipYellow'; + const [iLng, iLat] = interpShip(s); return { type: 'Feature', - properties: { id: s.mmsi || i, type: 'ship', name: s.name, rotation: s.heading || 0, iconId }, + properties: { id: s.mmsi || s.name || `ship-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId }, geometry: { type: 'Point', coordinates: [iLng, iLat] } }; }).filter(Boolean) }; - }, [activeLayers.ships_important, activeLayers.ships_civilian, activeLayers.ships_passenger, data?.ships, inView]); + }, [activeLayers.ships_military, activeLayers.ships_cargo, activeLayers.ships_civilian, activeLayers.ships_passenger, data?.ships, inView]); // Extract ship cluster positions from the map source for HTML labels const shipClusterHandlerRef = useRef<(() => void) | null>(null); @@ -975,19 +741,19 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele }, [earthquakesGeoJSON]); const carriersGeoJSON = useMemo(() => { - if (!activeLayers.ships_important || !data?.ships) return null; + if (!activeLayers.ships_military || !data?.ships) return null; return { type: 'FeatureCollection', features: data.ships.map((s: any, i: number) => { if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null; return { type: 'Feature', - properties: { id: s.mmsi || i, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' }, + properties: { id: s.mmsi || s.name || `carrier-${i}`, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' }, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }; }).filter(Boolean) }; - }, [activeLayers.ships_important, data?.ships]); + }, [activeLayers.ships_military, data?.ships]); const activeRouteGeoJSON = useMemo(() => { if (!selectedEntity || !data) return null; @@ -1180,11 +946,17 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele item.offsetY = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, item.offsetY)); } - return items.map((item: any) => ({ - ...item, - showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5 - })); - }, [data?.news, Math.round(viewState.zoom)]); + return items + .filter((item: any) => { + const alertKey = `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`; + return !dismissedAlerts.has(alertKey); + }) + .map((item: any) => ({ + ...item, + alertKey: `${item.title}|${item.coords?.[0]},${item.coords?.[1]}`, + showLine: Math.abs(item.offsetX) > 5 || Math.abs(item.offsetY) > 5 + })); + }, [data?.news, Math.round(viewState.zoom), dismissedAlerts]); // Tracked flights GeoJSON with interpolation const trackedFlightsGeoJSON = useMemo(() => { @@ -1802,246 +1574,35 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele )} {/* HTML labels for ship cluster counts (hidden when any entity popup is active) */} - {shipsGeoJSON && !selectedEntity && shipClusters.map((c: any) => ( - -
- {c.count} -
-
- ))} + {shipsGeoJSON && !selectedEntity && } {/* HTML labels for tracked flights — color-matched, zoom-gated for non-HVA */} - {trackedFlightsGeoJSON && !selectedEntity && data?.tracked_flights?.map((f: any, i: number) => { - if (f.lat == null || f.lng == null) return null; - if (!inView(f.lat, f.lng)) return null; - - const alertColor = f.alert_color || '#ff1493'; - // Always hide military labels (yellow) — too many, clutters map - if (alertColor === 'yellow') return null; - // Hide black (PIA) labels — they want to stay hidden - if (alertColor === 'black') return null; - - // Only show non-HVA/non-red labels when zoomed in (~2000mi or closer = zoom >= 5) - const isHighPriority = alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red'; - if (!isHighPriority && viewState.zoom < 5) return null; - - let displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN"; - // Strip redundant "Private" labels — tells you nothing - if (displayName === 'Private' || displayName === 'private') return null; - - // Map alert_color to a visible label color (some hex colors render near-white) - const labelColorMap: Record = { - '#ff1493': '#ff1493', pink: '#ff1493', red: '#ff4444', - blue: '#3b82f6', orange: '#FF8C00', '#32cd32': '#32cd32', - purple: '#b266ff', white: '#cccccc', - }; - const grounded = f.alt != null && f.alt <= 100; - const labelColor = grounded ? '#888' : (labelColorMap[alertColor] || alertColor); - const [iLng, iLat] = interpFlight(f); - - return ( - -
- {String(displayName)} -
-
- ); - })} + {trackedFlightsGeoJSON && !selectedEntity && data?.tracked_flights && ( + + )} {/* HTML labels for carriers (orange names, with ESTIMATED badge for OSINT positions) */} - {carriersGeoJSON && !selectedEntity && data?.ships?.map((s: any, i: number) => { - if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null; - if (!inView(s.lat, s.lng)) return null; - const [iLng, iLat] = interpShip(s); - return ( - -
-
- [[{s.name}]] -
- {s.estimated && ( -
- EST. POSITION — OSINT -
- )} -
-
- ); - })} + {carriersGeoJSON && !selectedEntity && data?.ships && ( + + )} {/* HTML labels for earthquake cluster counts (hidden when any entity popup is active) */} - {earthquakesGeoJSON && !selectedEntity && eqClusters.map((c: any) => ( - -
- {c.count} -
-
- ))} + {earthquakesGeoJSON && !selectedEntity && } {/* HTML labels for UAVs (orange names) */} - {uavGeoJSON && !selectedEntity && data?.uavs?.map((uav: any, i: number) => { - if (uav.lat == null || uav.lng == null) return null; - if (!inView(uav.lat, uav.lng)) return null; - const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`; - return ( - -
- {name} -
-
- ); - })} + {uavGeoJSON && !selectedEntity && data?.uavs && ( + + )} {/* HTML labels for earthquakes (yellow) - only show when zoomed in (~2000 miles = zoom ~5) */} - {earthquakesGeoJSON && !selectedEntity && viewState.zoom >= 5 && data?.earthquakes?.map((eq: any, i: number) => { - if (eq.lat == null || eq.lng == null) return null; - if (!inView(eq.lat, eq.lng)) return null; - return ( - -
- [M{eq.mag}] {eq.place || ''} -
-
- ); - })} + {earthquakesGeoJSON && !selectedEntity && viewState.zoom >= 5 && data?.earthquakes && ( + + )} {/* Maplibre HTML Custom Markers for high-importance Threat Overlays (highest z-index) */} - {activeLayers.global_incidents && spreadAlerts.map((n: any) => { - const idx = n.originalIdx; - // Stable key: survives data.news array reorder/replacement across polling cycles - const alertKey = `${n.title || ''}_${(n.coords || []).join(',')}`; - if (dismissedAlerts.has(alertKey)) return null; - - const count = n.cluster_count || 1; - const score = n.risk_score || 0; - - let riskColor = '#22c55e'; // Green (0) - if (score >= 9) riskColor = '#ef4444'; // Red - else if (score >= 7) riskColor = '#f97316'; // Orange - else if (score >= 4) riskColor = '#eab308'; // Yellow - else if (score >= 1) riskColor = '#3b82f6'; // Blue - - // Hide alerts when any entity is selected (focus mode) - let isVisible = viewState.zoom >= 1; - if (selectedEntity) { - if (selectedEntity.type === 'news') { - if (selectedEntity.id !== idx) isVisible = false; - } else { - isVisible = false; - } - } - - return ( - { - e.originalEvent.stopPropagation(); - onEntityClick?.({ id: idx, type: 'news' }); - }} - > -
- {/* Connector Line */} - {n.showLine && isVisible && ( - - - - - )} - -
- {/* Bubble Tail */} - {n.showLine && isVisible && ( -
0 ? `6px solid ${riskColor}` : 'none', - left: '50%', - [n.offsetY < 0 ? 'bottom' : 'top']: '-6px', - transform: 'translateX(-50%)' - }} - /> - )} - - {/* Dismiss button */} - - -
-
!! ALERT LVL {score} !!
-
- {n.title} -
- {count > 1 && ( -
- [+{count - 1} ACTIVE THREATS IN AREA] -
- )} -
-
- - ); - })} + {activeLayers.global_incidents && ( + { setDismissedAlerts(prev => new Set(prev).add(alertKey)); if (selectedEntity?.type === 'news') onEntityClick?.(null); }} /> + )} {frontlineGeoJSON && ( @@ -2396,34 +1957,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele onClose={() => onEntityClick?.(null)} anchor="bottom" offset={12} > -
-
+
+
🛰️ {sat.name}
-
- NORAD ID: {sat.id} +
+ NORAD ID: {sat.id}
{sat.sat_type && ( -
- Type: {sat.sat_type} +
+ Type: {sat.sat_type}
)} {sat.country && ( -
- Country: {sat.country} +
+ Country: {sat.country}
)} {sat.mission && ( -
+
{missionLabels[sat.mission] || `⚪ ${sat.mission.toUpperCase()}`}
)} -
- Altitude: {sat.alt_km?.toLocaleString()} km +
+ Altitude: {sat.alt_km?.toLocaleString()} km
{sat.wiki && (
@@ -2446,48 +2003,44 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele onClose={() => onEntityClick?.(null)} anchor="bottom" offset={12} > -
-
+
+
{uav.callsign}
-
+
LIVE ADS-B TRANSPONDER
{uav.aircraft_model && ( -
- Model: {uav.aircraft_model} +
+ Model: {uav.aircraft_model}
)} {uav.uav_type && ( -
- Classification: {uav.uav_type} +
+ Classification: {uav.uav_type}
)} {uav.country && ( -
- Registration: {uav.country} +
+ Registration: {uav.country}
)} {uav.icao24 && ( -
- ICAO: {uav.icao24} +
+ ICAO: {uav.icao24}
)} -
- Altitude: {uav.alt?.toLocaleString()} m +
+ Altitude: {uav.alt?.toLocaleString()} m
{uav.speed_knots > 0 && ( -
- Speed: {uav.speed_knots} kn +
+ Speed: {uav.speed_knots} kn
)} {uav.squawk && ( -
- Squawk: {uav.squawk} +
+ Squawk: {uav.squawk}
)} {uav.wiki && ( @@ -2502,7 +2055,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele {/* Ship / carrier click popup */} {selectedEntity?.type === 'ship' && (() => { - const ship = data?.ships?.find((s: any) => s.mmsi === selectedEntity.id); + const ship = data?.ships?.find((s: any, i: number) => { + return (s.mmsi || s.name || `ship-${i}`) === selectedEntity.id || + (s.mmsi || s.name || `carrier-${i}`) === selectedEntity.id; + }); if (!ship) return null; const [iLng, iLat] = interpShip(ship); return ( @@ -2512,83 +2068,79 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele onClose={() => onEntityClick?.(null)} anchor="bottom" offset={12} > -
+
-
+
{ship.name || 'UNKNOWN VESSEL'}
{ship.estimated && ( -
+
ESTIMATED POSITION — {ship.source || 'OSINT DERIVED'}
)} {ship.type && ( -
- Type: {ship.type.replace('_', ' ')} +
+ Type: {ship.type.replace('_', ' ')}
)} {ship.mmsi && ( -
- MMSI: {ship.mmsi} +
+ MMSI: {ship.mmsi}
)} {ship.imo && ( -
- IMO: {ship.imo} +
+ IMO: {ship.imo}
)} {ship.callsign && ( -
- Callsign: {ship.callsign} +
+ Callsign: {ship.callsign}
)} {ship.country && ( -
- Flag: {ship.country} +
+ Flag: {ship.country}
)} {ship.destination && ( -
- Destination: {ship.destination} +
+ Destination: {ship.destination}
)} {typeof ship.sog === 'number' && ship.sog > 0 && ( -
- Speed: {ship.sog.toFixed(1)} kn +
+ Speed: {ship.sog.toFixed(1)} kn
)} -
+
Heading: {ship.heading != null ? `${Math.round(ship.heading)}°` : 'UNKNOWN'}
{ship.type === 'carrier' && ship.source && ( -
-
+
+
SOURCE: {ship.source_url ? ( {ship.source} + className="text-[#00e5ff] underline">{ship.source} ) : ( - {ship.source} + {ship.source} )}
{ship.last_osint_update && ( -
LAST OSINT UPDATE: {new Date(ship.last_osint_update).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
+
LAST OSINT UPDATE: {new Date(ship.last_osint_update).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
)} {ship.desc && ( -
{ship.desc}
+
{ship.desc}
)}
)} {ship.type !== 'carrier' && ship.last_osint_update && ( -
- Last OSINT Update: {new Date(ship.last_osint_update).toLocaleDateString()} +
+ Last OSINT Update: {new Date(ship.last_osint_update).toLocaleDateString()}
)}
@@ -2614,36 +2166,36 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele className="threat-popup" maxWidth="280px" > -
-
+
+
{dc.name}
{dc.company && ( -
- Operator: {dc.company} +
+ Operator: {dc.company}
)} {dc.street && ( -
- Address: {dc.street}{dc.zip ? ` ${dc.zip}` : ''} +
+ Address: {dc.street}{dc.zip ? ` ${dc.zip}` : ''}
)} {dc.city && ( -
- Location: {dc.city}{dc.country ? `, ${dc.country}` : ''} +
+ Location: {dc.city}{dc.country ? `, ${dc.country}` : ''}
)} {!dc.city && dc.country && ( -
- Country: {dc.country} +
+ Country: {dc.country}
)} {outagesInCountry.length > 0 && ( -
+
OUTAGE IN REGION — {outagesInCountry.map((o: any) => `${o.region_name} (${o.severity}%)`).join(', ')}
)} -
+
DATA CENTER
diff --git a/frontend/src/components/MarketsPanel.tsx b/frontend/src/components/MarketsPanel.tsx index 45f4f09..3d59f59 100644 --- a/frontend/src/components/MarketsPanel.tsx +++ b/frontend/src/components/MarketsPanel.tsx @@ -45,10 +45,10 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
[{ticker}]
- ${info.price.toFixed(2)} + ${(info.price ?? 0).toFixed(2)} {info.up ? : } - {Math.abs(info.change_percent).toFixed(2)}% + {Math.abs(info.change_percent ?? 0).toFixed(2)}%
@@ -65,10 +65,10 @@ const MarketsPanel = React.memo(function MarketsPanel({ data }: { data: any }) {
{name}
- ${info.price.toFixed(2)} + ${(info.price ?? 0).toFixed(2)} {info.up ? : } - {Math.abs(info.change_percent).toFixed(2)}% + {Math.abs(info.change_percent ?? 0).toFixed(2)}%
diff --git a/frontend/src/components/TopRightControls.tsx b/frontend/src/components/TopRightControls.tsx index ac0346e..201dd97 100644 --- a/frontend/src/components/TopRightControls.tsx +++ b/frontend/src/components/TopRightControls.tsx @@ -1,26 +1,48 @@ "use client"; -import { useState } from "react"; -import { Github, MessageSquare, Download, AlertCircle, CheckCircle2 } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Github, MessageSquare, Download, AlertCircle, CheckCircle2, RefreshCw, ExternalLink, X } from "lucide-react"; +import { API_BASE } from "@/lib/api"; import packageJson from "../../package.json"; +type UpdateStatus = + | "idle" + | "checking" + | "available" + | "uptodate" + | "error" + | "confirming" + | "updating" + | "restarting" + | "update_error"; + export default function TopRightControls() { - const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "uptodate" | "error">("idle"); + const [updateStatus, setUpdateStatus] = useState("idle"); const [latestVersion, setLatestVersion] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const pollRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); const currentVersion = packageJson.version; + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + const checkForUpdates = async () => { setUpdateStatus("checking"); try { const res = await fetch("https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest"); if (!res.ok) throw new Error("Failed to fetch"); const data = await res.json(); - - // Remove 'v' prefix if it exists to compare semver cleanly - const latest = data.tag_name?.replace('v', '') || data.name?.replace('v', ''); - const current = currentVersion.replace('v', ''); - + + const latest = data.tag_name?.replace("v", "") || data.name?.replace("v", ""); + const current = currentVersion.replace("v", ""); + if (latest && latest !== current) { setLatestVersion(latest); setUpdateStatus("available"); @@ -35,8 +57,127 @@ export default function TopRightControls() { } }; + const triggerUpdate = async () => { + setUpdateStatus("updating"); + setErrorMessage(""); + try { + const res = await fetch(`${API_BASE}/api/system/update`, { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || "Update failed"); + + setUpdateStatus("restarting"); + + // Poll /api/health until backend comes back + pollRef.current = setInterval(async () => { + try { + const h = await fetch(`${API_BASE}/api/health`); + if (h.ok) { + if (pollRef.current) clearInterval(pollRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.location.reload(); + } + } catch { + // Backend still down — keep polling + } + }, 3000); + + // Give up after 90 seconds + timeoutRef.current = setTimeout(() => { + if (pollRef.current) clearInterval(pollRef.current); + setErrorMessage("Restart timed out — the app may need to be started manually."); + setUpdateStatus("update_error"); + }, 90000); + } catch (err: any) { + setErrorMessage(err.message || "Unknown error"); + setUpdateStatus("update_error"); + } + }; + + // ── Confirmation Dialog ── + const renderConfirmDialog = () => ( +
+
+ {/* Header */} +
+ + UPDATE v{currentVersion} → v{latestVersion} + + +
+ + {/* Actions */} +
+ + + + + MANUAL DOWNLOAD + + + +
+
+
+ ); + + // ── Error Dialog ── + const renderErrorDialog = () => ( +
+
+
+ + UPDATE FAILED + +
+
+

+ {errorMessage} +

+ + + + MANUAL DOWNLOAD + +
+
+
+ ); + return ( -
+
+ {/* Discussions link */} DISCUSSIONS - {updateStatus === "available" ? ( - setUpdateStatus("confirming")} className="flex items-center gap-1.5 px-2.5 py-1.5 bg-green-500/10 backdrop-blur-md border border-green-500/50 rounded-lg hover:bg-green-500/20 transition-all text-[10px] text-green-400 font-mono cursor-pointer shadow-[0_0_15px_rgba(34,197,94,0.3)]" > v{latestVersion} UPDATE! - - ) : ( + + )} + + {/* ── Confirming → show dialog ── */} + {updateStatus === "confirming" && ( + <> + + {renderConfirmDialog()} + + )} + + {/* ── Updating → spinner ── */} + {updateStatus === "updating" && ( +
+ + DOWNLOADING UPDATE... +
+ )} + + {/* ── Restarting → spinner + waiting ── */} + {updateStatus === "restarting" && ( +
+ + RESTARTING... +
+ )} + + {/* ── Error → show error dialog ── */} + {updateStatus === "update_error" && ( + <> + + {renderErrorDialog()} + + )} + + {/* ── Default states: idle / checking / uptodate / check-error ── */} + {!["available", "confirming", "updating", "restarting", "update_error"].includes(updateStatus) && ( diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index e461df5..8183784 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -28,7 +28,8 @@ const FRESHNESS_MAP: Record = { tracked: "military_flights", earthquakes: "earthquakes", satellites: "satellites", - ships_important: "ships", + ships_military: "ships", + ships_cargo: "ships", ships_civilian: "ships", ships_passenger: "ships", ukraine_frontline: "frontlines", @@ -91,17 +92,18 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active }, [gibsPlaying, gibsDate, setGibsDate]); // Compute ship category counts (memoized — ships array can be 1000+ items) - const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => { + const { militaryShipCount, cargoShipCount, passengerShipCount, civilianShipCount } = useMemo(() => { const ships = data?.ships; - if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 }; - let important = 0, passenger = 0, civilian = 0; + if (!ships || !ships.length) return { militaryShipCount: 0, cargoShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 }; + let military = 0, cargo = 0, passenger = 0, civilian = 0; for (const s of ships) { const t = s.type; - if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++; + if (t === 'carrier' || t === 'military_vessel') military++; + else if (t === 'tanker' || t === 'cargo') cargo++; else if (t === 'passenger') passenger++; else civilian++; } - return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian }; + return { militaryShipCount: military, cargoShipCount: cargo, passengerShipCount: passenger, civilianShipCount: civilian }; }, [data?.ships]); // Find POTUS fleet planes currently airborne from tracked flights @@ -127,7 +129,8 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active { id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye }, { id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity }, { id: "satellites", name: "Satellites", source: data?.satellite_source === "celestrak" ? "CelesTrak SGP4" : data?.satellite_source === "tle_api" ? "TLE API · SGP4" : data?.satellite_source === "disk_cache" ? "Cached · SGP4 (est.)" : "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite }, - { id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship }, + { id: "ships_military", name: "Military / Carriers", source: "AIS Stream", count: militaryShipCount, icon: Ship }, + { id: "ships_cargo", name: "Cargo / Tankers", source: "AIS Stream", count: cargoShipCount, icon: Ship }, { id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor }, { id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor }, { id: "ukraine_frontline", name: "Ukraine Frontline", source: "DeepStateMap", count: data?.frontlines ? 1 : 0, icon: AlertTriangle }, @@ -298,7 +301,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active >
- {(['ships_important', 'ships_civilian', 'ships_passenger'].includes(layer.id)) ? shipIcon : } + {(layer.id.startsWith('ships_')) ? shipIcon : }
{layer.name} diff --git a/frontend/src/components/map/MapMarkers.tsx b/frontend/src/components/map/MapMarkers.tsx new file mode 100644 index 0000000..a2b9f1d --- /dev/null +++ b/frontend/src/components/map/MapMarkers.tsx @@ -0,0 +1,283 @@ +import React from "react"; +import { Marker } from "react-map-gl/maplibre"; +import type { ViewState } from "react-map-gl/maplibre"; + +// Shared monospace label style base +const LABEL_BASE: React.CSSProperties = { + fontFamily: 'monospace', + fontWeight: 'bold', + textShadow: '0 0 3px #000, 0 0 3px #000', + pointerEvents: 'none', +}; + +const LABEL_SHADOW_EXTRA = '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000'; + +// -- Cluster count label (ships / earthquakes) -- +export function ClusterCountLabels({ clusters, prefix }: { clusters: any[]; prefix: string }) { + return ( + <> + {clusters.map((c: any) => ( + +
+ {c.count} +
+
+ ))} + + ); +} + +// -- Tracked flights labels -- +const TRACKED_LABEL_COLOR_MAP: Record = { + '#ff1493': '#ff1493', pink: '#ff1493', red: '#ff4444', + blue: '#3b82f6', orange: '#FF8C00', '#32cd32': '#32cd32', + purple: '#b266ff', white: '#cccccc', +}; + +interface TrackedFlightLabelsProps { + flights: any[]; + viewState: ViewState; + inView: (lat: number, lng: number) => boolean; + interpFlight: (f: any) => [number, number]; +} + +export function TrackedFlightLabels({ flights, viewState, inView, interpFlight }: TrackedFlightLabelsProps) { + return ( + <> + {flights.map((f: any, i: number) => { + if (f.lat == null || f.lng == null) return null; + if (!inView(f.lat, f.lng)) return null; + + const alertColor = f.alert_color || '#ff1493'; + if (alertColor === 'yellow' || alertColor === 'black') return null; + + const isHighPriority = alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red'; + if (!isHighPriority && viewState.zoom < 5) return null; + + let displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN"; + if (displayName === 'Private' || displayName === 'private') return null; + + const grounded = f.alt != null && f.alt <= 100; + const labelColor = grounded ? '#888' : (TRACKED_LABEL_COLOR_MAP[alertColor] || alertColor); + const [iLng, iLat] = interpFlight(f); + + return ( + +
+ {String(displayName)} +
+
+ ); + })} + + ); +} + +// -- Carrier labels -- +interface CarrierLabelsProps { + ships: any[]; + inView: (lat: number, lng: number) => boolean; + interpShip: (s: any) => [number, number]; +} + +export function CarrierLabels({ ships, inView, interpShip }: CarrierLabelsProps) { + return ( + <> + {ships.map((s: any, i: number) => { + if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null; + if (!inView(s.lat, s.lng)) return null; + const [iLng, iLat] = interpShip(s); + return ( + +
+
+ [[{s.name}]] +
+ {s.estimated && ( +
+ EST. POSITION — OSINT +
+ )} +
+
+ ); + })} + + ); +} + +// -- UAV labels -- +interface UavLabelsProps { + uavs: any[]; + inView: (lat: number, lng: number) => boolean; +} + +export function UavLabels({ uavs, inView }: UavLabelsProps) { + return ( + <> + {uavs.map((uav: any, i: number) => { + if (uav.lat == null || uav.lng == null) return null; + if (!inView(uav.lat, uav.lng)) return null; + const name = uav.aircraft_model ? `[UAV: ${uav.aircraft_model}]` : `[UAV: ${uav.callsign}]`; + return ( + +
+ {name} +
+
+ ); + })} + + ); +} + +// -- Earthquake labels -- +interface EarthquakeLabelsProps { + earthquakes: any[]; + inView: (lat: number, lng: number) => boolean; +} + +export function EarthquakeLabels({ earthquakes, inView }: EarthquakeLabelsProps) { + return ( + <> + {earthquakes.map((eq: any, i: number) => { + if (eq.lat == null || eq.lng == null) return null; + if (!inView(eq.lat, eq.lng)) return null; + return ( + +
+ [M{eq.mag}] {eq.place || ''} +
+
+ ); + })} + + ); +} + +// -- Threat alert markers -- +function getRiskColor(score: number): string { + if (score >= 9) return '#ef4444'; + if (score >= 7) return '#f97316'; + if (score >= 4) return '#eab308'; + if (score >= 1) return '#3b82f6'; + return '#22c55e'; +} + +interface ThreatMarkerProps { + spreadAlerts: any[]; + viewState: ViewState; + selectedEntity: any; + onEntityClick?: (entity: { id: number; type: string } | null) => void; + onDismiss?: (alertKey: string) => void; +} + +export function ThreatMarkers({ spreadAlerts, viewState, selectedEntity, onEntityClick, onDismiss }: ThreatMarkerProps) { + return ( + <> + {spreadAlerts.map((n: any) => { + const idx = n.originalIdx; + const count = n.cluster_count || 1; + const score = n.risk_score || 0; + const riskColor = getRiskColor(score); + + let isVisible = viewState.zoom >= 1; + if (selectedEntity) { + if (selectedEntity.type === 'news') { + if (selectedEntity.id !== idx) isVisible = false; + } else { + isVisible = false; + } + } + + const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`; + + return ( + { + e.originalEvent.stopPropagation(); + onEntityClick?.({ id: idx, type: 'news' }); + }} + > +
+ {n.showLine && isVisible && ( + + + + + )} + +
+ {n.showLine && isVisible && ( +
0 ? `6px solid ${riskColor}` : 'none', + left: '50%', + [n.offsetY < 0 ? 'bottom' : 'top']: '-6px', + transform: 'translateX(-50%)' + }} + /> + )} + +
+ {onDismiss && ( + + )} +
!! ALERT LVL {score} !!
+
+ {n.title} +
+ {count > 1 && ( +
+ [+{count - 1} ACTIVE THREATS IN AREA] +
+ )} +
+
+ + ); + })} + + ); +} diff --git a/frontend/src/components/map/hooks/useImperativeSource.ts b/frontend/src/components/map/hooks/useImperativeSource.ts new file mode 100644 index 0000000..7f0f4ed --- /dev/null +++ b/frontend/src/components/map/hooks/useImperativeSource.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import { EMPTY_FC } from "@/components/map/mapConstants"; + +// Imperatively push GeoJSON data to a MapLibre source, bypassing React reconciliation. +// This is critical for high-volume layers (flights, ships, satellites, fires) where +// React's prop diffing on thousands of coordinate arrays causes memory pressure. +export function useImperativeSource(map: MapRef | null, sourceId: string, geojson: any, debounceMs = 0) { + const timerRef = useRef | null>(null); + useEffect(() => { + if (!map) return; + const push = () => { + const src = map.getSource(sourceId) as any; + if (src && typeof src.setData === 'function') { + src.setData(geojson || EMPTY_FC); + } + }; + if (debounceMs > 0) { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(push, debounceMs); + return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + } + push(); + }, [map, sourceId, geojson, debounceMs]); +} diff --git a/frontend/src/components/map/icons/AircraftIcons.ts b/frontend/src/components/map/icons/AircraftIcons.ts new file mode 100644 index 0000000..fccba06 --- /dev/null +++ b/frontend/src/components/map/icons/AircraftIcons.ts @@ -0,0 +1,146 @@ +// --- SVG icon data URIs for all map markers --- +// Extracted from MaplibreViewer.tsx — pure data, no JSX + +export const svgPlaneCyan = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneOrange = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlanePurple = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgFighter = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeli = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliCyan = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliOrange = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliPurple = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgTanker = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgRecon = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgShipYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgShipBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgShipWhite = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgCarrier = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgCctv = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgWarning = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgThreat = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgTriangleYellow = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgTriangleRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; + +// --- Aircraft type-specific SVG paths (top-down silhouettes) --- +// Airliner: wide swept wings with engine pods, narrow fuselage +export const AIRLINER_PATH = "M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z M5.5 13.5L3.5 14.5 M18.5 13.5L20.5 14.5"; +// Turboprop: straight high wings, shorter body +export const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L9 19.5V21L12 20L15 21V19.5L13.2 18.5V11.5L21 13.5V12L13.2 9V4C13.2 3.5 12.7 3 12 3Z"; +// Bizjet: sleek, small swept wings, T-tail +export const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z"; + +// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) --- +export function makeFireSvg(fill: string, innerFill: string, size = 18) { + // Multi-forked flame: main body + left tongue + right tongue + inner glow + return `data:image/svg+xml;utf8,${encodeURIComponent( + `` + + // Main flame body (wide base, pointed top) + `` + + // Left tongue (forks out left from top) + `` + + // Right tongue (forks out right from top) + `` + + // Inner bright core + `` + + `` + )}`; +} +export const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16); +export const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18); +export const svgFireRed = makeFireSvg('#ff2200', '#ff8800', 20); +export const svgFireDarkRed = makeFireSvg('#cc0000', '#ff2200', 22); +// Larger fire icons for cluster markers (visually distinct from Global Incidents circles) +export const svgFireClusterSmall = makeFireSvg('#ff6600', '#ffcc00', 32); +export const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40); +export const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48); +export const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56); + +export function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) { + const paths: Record = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" }; + const p = paths[type] || paths.generic; + // Airliner gets engine pod circles + const extras = type === 'airliner' ? `` : ''; + return `data:image/svg+xml;utf8,${encodeURIComponent(`${extras}`)}`; +} + +// POTUS fleet — oversized hot pink with yellow halo ring +export const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +export const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; + +// POTUS fleet ICAO hex codes (verified FAA registry) +export const POTUS_ICAOS = new Set([ + 'ADFDF8','ADFDF9', // Air Force One (VC-25A) + 'ADFEB7','ADFEB8','ADFEB9','ADFEBA', // Air Force Two (C-32A) + 'AE4AE6','AE4AE8','AE4AEA','AE4AEC', // Air Force Two (C-32B) + 'AE0865','AE5E76','AE5E77','AE5E79', // Marine One (VH-3D / VH-92A) +]); + +// Pre-built aircraft SVGs by type & color +export const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan'); +export const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00'); +export const svgAirlinerPurple = makeAircraftSvg('airliner', '#9B59B6'); +export const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow'); +export const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22); +export const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22); +export const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22); +export const svgAirlinerBlue = makeAircraftSvg('airliner', '#3b82f6', 'black', 22); +export const svgAirlinerLime = makeAircraftSvg('airliner', '#32CD32', 'black', 22); +export const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22); +export const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22); + +export const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan'); +export const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00'); +export const svgTurbopropPurple = makeAircraftSvg('turboprop', '#9B59B6'); +export const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow'); +export const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22); +export const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22); +export const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22); +export const svgTurbopropBlue = makeAircraftSvg('turboprop', '#3b82f6', 'black', 22); +export const svgTurbopropLime = makeAircraftSvg('turboprop', '#32CD32', 'black', 22); +export const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22); +export const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22); + +export const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan'); +export const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00'); +export const svgBizjetPurple = makeAircraftSvg('bizjet', '#9B59B6'); +export const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow'); +export const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22); +export const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22); +export const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22); +export const svgBizjetBlue = makeAircraftSvg('bizjet', '#3b82f6', 'black', 22); +export const svgBizjetLime = makeAircraftSvg('bizjet', '#32CD32', 'black', 22); +export const svgBizjetBlack = makeAircraftSvg('bizjet', '#222', '#555', 22); +export const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#666', 22); + +// Grey variants for grounded/parked aircraft (altitude 0) +export const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333'); +export const svgTurbopropGrey = makeAircraftSvg('turboprop', '#555', '#333'); +export const svgBizjetGrey = makeAircraftSvg('bizjet', '#555', '#333'); +export const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; + +// Grey icon map for grounded aircraft +export const GROUNDED_ICON_MAP: Record = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' }; + +// Per-layer color maps (module-level to avoid re-allocation every render tick) +export const COLOR_MAP_COMMERCIAL: Record = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' }; +export const COLOR_MAP_PRIVATE: Record = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' }; +export const COLOR_MAP_JETS: Record = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' }; +export const COLOR_MAP_MILITARY: Record = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' }; +export const MIL_SPECIAL_MAP: Record = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' }; diff --git a/frontend/src/components/map/icons/SatelliteIcons.ts b/frontend/src/components/map/icons/SatelliteIcons.ts new file mode 100644 index 0000000..26fb82f --- /dev/null +++ b/frontend/src/components/map/icons/SatelliteIcons.ts @@ -0,0 +1,28 @@ +// Satellite icon SVG builder and mission-type mappings +// Extracted from MaplibreViewer.tsx — pure data, no JSX + +export const makeSatSvg = (color: string) => { + const svg = ` + + + + + + + `; + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); +}; + +export const MISSION_COLORS: Record = { + 'military_recon': '#ff3333', 'military_sar': '#ff3333', + 'sar': '#00e5ff', 'sigint': '#ffffff', + 'navigation': '#4488ff', 'early_warning': '#ff00ff', + 'commercial_imaging': '#44ff44', 'space_station': '#ffdd00' +}; + +export const MISSION_ICON_MAP: Record = { + 'military_recon': 'sat-mil', 'military_sar': 'sat-mil', + 'sar': 'sat-sar', 'sigint': 'sat-sigint', + 'navigation': 'sat-nav', 'early_warning': 'sat-ew', + 'commercial_imaging': 'sat-com', 'space_station': 'sat-station' +}; diff --git a/frontend/src/components/map/mapConstants.ts b/frontend/src/components/map/mapConstants.ts new file mode 100644 index 0000000..1fe2ce4 --- /dev/null +++ b/frontend/src/components/map/mapConstants.ts @@ -0,0 +1,5 @@ +// Shared map constants +// Extracted from MaplibreViewer.tsx + +// Empty GeoJSON constant — avoids recreating empty objects on every render +export const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; diff --git a/frontend/src/components/map/styles/mapStyles.ts b/frontend/src/components/map/styles/mapStyles.ts new file mode 100644 index 0000000..d83fe7e --- /dev/null +++ b/frontend/src/components/map/styles/mapStyles.ts @@ -0,0 +1,41 @@ +export const darkStyle = { + version: 8, + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", + "https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", + "https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png", + "https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png" + ], + tileSize: 256 + } + }, + layers: [ + { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }, + { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } } + ] +}; + +export const lightStyle = { + version: 8, + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + sources: { + 'carto-light': { + type: 'raster', + tiles: [ + "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", + "https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", + "https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png", + "https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png" + ], + tileSize: 256 + } + }, + layers: [ + { id: 'carto-light-layer', type: 'raster', source: 'carto-light', minzoom: 0, maxzoom: 22 }, + { id: 'imagery-ceiling', type: 'background', paint: { 'background-opacity': 0 } } + ] +}; diff --git a/frontend/src/lib/DashboardDataContext.tsx b/frontend/src/lib/DashboardDataContext.tsx new file mode 100644 index 0000000..09a6ee4 --- /dev/null +++ b/frontend/src/lib/DashboardDataContext.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React, { createContext, useContext } from "react"; + +interface DashboardDataContextValue { + data: any; + selectedEntity: { id: string | number; type: string; extra?: any } | null; + setSelectedEntity: (entity: { id: string | number; type: string; extra?: any } | null) => void; +} + +const DashboardDataContext = createContext(null); + +export function DashboardDataProvider({ + data, + selectedEntity, + setSelectedEntity, + children, +}: DashboardDataContextValue & { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function useDashboardData(): DashboardDataContextValue { + const ctx = useContext(DashboardDataContext); + if (!ctx) throw new Error("useDashboardData must be used within DashboardDataProvider"); + return ctx; +} diff --git a/frontend/src/utils/aircraftClassification.ts b/frontend/src/utils/aircraftClassification.ts new file mode 100644 index 0000000..b647546 --- /dev/null +++ b/frontend/src/utils/aircraftClassification.ts @@ -0,0 +1,12 @@ +// ICAO type code -> aircraft shape classification +export const HELI_TYPES = new Set(['R22', 'R44', 'R66', 'B06', 'B05', 'B47G', 'B105', 'B212', 'B222', 'B230', 'B407', 'B412', 'B429', 'B430', 'B505', 'BK17', 'S55', 'S58', 'S61', 'S64', 'S70', 'S76', 'S92', 'A109', 'A119', 'A139', 'A169', 'A189', 'AW09', 'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'EC75', 'H125', 'H130', 'H135', 'H145', 'H155', 'H160', 'H175', 'H215', 'H225', 'AS32', 'AS35', 'AS50', 'AS55', 'AS65', 'MD52', 'MD60', 'MDHI', 'MD90', 'NOTR', 'HUEY', 'GAMA', 'CABR', 'EXE', 'R300', 'R480', 'LAMA', 'ALLI', 'PUMA', 'NH90', 'CH47', 'UH1', 'UH60', 'AH64', 'MI8', 'MI24', 'MI26', 'MI28', 'KA52', 'K32', 'LYNX', 'WILD', 'MRLX', 'A149', 'A119']); +export const TURBOPROP_TYPES = new Set(['AT43', 'AT45', 'AT72', 'AT73', 'AT75', 'AT76', 'B190', 'B350', 'BE20', 'BE30', 'BE40', 'BE9L', 'BE99', 'C130', 'C160', 'C208', 'C212', 'C295', 'CN35', 'D228', 'D328', 'DHC2', 'DHC3', 'DHC4', 'DHC5', 'DHC6', 'DHC7', 'DHC8', 'DO28', 'DH8A', 'DH8B', 'DH8C', 'DH8D', 'E110', 'E120', 'F27', 'F406', 'F50', 'G159', 'G73T', 'J328', 'JS31', 'JS32', 'JS41', 'L188', 'MA60', 'M28', 'N262', 'P68', 'P180', 'PA31', 'PA42', 'PC12', 'PC21', 'PC24', 'S2', 'S340', 'SF34', 'SF50', 'SW4', 'TRIS', 'TBM7', 'TBM8', 'TBM9', 'C30J', 'C5M', 'AN12', 'AN24', 'AN26', 'AN30', 'AN32', 'IL18', 'L410', 'Y12', 'BALL', 'AEST', 'AC68', 'AC80', 'AC90', 'AC95', 'AC11', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', 'C402', 'C414', 'C421', 'C425', 'C441', 'M20P', 'M20T', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'PA60', 'P28A', 'P28B', 'P28R', 'P32R', 'P46T', 'SR20', 'SR22', 'DA40', 'DA42', 'DA62', 'RV10', 'BE33', 'BE35', 'BE36', 'BE55', 'BE58', 'DR40', 'TB20', 'AA5']); +export const BIZJET_TYPES = new Set(['ASTR', 'C25A', 'C25B', 'C25C', 'C25M', 'C500', 'C501', 'C510', 'C525', 'C526', 'C550', 'C551', 'C560', 'C56X', 'C650', 'C680', 'C700', 'C750', 'CL30', 'CL35', 'CL60', 'CONI', 'CRJX', 'E35L', 'E45X', 'E50P', 'E55P', 'F2TH', 'F900', 'FA10', 'FA20', 'FA50', 'FA7X', 'FA8X', 'G100', 'G150', 'G200', 'G280', 'GA5C', 'GA6C', 'GALX', 'GL5T', 'GL7T', 'GLEX', 'GLF2', 'GLF3', 'GLF4', 'GLF5', 'GLF6', 'H25A', 'H25B', 'H25C', 'HA4T', 'HDJT', 'LJ23', 'LJ24', 'LJ25', 'LJ28', 'LJ31', 'LJ35', 'LJ40', 'LJ45', 'LJ55', 'LJ60', 'LJ70', 'LJ75', 'MU30', 'PC24', 'PRM1', 'SBR1', 'SBR2', 'WW24', 'BE40', 'BLCF']); + +export function classifyAircraft(model: string, category?: string): 'heli' | 'turboprop' | 'bizjet' | 'airliner' { + const m = (model || '').toUpperCase(); + if (category === 'heli' || HELI_TYPES.has(m)) return 'heli'; + if (BIZJET_TYPES.has(m)) return 'bizjet'; + if (TURBOPROP_TYPES.has(m)) return 'turboprop'; + return 'airliner'; +} diff --git a/frontend/src/utils/positioning.ts b/frontend/src/utils/positioning.ts new file mode 100644 index 0000000..b778b1a --- /dev/null +++ b/frontend/src/utils/positioning.ts @@ -0,0 +1,23 @@ +// --- Smooth position interpolation helpers --- +// Given heading (degrees) and speed (knots), compute new lat/lng after dt seconds +export function interpolatePosition(lat: number, lng: number, headingDeg: number, speedKnots: number, dtSeconds: number, maxDist = 0, maxDt = 65): [number, number] { + if (!speedKnots || speedKnots <= 0 || dtSeconds <= 0) return [lat, lng]; + // Cap interpolation time to prevent runaway drift when data is stale + const clampedDt = Math.min(dtSeconds, maxDt); + // 1 knot = 1 nautical mile/hour = 1852 m/h + const speedMps = speedKnots * 0.5144; // meters per second + const dist = maxDist > 0 ? Math.min(speedMps * clampedDt, maxDist) : speedMps * clampedDt; + const R = 6371000; // Earth radius in meters + const headingRad = (headingDeg * Math.PI) / 180; + const latRad = (lat * Math.PI) / 180; + const lngRad = (lng * Math.PI) / 180; + const newLatRad = Math.asin( + Math.sin(latRad) * Math.cos(dist / R) + + Math.cos(latRad) * Math.sin(dist / R) * Math.cos(headingRad) + ); + const newLngRad = lngRad + Math.atan2( + Math.sin(headingRad) * Math.sin(dist / R) * Math.cos(latRad), + Math.cos(dist / R) - Math.sin(latRad) * Math.sin(newLatRad) + ); + return [(newLatRad * 180) / Math.PI, (newLngRad * 180) / Math.PI]; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index cf9c65d..735c2d2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, + "types": ["node"], "strict": true, "noEmit": true, "esModuleInterop": true, diff --git a/start.sh b/start.sh index a1a4a34..5455f30 100644 --- a/start.sh +++ b/start.sh @@ -1,4 +1,8 @@ #!/bin/bash + +# Graceful shutdown: kill all child processes on exit/interrupt +trap 'kill 0' EXIT SIGINT SIGTERM + echo "=======================================================" echo " S H A D O W B R O K E R - macOS / Linux Start " echo "======================================================="