Compare commits

...

23 Commits

Author SHA1 Message Date
anoracleofra-code 34db99deaf v0.8.0: POTUS fleet tracking, full aircraft color-coding, carrier fidelity, UI overhaul
New features:
- POTUS fleet (AF1, AF2, Marine One) with hot-pink icons + gold halo ring
- 9-color aircraft system: military, medical, police, VIP, privacy, dictators
- Sentinel-2 fullscreen overlay with download/copy/open buttons (green themed)
- Carrier homeport deconfliction — distinct pier positions instead of stacking
- Toggle all data layers button (cyan when active, excludes MODIS Terra)
- Version badge + update checker + Discussions shortcut in UI
- Overhauled MapLegend with POTUS fleet, wildfires, infrastructure sections
- Data center map layer with ~700 global DCs from curated dataset

Fixes:
- All Air Force Two ICAO hex codes now correctly identified
- POTUS icon priority over grounded state
- Sentinel-2 no longer overlaps bottom coordinate bar
- Region dossier Nominatim 429 rate-limit retry/backoff
- Docker ENV legacy format warnings resolved
- UI buttons cyan in dark mode, grey in light mode
- Circuit breaker for flaky upstream APIs

Community: @suranyami — parallel multi-arch Docker builds + runtime BACKEND_URL fix (PR #35, #44)

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

Former-commit-id: 7c523df70a2d26f675603166e3513d29230592cd
2026-03-12 09:31:37 -06:00
Shadowbroker a0d0a449eb Merge pull request #44 from suranyami/fix-backend-url-regression-speed-up-docker-builds
ci: speed up multi-arch Docker builds + fix BACKEND_URL baked in at build time
Former-commit-id: 54ca8d59aede7e47df315ac526bde35f4e4d0622
2026-03-11 19:34:57 -06:00
David Parry 26a72f4f95 chore: untrack local config files (.claude, .mise.local.toml)
These are already covered by the .gitignore added in this branch.

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

Former-commit-id: dcfdd7bb329ef7e63ee5755ccbe403bf951903f6
2026-03-12 12:11:09 +11:00
David Parry 3eff24c6ed Merge branch 'main' of github.com:suranyami/Shadowbroker
Former-commit-id: 8e9607c7adaf4f1b4b5013fab10429787671ec03
2026-03-12 12:08:19 +11:00
anoracleofra-code bb345ed665 feat: add TopRightControls component
Former-commit-id: e75da4288a
2026-03-11 18:39:26 -06:00
anoracleofra-code dec5b0da9c chore: bump version to 0.7.0
Former-commit-id: 8ee47f52ab
2026-03-11 18:30:49 -06:00
David Parry 68cacc0fed Merge pull request #6 from suranyami/fix-regression-BACKEND_URL
Fix regression, BACKEND_URL now only processed at request-time

Former-commit-id: 4131a0cadb3f17398ccaf7d14704e4399e9fa7b8
2026-03-12 11:22:03 +11:00
David Parry 40e89ac30b Fix regression, BACKEND_URL now only processed at request-time
Former-commit-id: da14f44e910786e9e21b5968b77e97a94f2876ab
2026-03-12 11:18:23 +11:00
David Parry 350ec11725 Merge pull request #5 from suranyami/speed-up-docker-builds
Ensure lower case image name

Former-commit-id: dc43a87ef0
2026-03-12 10:59:41 +11:00
David Parry 5d4dd0560d Ensure lower case image name
Former-commit-id: f98cafd987
2026-03-12 10:34:33 +11:00
David Parry 345f3c7451 Merge pull request #4 from suranyami/speed-up-docker-builds
Add optimizations for separate arm64/x86_64 builds

Former-commit-id: 50d265fcf0
2026-03-12 10:30:01 +11:00
David Parry dde527821c Merge branch 'BigBodyCobain:main' into main
Former-commit-id: 5c49568921
2026-03-12 10:29:30 +11:00
David Parry 5bee764614 Add optimizations for separate arm64/x86_64 builds
Former-commit-id: aff71e6cd7
2026-03-12 10:25:33 +11:00
anoracleofra-code c986de9e35 fix: legend - earthquake icon yellow, outage zone grey
Former-commit-id: 85478250c3
2026-03-11 14:57:51 -06:00
anoracleofra-code d2fa45c6a6 Merge branch 'main' of https://github.com/BigBodyCobain/Shadowbroker
Former-commit-id: cbc506242d
2026-03-11 14:30:25 -06:00
anoracleofra-code d78bf61256 fix: aircraft categorization, fullscreen satellite imagery, region dossier rate-limit, updated map legend
- Fixed 288+ miscategorized aircraft in plane_alert_db.json (gov/police/medical)
- data_fetcher.py: tracked_names enrichment now assigns blue/lime colors for gov/law/medical operators
- region_dossier.py: fixed Nominatim 429 rate-limiting with retry/backoff
- MaplibreViewer.tsx: Sentinel-2 popup replaced with fullscreen overlay + download/copy buttons
- MapLegend.tsx: updated to show all 9 tracked aircraft color categories + POTUS fleet + wildfires + infrastructure


Former-commit-id: d109434616
2026-03-11 14:29:18 -06:00
Shadowbroker b10d6e6e00 Update README.md
Former-commit-id: b1cb267da3
2026-03-11 14:09:50 -06:00
Shadowbroker afdc626bdb Update README.md
Former-commit-id: a3a0f5e990
2026-03-11 14:07:46 -06:00
anoracleofra-code 5ab02e821f feat: POTUS Fleet tracker, Docker secrets, route fix, SQLite->JSON migration
- Add Docker Swarm secrets _FILE support (AIS_API_KEY_FILE, etc.)
- Fix flight route lookup: pass lat/lng to adsb.lol routeset API, return airport names
- Replace SQLite plane_alert DB with JSON file + O(1) category color mapping
- Add POTUS Fleet (AF1, AF2, Marine One) with hardcoded ICAO overrides
- Add tracked_names enrichment from Excel data with POTUS protection
- Add oversized gold-ringed POTUS SVG icons on map
- Add POTUS Fleet tracker panel in WorldviewLeftPanel with fly-to
- Overhaul tracked flight labels: zoom-gated, PIA hidden, color-mapped
- Add orange color to trackedIconMap, soften white icon strokes
- Fix NewsFeed Wikipedia links to use alert_wiki slug


Former-commit-id: 6f952104c1
2026-03-11 12:28:04 -06:00
anoracleofra-code ac62e4763f chore: update ChangelogModal for v0.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: a771fe8cfb
2026-03-11 06:37:15 -06:00
David Parry beadce5dae Merge pull request #3 from suranyami/feat/multi-arch-docker-and-backend-proxy
fix: resolve proxy gzip decoding and BACKEND_URL Docker override issues
Former-commit-id: 7af4af1507
2026-03-11 15:58:05 +11:00
David Parry f99cc669f5 Merge pull request #2 from suranyami/feat/multi-arch-docker-and-backend-proxy
feat: proxy backend API through Next.js using runtime BACKEND_URL
Former-commit-id: d930001673
2026-03-11 14:22:58 +11:00
David Parry 82715c79a6 Merge pull request #1 from suranyami/feat/multi-arch-docker-and-backend-proxy
Feat/multi arch docker and backend proxy

Former-commit-id: 82e0033239
2026-03-11 13:56:22 +11:00
34 changed files with 1799 additions and 720 deletions
+16
View File
@@ -0,0 +1,16 @@
# ShadowBroker — Docker Compose Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required for backend container ─────────────────────────────
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
AIS_API_KEY=
# ── Optional ───────────────────────────────────────────────────
# LTA (Singapore traffic cameras) — leave blank to skip
# LTA_ACCOUNT_KEY=
# Override the backend URL the frontend uses (leave blank for auto-detect)
# NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
+166 -26
View File
@@ -13,17 +13,29 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push-frontend: build-frontend:
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.0.0
@@ -35,6 +47,69 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
- name: Build and push Docker image by digest
id: build
uses: docker/build-push-action@v5.0.0
with:
context: ./frontend
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=frontend-${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
- name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests/frontend
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/frontend/${digest#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/frontend/*
if-no-files-found: error
retention-days: 1
merge-frontend:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
needs: build-frontend
permissions:
contents: read
packages: write
steps:
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests/frontend
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5.0.0 uses: docker/metadata-action@v5.0.0
@@ -45,29 +120,36 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Create and push manifest
id: build-and-push working-directory: /tmp/digests/frontend
uses: docker/build-push-action@v5.0.0 run: |
with: docker buildx imagetools create \
context: ./frontend $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
platforms: linux/amd64,linux/arm64 $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend@sha256:%s ' *)
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-and-push-backend: build-backend:
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0 uses: docker/setup-buildx-action@v3.0.0
@@ -79,6 +161,69 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
- name: Build and push Docker image by digest
id: build
uses: docker/build-push-action@v5.0.0
with:
context: ./backend
platforms: ${{ matrix.platform }}
push: ${{ github.event_name != 'pull_request' }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=backend-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=backend-${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
- name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests/backend
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/backend/${digest#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
path: /tmp/digests/backend/*
if-no-files-found: error
retention-days: 1
merge-backend:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
needs: build-backend
permissions:
contents: read
packages: write
steps:
- name: Lowercase image name
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests/backend
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5.0.0 uses: docker/metadata-action@v5.0.0
@@ -89,14 +234,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Create and push manifest
id: build-and-push working-directory: /tmp/digests/backend
uses: docker/build-push-action@v5.0.0 run: |
with: docker buildx imagetools create \
context: ./backend $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
platforms: linux/amd64,linux/arm64 $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend@sha256:%s ' *)
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+3
View File
@@ -94,3 +94,6 @@ clean_zip.py
zip_repo.py zip_repo.py
refactor_cesium.py refactor_cesium.py
jobs.json jobs.json
.claude
.mise.local.toml
+6 -2
View File
@@ -9,7 +9,11 @@
--- ---
![560645594-989008ee-c690-4cc0-aade-14c24ca82874](https://github.com/user-attachments/assets/5a879552-327f-4f66-81e9-4ae1cec9c468)
https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852
@@ -21,7 +25,7 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
## Interesting Use Cases ## Interesting Use Cases
* Track private jets of billionaires * Track everything from Air Force One to the private jets of billionaires, dictators, and corporations
* Monitor satellites passing overhead and see high-resolution satellite imagery * Monitor satellites passing overhead and see high-resolution satellite imagery
* Nose around local emergency scanners * Nose around local emergency scanners
* Watch naval traffic worldwide * Watch naval traffic worldwide
+15
View File
@@ -0,0 +1,15 @@
# ShadowBroker Backend — Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required Keys ──────────────────────────────────────────────
# Without these, the corresponding data layers will be empty.
OPENSKY_CLIENT_ID= # https://opensky-network.org/ — free account, OAuth2 client ID
OPENSKY_CLIENT_SECRET= # OAuth2 client secret from your OpenSky dashboard
AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# ── Optional ───────────────────────────────────────────────────
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
+6 -4
View File
@@ -9,16 +9,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install dependencies # Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# 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
# Copy source code # Copy source code
COPY . . COPY . .
# Install Node.js dependencies (ws module for AIS WebSocket proxy)
RUN npm install --omit=dev
# Create a non-root user for security # Create a non-root user for security
RUN adduser --system --uid 1001 backenduser \ RUN adduser --system --uid 1001 backenduser \
&& chown -R backenduser /app && chown -R backenduser /app
@@ -0,0 +1 @@
430ac93c4f7c4fb5a3e596ec38e3b7794c731cc1
@@ -0,0 +1 @@
476b691be156eb4fe6a6ad80f882c1dbaded8c33
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{}
@@ -0,0 +1 @@
38a18cbbf1acbec5eb9266b809c28d31e2941c53
File diff suppressed because one or more lines are too long
+166
View File
@@ -0,0 +1,166 @@
"""
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()
+48 -9
View File
@@ -1,3 +1,40 @@
import os
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Docker Swarm Secrets support
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
# the file is read and its trimmed content is placed into VAR.
# This MUST run before service imports — modules read os.environ at import time.
# ---------------------------------------------------------------------------
_SECRET_VARS = [
"AIS_API_KEY",
"OPENSKY_CLIENT_ID",
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
"CORS_ORIGINS",
]
for _var in _SECRET_VARS:
_file_var = f"{_var}_FILE"
_file_path = os.environ.get(_file_var)
if _file_path:
try:
with open(_file_path, "r") as _f:
_value = _f.read().strip()
if _value:
os.environ[_var] = _value
logger.info(f"Loaded secret {_var} from {_file_path}")
else:
logger.warning(f"Secret file {_file_path} for {_var} is empty")
except FileNotFoundError:
logger.error(f"Secret file {_file_path} for {_var} not found")
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
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -5,14 +42,10 @@ from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_da
from services.ais_stream import start_ais_stream, stop_ais_stream from services.ais_stream import start_ais_stream, stop_ais_stream
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
import uvicorn import uvicorn
import logging
import hashlib import hashlib
import json as json_mod import json as json_mod
import os
import socket import socket
logging.basicConfig(level=logging.INFO)
def _build_cors_origins(): def _build_cors_origins():
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides. """Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
@@ -100,6 +133,8 @@ async def live_data_fast(request: Request):
"uavs": d.get("uavs", []), "uavs": d.get("uavs", []),
"liveuamap": d.get("liveuamap", []), "liveuamap": d.get("liveuamap", []),
"gps_jamming": d.get("gps_jamming", []), "gps_jamming": d.get("gps_jamming", []),
"satellites": d.get("satellites", []),
"satellite_source": d.get("satellite_source", "none"),
"freshness": dict(source_timestamps), "freshness": dict(source_timestamps),
} }
return _etag_response(request, payload, prefix="fast|") return _etag_response(request, payload, prefix="fast|")
@@ -187,9 +222,9 @@ async def api_get_nearest_radios_list(lat: float, lng: float, limit: int = 5):
from services.network_utils import fetch_with_curl from services.network_utils import fetch_with_curl
@app.get("/api/route/{callsign}") @app.get("/api/route/{callsign}")
async def get_flight_route(callsign: str): async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0):
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign}]}, timeout=10) r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10)
if r.status_code == 200: if r and r.status_code == 200:
data = r.json() data = r.json()
route_list = [] route_list = []
if isinstance(data, dict): if isinstance(data, dict):
@@ -201,9 +236,13 @@ async def get_flight_route(callsign: str):
route = route_list[0] route = route_list[0]
airports = route.get("_airports", []) airports = route.get("_airports", [])
if len(airports) >= 2: if len(airports) >= 2:
orig = airports[0]
dest = airports[-1]
return { return {
"orig_loc": [airports[0].get("lon", 0), airports[0].get("lat", 0)], "orig_loc": [orig.get("lon", 0), orig.get("lat", 0)],
"dest_loc": [airports[-1].get("lon", 0), airports[-1].get("lat", 0)] "dest_loc": [dest.get("lon", 0), dest.get("lat", 0)],
"origin_name": f"{orig.get('iata', '') or orig.get('icao', '')}: {orig.get('name', 'Unknown')}",
"dest_name": f"{dest.get('iata', '') or dest.get('icao', '')}: {dest.get('name', 'Unknown')}",
} }
return {} return {}
+150 -86
View File
@@ -26,104 +26,116 @@ logger = logging.getLogger(__name__)
# Carrier registry: hull number → metadata + fallback position # Carrier registry: hull number → metadata + fallback position
# ----------------------------------------------------------------- # -----------------------------------------------------------------
CARRIER_REGISTRY: Dict[str, dict] = { CARRIER_REGISTRY: Dict[str, dict] = {
# Fallback positions sourced from USNI News Fleet & Marine Tracker (Mar 9, 2026)
# https://news.usni.org/2026/03/09/usni-news-fleet-and-marine-tracker-march-9-2026
# --- Bremerton, WA (Naval Base Kitsap) ---
# Distinct pier positions along Sinclair Inlet so carriers don't stack
"CVN-68": { "CVN-68": {
"name": "USS Nimitz (CVN-68)", "name": "USS Nimitz (CVN-68)",
"wiki": "https://en.wikipedia.org/wiki/USS_Nimitz", "wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
"homeport": "Bremerton, WA", "homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63, "homeport_lat": 47.5535, "homeport_lng": -122.6400,
"fallback_lat": 21.35, "fallback_lng": -157.95, "fallback_lat": 47.5535, "fallback_lng": -122.6400,
"fallback_heading": 270,
"fallback_desc": "Pacific Fleet / Pearl Harbor"
},
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 120,
"fallback_desc": "Red Sea / CENTCOM AOR"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 34.0, "fallback_lng": 25.0,
"fallback_heading": 90, "fallback_heading": 90,
"fallback_desc": "Eastern Mediterranean deterrence" "fallback_desc": "Bremerton, WA (Maintenance)"
},
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 15.0, "fallback_lng": 115.0,
"fallback_heading": 45,
"fallback_desc": "South China Sea patrol"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 22.0, "fallback_lng": 122.0,
"fallback_heading": 300,
"fallback_desc": "Philippine Sea / Taiwan Strait"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 21.0, "fallback_lng": -158.0,
"fallback_heading": 270,
"fallback_desc": "Pacific deployment"
},
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.28, "homeport_lng": 139.67,
"fallback_lat": 35.0, "fallback_lng": 139.0,
"fallback_heading": 0,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.95, "fallback_lng": -76.33,
"fallback_heading": 0,
"fallback_desc": "RCOH / Norfolk (maintenance)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 90,
"fallback_desc": "Mediterranean deployment"
}, },
"CVN-76": { "CVN-76": {
"name": "USS Ronald Reagan (CVN-76)", "name": "USS Ronald Reagan (CVN-76)",
"wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan", "wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
"homeport": "Bremerton, WA", "homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63, "homeport_lat": 47.5580, "homeport_lng": -122.6360,
"fallback_lat": 47.56, "fallback_lng": -122.63, "fallback_lat": 47.5580, "fallback_lng": -122.6360,
"fallback_heading": 90,
"fallback_desc": "Bremerton, WA (Decommissioning)"
},
# --- Norfolk, VA (Naval Station Norfolk) ---
# Piers run N-S along Willoughby Bay; each carrier gets a distinct berth
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9465, "homeport_lng": -76.3265,
"fallback_lat": 36.9465, "fallback_lng": -76.3265,
"fallback_heading": 0, "fallback_heading": 0,
"fallback_desc": "Bremerton, WA (Homeport)" "fallback_desc": "Norfolk, VA (Post-deployment maintenance)"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9505, "homeport_lng": -76.3250,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 0,
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9540, "homeport_lng": -76.3235,
"fallback_lat": 36.98, "fallback_lng": -76.43,
"fallback_heading": 0,
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9580, "homeport_lng": -76.3220,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 0,
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)"
}, },
"CVN-77": { "CVN-77": {
"name": "USS George H.W. Bush (CVN-77)", "name": "USS George H.W. Bush (CVN-77)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush", "wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
"homeport": "Norfolk, VA", "homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33, "homeport_lat": 36.9620, "homeport_lng": -76.3210,
"fallback_lat": 36.95, "fallback_lng": -76.33, "fallback_lat": 36.5, "fallback_lng": -74.0,
"fallback_heading": 0, "fallback_heading": 0,
"fallback_desc": "Norfolk, VA (Homeport)" "fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)"
},
# --- San Diego, CA (Naval Base San Diego) ---
# Carrier piers along the east shore of San Diego Bay, spread N-S
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.6840, "homeport_lng": -117.1290,
"fallback_lat": 32.6840, "fallback_lng": -117.1290,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Homeport)"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6885, "homeport_lng": -117.1280,
"fallback_lat": 32.6885, "fallback_lng": -117.1280,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Maintenance)"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6925, "homeport_lng": -117.1275,
"fallback_lat": 20.0, "fallback_lng": 64.0,
"fallback_heading": 0,
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)"
},
# --- Yokosuka, Japan (CFAY) ---
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.2830, "homeport_lng": 139.6700,
"fallback_lat": 35.2830, "fallback_lng": 139.6700,
"fallback_heading": 180,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
}, },
} }
@@ -302,7 +314,8 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
"lat": coords[0], "lat": coords[0],
"lng": coords[1], "lng": coords[1],
"desc": title[:100], "desc": title[:100],
"source": "GDELT OSINT", "source": "GDELT News API",
"source_url": article.get("url", "https://api.gdeltproject.org"),
"updated": datetime.now(timezone.utc).isoformat() "updated": datetime.now(timezone.utc).isoformat()
} }
logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']}{coords} (from: {title[:80]})") logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']}{coords} (from: {title[:80]})")
@@ -316,7 +329,7 @@ def update_carrier_positions():
logger.info("Carrier tracker: updating positions from OSINT sources...") logger.info("Carrier tracker: updating positions from OSINT sources...")
# Start with fallback positions # Start with fallback positions (sourced from USNI News Fleet Tracker)
positions: Dict[str, dict] = {} positions: Dict[str, dict] = {}
for hull, info in CARRIER_REGISTRY.items(): for hull, info in CARRIER_REGISTRY.items():
positions[hull] = { positions[hull] = {
@@ -326,7 +339,8 @@ def update_carrier_positions():
"heading": info["fallback_heading"], "heading": info["fallback_heading"],
"desc": info["fallback_desc"], "desc": info["fallback_desc"],
"wiki": info["wiki"], "wiki": info["wiki"],
"source": "Static OSINT estimate", "source": "USNI News Fleet & Marine Tracker",
"source_url": "https://news.usni.org/category/fleet-tracker",
"updated": datetime.now(timezone.utc).isoformat() "updated": datetime.now(timezone.utc).isoformat()
} }
@@ -370,6 +384,55 @@ def update_carrier_positions():
logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}") logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}")
def _deconflict_positions(result: List[dict]) -> List[dict]:
"""Offset carriers that share identical coordinates so they don't stack.
At port: offset along the pier axis (~500m / 0.004° apart).
At sea: offset perpendicular to each other (~0.08° / ~9km apart)
so they're visibly separate but clearly operating together.
"""
# Group by rounded lat/lng (within ~0.01° ≈ 1km = same spot)
from collections import defaultdict
groups: dict[str, list[int]] = defaultdict(list)
for i, c in enumerate(result):
key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
groups[key].append(i)
for indices in groups.values():
if len(indices) < 2:
continue
n = len(indices)
# Determine if this is a port (near a homeport) or at sea
sample = result[indices[0]]
at_port = any(
abs(sample["lat"] - info.get("homeport_lat", 0)) < 0.05
and abs(sample["lng"] - info.get("homeport_lng", 0)) < 0.05
for info in CARRIER_REGISTRY.values()
)
if at_port:
# Use each carrier's distinct homeport pier coordinates
for idx in indices:
carrier = result[idx]
hull = None
for h, info in CARRIER_REGISTRY.items():
if info["name"] == carrier["name"]:
hull = h
break
if hull:
info = CARRIER_REGISTRY[hull]
carrier["lat"] = info["homeport_lat"]
carrier["lng"] = info["homeport_lng"]
else:
# At sea: spread in a line perpendicular to travel (~0.08° apart)
spacing = 0.08 # ~9km — close enough to see they're together
start_offset = -(n - 1) * spacing / 2
for j, idx in enumerate(indices):
result[idx]["lng"] += start_offset + j * spacing
return result
def get_carrier_positions() -> List[dict]: def get_carrier_positions() -> List[dict]:
"""Return current carrier positions for the data pipeline.""" """Return current carrier positions for the data pipeline."""
with _positions_lock: with _positions_lock:
@@ -381,7 +444,7 @@ def get_carrier_positions() -> List[dict]:
"type": "carrier", "type": "carrier",
"lat": pos["lat"], "lat": pos["lat"],
"lng": pos["lng"], "lng": pos["lng"],
"heading": pos.get("heading", 0), "heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
"sog": 0, "sog": 0,
"cog": 0, "cog": 0,
"country": "United States", "country": "United States",
@@ -389,9 +452,10 @@ def get_carrier_positions() -> List[dict]:
"wiki": pos.get("wiki", info.get("wiki", "")), "wiki": pos.get("wiki", info.get("wiki", "")),
"estimated": True, "estimated": True,
"source": pos.get("source", "OSINT estimated position"), "source": pos.get("source", "OSINT estimated position"),
"source_url": pos.get("source_url", "https://news.usni.org/category/fleet-tracker"),
"last_osint_update": pos.get("updated", "") "last_osint_update": pos.get("updated", "")
}) })
return result return _deconflict_positions(result)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
+297 -216
View File
@@ -142,56 +142,132 @@ def _mark_fresh(*keys):
_data_lock = threading.Lock() _data_lock = threading.Lock()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Plane-Alert DB — load tracked aircraft from CSV on startup # Plane-Alert DB — load tracked aircraft from JSON on startup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Category → color mapping # Exact category → color mapping for all 53 known categories.
_PINK_CATEGORIES = { # O(1) dict lookup — no keyword scanning, no false positives.
"Dictator Alert", "Head of State", "Da Comrade", "Oligarch", _CATEGORY_COLOR: dict[str, str] = {
"Governments", "Royal Aircraft", "Quango", # YELLOW — Military / Intelligence / Defense
} "USAF": "yellow",
_RED_CATEGORIES = { "Other Air Forces": "yellow",
"Don't you know who I am?", "As Seen on TV", "Joe Cool", "Toy Soldiers": "yellow",
"Vanity Plate", "Football", "Bizjets", "Oxcart": "yellow",
} "United States Navy": "yellow",
_DARKBLUE_CATEGORIES = { "GAF": "yellow",
"USAF", "United States Navy", "United States Marine Corps", "Hired Gun": "yellow",
"Special Forces", "Hired Gun", "Oxcart", "Gunship", "Nuclear", "United States Marine Corps": "yellow",
"CAP", "Zoomies", "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: def _category_to_color(cat: str) -> str:
if cat in _PINK_CATEGORIES: """O(1) exact lookup. Unknown categories default to purple."""
return "pink" return _CATEGORY_COLOR.get(cat, "purple")
if cat in _RED_CATEGORIES:
return "red"
if cat in _DARKBLUE_CATEGORIES:
return "darkblue"
return "white"
# Load once on module import _PLANE_ALERT_DB: dict = {}
_PLANE_ALERT_DB: dict = {} # uppercase ICAO hex → dict of aircraft info
# ---------------------------------------------------------------------------
# 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(): def _load_plane_alert_db():
"""Parse plane_alert_db.json into a dict keyed by uppercase ICAO hex.""" """Load plane_alert_db.json (exported from SQLite) into memory."""
global _PLANE_ALERT_DB global _PLANE_ALERT_DB
import json
json_path = os.path.join( json_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "plane_alert_db.json" "data", "plane_alert_db.json"
) )
if not os.path.exists(json_path): if not os.path.exists(json_path):
logger.warning(f"Plane-Alert JSON DB not found at {json_path}") logger.warning(f"Plane-Alert DB not found at {json_path}")
return return
try: try:
with open(json_path, "r", encoding="utf-8") as fh: with open(json_path, "r", encoding="utf-8") as fh:
data = json.load(fh) raw = json.load(fh)
for icao_hex, info in data.items(): for icao_hex, info in raw.items():
info["color"] = _category_to_color(info.get("category", "")) info["color"] = _category_to_color(info.get("category", ""))
_PLANE_ALERT_DB[icao_hex] = info # Apply POTUS fleet overrides (correct colors + clean operator names)
logger.info(f"Plane-Alert JSON DB loaded: {len(_PLANE_ALERT_DB)} aircraft") 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: except Exception as e:
logger.error(f"Failed to load Plane-Alert JSON DB: {e}") logger.error(f"Failed to load Plane-Alert DB: {e}")
_load_plane_alert_db() _load_plane_alert_db()
@@ -204,11 +280,12 @@ def enrich_with_plane_alert(flight: dict) -> dict:
flight["alert_color"] = info["color"] flight["alert_color"] = info["color"]
flight["alert_operator"] = info["operator"] flight["alert_operator"] = info["operator"]
flight["alert_type"] = info["ac_type"] flight["alert_type"] = info["ac_type"]
flight["alert_tag1"] = info["tag1"] flight["alert_tags"] = info["tags"]
flight["alert_tag2"] = info["tag2"]
flight["alert_tag3"] = info["tag3"]
flight["alert_link"] = info["link"] flight["alert_link"] = info["link"]
# Override registration if DB has a better one if info.get("wiki"):
flight["alert_wiki"] = info["wiki"]
if info.get("potus_fleet"):
flight["potus_fleet"] = info["potus_fleet"]
if info["registration"]: if info["registration"]:
flight["registration"] = info["registration"] flight["registration"] = info["registration"]
@@ -245,21 +322,37 @@ _load_tracked_names()
def enrich_with_tracked_names(flight: dict) -> dict: def enrich_with_tracked_names(flight: dict) -> dict:
"""If flight's registration matches our Excel extraction, tag it as tracked.""" """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() reg = flight.get("registration", "").strip().upper()
callsign = flight.get("callsign", "").strip().upper() callsign = flight.get("callsign", "").strip().upper()
match = None match = None
if reg and reg in _TRACKED_NAMES_DB: if reg and reg in _TRACKED_NAMES_DB:
match = _TRACKED_NAMES_DB[reg] match = _TRACKED_NAMES_DB[reg]
elif callsign and callsign in _TRACKED_NAMES_DB: elif callsign and callsign in _TRACKED_NAMES_DB:
match = _TRACKED_NAMES_DB[callsign] match = _TRACKED_NAMES_DB[callsign]
if match: if match:
# Don't overwrite Plane-Alert DB operator if it exists unless we want Excel to take precedence. name = match["name"]
# Let's let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC). # Let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC).
flight["alert_operator"] = match["name"] flight["alert_operator"] = name
flight["alert_category"] = match["category"] flight["alert_category"] = match["category"]
if "alert_color" not in flight:
# 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" flight["alert_color"] = "pink"
return flight return flight
@@ -374,7 +467,8 @@ def fetch_news():
source_weights = {f["name"]: f["weight"] for f in feed_config} source_weights = {f["name"]: f["weight"] for f in feed_config}
clusters = {} clusters = {}
_cluster_grid = {} # spatial hash grid: (cell_x, cell_y) → [cluster_keys]
# Fetch all feeds in parallel for speed (each has a 10s timeout) # Fetch all feeds in parallel for speed (each has a 10s timeout)
def _fetch_feed(item): def _fetch_feed(item):
source_name, url = item source_name, url = item
@@ -447,20 +541,25 @@ def fetch_news():
break break
# If mapped, check if there is an existing cluster within ~400km (4 degrees) to merge them # 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: if lat is not None:
key = None key = None
for existing_key in clusters.keys(): cell_x, cell_y = int(lng // 4), int(lat // 4)
if "," in existing_key: for dx in range(-1, 2):
parts = existing_key.split(",") for dy in range(-1, 2):
try: for ckey in _cluster_grid.get((cell_x + dx, cell_y + dy), []):
parts = ckey.split(",")
elat, elng = float(parts[0]), float(parts[1]) elat, elng = float(parts[0]), float(parts[1])
if ((lat - elat)**2 + (lng - elng)**2)**0.5 < 4.0: if ((lat - elat)**2 + (lng - elng)**2)**0.5 < 4.0:
key = existing_key key = ckey
break break
except ValueError: if key:
pass break
if key:
break
if key is None: if key is None:
key = f"{lat},{lng}" key = f"{lat},{lng}"
_cluster_grid.setdefault((cell_x, cell_y), []).append(key)
else: else:
key = title key = title
@@ -1100,11 +1199,11 @@ def fetch_flights():
if hex_id: if hex_id:
seen_hexes.add(hex_id) seen_hexes.add(hex_id)
# Prune stale trails (10 min for non-tracked, 30 min for tracked) # 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', [])} tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
stale_keys = [] stale_keys = []
for k, v in flight_trails.items(): for k, v in flight_trails.items():
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 600 cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 300
if v['last_seen'] < cutoff: if v['last_seen'] < cutoff:
stale_keys.append(k) stale_keys.append(k)
for k in stale_keys: for k in stale_keys:
@@ -1215,17 +1314,25 @@ def fetch_ships():
"""Fetch real-time AIS vessel data and combine with OSINT carrier positions.""" """Fetch real-time AIS vessel data and combine with OSINT carrier positions."""
from services.ais_stream import get_ais_vessels from services.ais_stream import get_ais_vessels
from services.carrier_tracker import get_carrier_positions from services.carrier_tracker import get_carrier_positions
ships = [] ships = []
# Dynamic OSINT carrier positions (updated from GDELT + cache) # Dynamic OSINT carrier positions (updated from GDELT + cache)
carriers = get_carrier_positions() try:
ships.extend(carriers) 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 # Real AIS vessel data from aisstream.io
ais_vessels = get_ais_vessels() try:
ships.extend(ais_vessels) 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") logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
latest_data['ships'] = ships latest_data['ships'] = ships
_mark_fresh("ships") _mark_fresh("ships")
@@ -1584,123 +1691,37 @@ def fetch_internet_outages():
if outages: if outages:
_mark_fresh("internet_outages") _mark_fresh("internet_outages")
_DC_CACHE_PATH = Path(__file__).parent.parent / "data" / "datacenters.json" _DC_GEOCODED_PATH = Path(__file__).parent.parent / "data" / "datacenters_geocoded.json"
_DC_URL = "https://raw.githubusercontent.com/Ringmast4r/Data-Center-Map---Global/1f290297c6a11454dc7a47bf95aef7cf0fe1d34c/datacenters_cleaned.json"
# Country bounding boxes (lat_min, lat_max, lng_min, lng_max) for coordinate validation.
# The source dataset has abs(lat) for all Southern Hemisphere entries, so we fix the sign
# and then validate the result falls within the country's bounding box.
_COUNTRY_BBOX: dict[str, tuple[float, float, float, float]] = {
"Argentina": (-55, -21, -74, -53), "Australia": (-44, -10, 112, 154),
"Bolivia": (-23, -9, -70, -57), "Brazil": (-34, 6, -74, -34),
"Chile": (-56, -17, -76, -66), "Colombia": (-5, 13, -82, -66),
"Ecuador": (-5, 2, -81, -75), "Indonesia": (-11, 6, 95, 141),
"Kenya": (-5, 5, 34, 42), "Madagascar": (-26, -12, 43, 51),
"Mozambique": (-27, -10, 30, 41), "New Zealand": (-47, -34, 166, 179),
"Paraguay": (-28, -19, -63, -54), "Peru": (-18, 0, -82, -68),
"South Africa": (-35, -22, 16, 33), "Tanzania": (-12, -1, 29, 41),
"Uruguay": (-35, -30, -59, -53), "Zimbabwe": (-23, -15, 25, 34),
# Northern-hemisphere countries for validation only
"United States": (24, 72, -180, -65), "Canada": (41, 84, -141, -52),
"United Kingdom": (49, 61, -9, 2), "Germany": (47, 55, 5, 16),
"France": (41, 51, -5, 10), "Japan": (24, 46, 123, 146),
"India": (6, 36, 68, 98), "China": (18, 54, 73, 135),
"Singapore": (1, 2, 103, 105), "Spain": (36, 44, -10, 5),
"Netherlands": (50, 54, 3, 8), "Sweden": (55, 70, 11, 25),
"Italy": (36, 47, 6, 19), "Russia": (41, 82, 19, 180),
"Mexico": (14, 33, -118, -86), "Nigeria": (4, 14, 2, 15),
"Thailand": (5, 21, 97, 106), "Malaysia": (0, 8, 99, 120),
"Philippines": (4, 21, 116, 127), "South Korea": (33, 39, 124, 132),
"Taiwan": (21, 26, 119, 123), "Hong Kong": (22, 23, 113, 115),
"Vietnam": (8, 24, 102, 110), "Poland": (49, 55, 14, 25),
"Switzerland": (45, 48, 5, 11), "Austria": (46, 49, 9, 17),
"Belgium": (49, 52, 2, 7), "Denmark": (54, 58, 8, 16),
"Finland": (59, 70, 20, 32), "Norway": (57, 72, 4, 32),
"Ireland": (51, 56, -11, -5), "Portugal": (36, 42, -10, -6),
"Turkey": (35, 42, 25, 45), "Israel": (29, 34, 34, 36),
"UAE": (22, 27, 51, 56), "Saudi Arabia": (16, 33, 34, 56),
}
# Countries whose DCs always sit south of the equator
_SOUTHERN_COUNTRIES = {
"Argentina", "Australia", "Bolivia", "Brazil", "Chile", "Madagascar",
"Mozambique", "New Zealand", "Paraguay", "Peru", "South Africa",
"Tanzania", "Uruguay", "Zimbabwe",
}
def _fix_dc_coords(lat: float, lng: float, country: str) -> tuple[float, float] | None:
"""Fix and validate data-center coordinates against the stated country.
The source dataset stores abs(lat) for Southern-Hemisphere entries.
We negate lat when the country is in the Southern Hemisphere, then
validate the result falls within the country bounding box (if known).
Returns corrected (lat, lng) or None if the coords are clearly wrong.
"""
# Fix Southern Hemisphere sign
if country in _SOUTHERN_COUNTRIES and lat > 0:
lat = -lat
bbox = _COUNTRY_BBOX.get(country)
if bbox:
lat_min, lat_max, lng_min, lng_max = bbox
if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
return lat, lng
# Try swapping sign as last resort (some entries are just wrong sign)
if lat_min <= -lat <= lat_max and lng_min <= lng <= lng_max:
return -lat, lng
# Coords don't match country at all — drop the entry
return None
# No bbox for this country — basic sanity only
return lat, lng
def fetch_datacenters(): def fetch_datacenters():
"""Load data center locations (static dataset, cached locally after first fetch).""" """Load geocoded data centers (5K+ street-level precise locations)."""
dcs = [] dcs = []
try: try:
raw = None if not _DC_GEOCODED_PATH.exists():
# Use local cache if it exists and is less than 7 days old logger.warning(f"Geocoded DC file not found: {_DC_GEOCODED_PATH}")
if _DC_CACHE_PATH.exists(): return
age_days = (time.time() - _DC_CACHE_PATH.stat().st_mtime) / 86400 raw = json.loads(_DC_GEOCODED_PATH.read_text(encoding="utf-8"))
if age_days < 7: for entry in raw:
raw = json.loads(_DC_CACHE_PATH.read_text(encoding="utf-8")) lat = entry.get("lat")
# Otherwise fetch from GitHub lng = entry.get("lng")
if raw is None: if lat is None or lng is None:
resp = fetch_with_curl(_DC_URL, timeout=20) continue
if resp.status_code == 200: if not (-90 <= lat <= 90 and -180 <= lng <= 180):
raw = resp.json() continue
_DC_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) dcs.append({
_DC_CACHE_PATH.write_text(json.dumps(raw), encoding="utf-8") "name": entry.get("name", "Unknown"),
if raw: "company": entry.get("company", ""),
dropped = 0 "street": entry.get("street", ""),
for entry in raw: "city": entry.get("city", ""),
coords = entry.get("city_coords") "country": entry.get("country", ""),
if not coords or not isinstance(coords, list) or len(coords) < 2: "zip": entry.get("zip", ""),
continue "lat": lat,
lat, lng = coords[0], coords[1] "lng": lng,
if not (-90 <= lat <= 90 and -180 <= lng <= 180): })
continue logger.info(f"Data centers: {len(dcs)} geocoded locations loaded")
country = entry.get("country", "")
fixed = _fix_dc_coords(lat, lng, country)
if fixed is None:
dropped += 1
continue
lat, lng = fixed
dcs.append({
"name": entry.get("name", "Unknown"),
"company": entry.get("company", ""),
"city": entry.get("city", ""),
"country": country,
"lat": lat,
"lng": lng,
})
if dropped:
logger.info(f"Data centers: dropped {dropped} entries with mismatched coordinates")
logger.info(f"Data centers: {len(dcs)} with valid coordinates (from {'cache' if _DC_CACHE_PATH.exists() else 'GitHub'})")
except Exception as e: except Exception as e:
logger.error(f"Error fetching data centers: {e}") logger.error(f"Error loading data centers: {e}")
latest_data["datacenters"] = dcs latest_data["datacenters"] = dcs
if dcs: if dcs:
_mark_fresh("datacenters") _mark_fresh("datacenters")
@@ -1766,7 +1787,37 @@ def fetch_earthquakes():
_mark_fresh("earthquakes") _mark_fresh("earthquakes")
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes # Satellite GP data cache — re-download from CelesTrak only every 30 minutes
_sat_gp_cache = {"data": None, "last_fetch": 0} _sat_gp_cache = {"data": None, "last_fetch": 0, "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"
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: # 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")
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 — module-level constant. # Satellite intelligence classification database — module-level constant.
# Key: substring to match in OBJECT_NAME → {country, mission, sat_type, wiki} # Key: substring to match in OBJECT_NAME → {country, mission, sat_type, wiki}
@@ -1868,31 +1919,39 @@ def _fetch_satellites_from_tle_api():
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
search_terms.add(term) search_terms.add(term)
all_results = [] def _fetch_term(term):
seen_ids = set() """Fetch a single search term from TLE API."""
for term in search_terms: results = []
try: try:
url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json" url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json"
response = fetch_with_curl(url, timeout=10) response = fetch_with_curl(url, timeout=8)
if response.status_code != 200: if response.status_code != 200:
continue return results
data = response.json() data = response.json()
for member in data.get("member", []): for member in data.get("member", []):
sat_id = member.get("satelliteId")
if sat_id in seen_ids:
continue
seen_ids.add(sat_id)
gp = _parse_tle_to_gp( gp = _parse_tle_to_gp(
member.get("name", "UNKNOWN"), member.get("name", "UNKNOWN"),
sat_id, member.get("satelliteId"),
member.get("line1", ""), member.get("line1", ""),
member.get("line2", ""), member.get("line2", ""),
) )
if gp: if gp:
all_results.append(gp) results.append(gp)
except Exception as e: except Exception as e:
logger.debug(f"TLE fallback search '{term}' failed: {e}") logger.debug(f"TLE fallback search '{term}' failed: {e}")
continue 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 return all_results
@@ -1905,25 +1964,28 @@ def fetch_satellites():
now_ts = time.time() now_ts = time.time()
if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800: if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800:
# Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks # 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 = [ gp_urls = [
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json", "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json", "https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
] ]
for url in gp_urls: for url in gp_urls:
try: try:
response = fetch_with_curl(url, timeout=8) response = fetch_with_curl(url, timeout=5)
if response.status_code == 200: if response.status_code == 200:
gp_data = response.json() gp_data = response.json()
if isinstance(gp_data, list) and len(gp_data) > 100: if isinstance(gp_data, list) and len(gp_data) > 100:
_sat_gp_cache["data"] = gp_data _sat_gp_cache["data"] = gp_data
_sat_gp_cache["last_fetch"] = now_ts _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}") logger.info(f"Satellites: Downloaded {len(gp_data)} GP records from {url}")
break break
except Exception as e: except Exception as e:
logger.warning(f"Satellites: Failed to fetch from {url}: {e}") logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
continue continue
# Fallback: if CelesTrak is blocked, use tle.ivanstanojevic.me TLE API # Fallback 1: TLE API (parallel fetch)
if _sat_gp_cache["data"] is None: if _sat_gp_cache["data"] is None:
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...") logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
try: try:
@@ -1931,10 +1993,20 @@ def fetch_satellites():
if fallback_data and len(fallback_data) > 10: if fallback_data and len(fallback_data) > 10:
_sat_gp_cache["data"] = fallback_data _sat_gp_cache["data"] = fallback_data
_sat_gp_cache["last_fetch"] = now_ts _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") logger.info(f"Satellites: Got {len(fallback_data)} records from TLE fallback API")
except Exception as e: except Exception as e:
logger.error(f"Satellites: TLE fallback also failed: {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"] data = _sat_gp_cache["data"]
if not data: if not data:
logger.warning("No satellite GP data available from any source") logger.warning("No satellite GP data available from any source")
@@ -1942,33 +2014,40 @@ def fetch_satellites():
return return
# Only keep satellites matching the intel classification DB # Only keep satellites matching the intel classification DB
classified = [] # Skip re-classification if TLEs haven't changed (saves O(n*m) scan)
for sat in data: if _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"] and _sat_classified_cache["data"]:
name = sat.get("OBJECT_NAME", "UNKNOWN").upper() classified = _sat_classified_cache["data"]
intel = None logger.info(f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)")
for key, meta in _SAT_INTEL_DB: else:
if key.upper() in name: classified = []
intel = dict(meta) for sat in data:
break name = sat.get("OBJECT_NAME", "UNKNOWN").upper()
if not intel: intel = None
continue # Skip junk, debris, CubeSats, bulk constellations for key, meta in _SAT_INTEL_DB:
entry = { if key.upper() in name:
"id": sat.get("NORAD_CAT_ID"), intel = dict(meta)
"name": sat.get("OBJECT_NAME", "UNKNOWN"), break
"MEAN_MOTION": sat.get("MEAN_MOTION"), if not intel:
"ECCENTRICITY": sat.get("ECCENTRICITY"), continue # Skip junk, debris, CubeSats, bulk constellations
"INCLINATION": sat.get("INCLINATION"), entry = {
"RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"), "id": sat.get("NORAD_CAT_ID"),
"ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"), "name": sat.get("OBJECT_NAME", "UNKNOWN"),
"MEAN_ANOMALY": sat.get("MEAN_ANOMALY"), "MEAN_MOTION": sat.get("MEAN_MOTION"),
"BSTAR": sat.get("BSTAR"), "ECCENTRICITY": sat.get("ECCENTRICITY"),
"EPOCH": sat.get("EPOCH"), "INCLINATION": sat.get("INCLINATION"),
} "RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
entry.update(intel) "ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"),
classified.append(entry) "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 all_sats = classified
logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog")
# Propagate orbital elements to get current lat/lng/alt using SGP4 # Propagate orbital elements to get current lat/lng/alt using SGP4
now = datetime.utcnow() now = datetime.utcnow()
@@ -2063,9 +2142,11 @@ def fetch_satellites():
# Only overwrite if we got data — don't wipe the map on API timeout # Only overwrite if we got data — don't wipe the map on API timeout
if sats: if sats:
latest_data["satellites"] = sats latest_data["satellites"] = sats
latest_data["satellite_source"] = _sat_gp_cache.get("source", "none")
_mark_fresh("satellites") _mark_fresh("satellites")
elif not latest_data.get("satellites"): elif not latest_data.get("satellites"):
latest_data["satellites"] = [] latest_data["satellites"] = []
latest_data["satellite_source"] = "none"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Real UAV detection from ADS-B data — filters military drone transponders # Real UAV detection from ADS-B data — filters military drone transponders
@@ -2275,14 +2356,14 @@ def update_slow_data():
logger.info("Slow-tier update complete.") logger.info("Slow-tier update complete.")
def update_all_data(): def update_all_data():
"""Full update — runs on startup. Fast and slow tiers run IN PARALLEL for fastest startup.""" """Full update — runs on startup. All tiers run IN PARALLEL for fastest startup."""
logger.info("Full data update starting (parallel)...") logger.info("Full data update starting (parallel)...")
fetch_airports() # Cached after first download # Run airports, fast, and slow ALL in parallel so the user sees data ASAP
# Run fast + slow in parallel so the user sees data ASAP with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: f0 = pool.submit(fetch_airports) # Cached after first download
f1 = pool.submit(update_fast_data) f1 = pool.submit(update_fast_data)
f2 = pool.submit(update_slow_data) f2 = pool.submit(update_slow_data)
concurrent.futures.wait([f1, f2]) concurrent.futures.wait([f0, f1, f2])
logger.info("Full data update complete.") logger.info("Full data update complete.")
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
+15 -1
View File
@@ -24,6 +24,11 @@ _BASH_PATH = shutil.which("bash") or "bash"
_domain_fail_cache: dict[str, float] = {} _domain_fail_cache: dict[str, float] = {}
_DOMAIN_FAIL_TTL = 300 # 5 minutes _DOMAIN_FAIL_TTL = 300 # 5 minutes
# Circuit breaker: track domains where BOTH requests AND curl fail
# If a domain failed completely within the last 2 minutes, skip it entirely
_circuit_breaker: dict[str, float] = {}
_CIRCUIT_BREAKER_TTL = 120 # 2 minutes
class _DummyResponse: class _DummyResponse:
"""Minimal response object matching requests.Response interface.""" """Minimal response object matching requests.Response interface."""
def __init__(self, status_code, text): def __init__(self, status_code, text):
@@ -54,6 +59,10 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
domain = urlparse(url).netloc domain = urlparse(url).netloc
# Circuit breaker: if domain failed completely <2min ago, fail fast
if domain in _circuit_breaker and (time.time() - _circuit_breaker[domain]) < _CIRCUIT_BREAKER_TTL:
raise Exception(f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)")
# Check if this domain recently failed with requests — skip straight to curl # Check if this domain recently failed with requests — skip straight to curl
if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL: if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL:
pass # Fall through to curl below pass # Fall through to curl below
@@ -64,8 +73,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
else: else:
res = _session.get(url, timeout=timeout, headers=default_headers) res = _session.get(url, timeout=timeout, headers=default_headers)
res.raise_for_status() res.raise_for_status()
# Clear failure cache on success # Clear failure caches on success
_domain_fail_cache.pop(domain, None) _domain_fail_cache.pop(domain, None)
_circuit_breaker.pop(domain, None)
return res return res
except Exception as e: except Exception as e:
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...") logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
@@ -92,10 +102,14 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
lines = res.stdout.rstrip().rsplit("\n", 1) lines = res.stdout.rstrip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else res.stdout body = lines[0] if len(lines) > 1 else res.stdout
http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200 http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200
if http_code < 400:
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
return _DummyResponse(http_code, body) return _DummyResponse(http_code, body)
else: else:
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}") logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "") return _DummyResponse(500, "")
except Exception as curl_e: except Exception as curl_e:
logger.error(f"bash curl fallback exception: {curl_e}") logger.error(f"bash curl fallback exception: {curl_e}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "") return _DummyResponse(500, "")
+36 -14
View File
@@ -1,6 +1,8 @@
import logging import logging
import time
import concurrent.futures import concurrent.futures
from urllib.parse import quote from urllib.parse import quote
import requests as _requests
from cachetools import TTLCache from cachetools import TTLCache
from services.network_utils import fetch_with_curl from services.network_utils import fetch_with_curl
@@ -10,26 +12,46 @@ logger = logging.getLogger(__name__)
# Key: rounded lat/lng grid (0.1 degree ≈ 11km) # Key: rounded lat/lng grid (0.1 degree ≈ 11km)
dossier_cache = TTLCache(maxsize=500, ttl=86400) dossier_cache = TTLCache(maxsize=500, ttl=86400)
# Nominatim requires max 1 req/sec — track last call time
_nominatim_last_call = 0.0
def _reverse_geocode(lat: float, lng: float) -> dict: def _reverse_geocode(lat: float, lng: float) -> dict:
global _nominatim_last_call
url = ( url = (
f"https://nominatim.openstreetmap.org/reverse?" f"https://nominatim.openstreetmap.org/reverse?"
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en" f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
) )
try: headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
res = fetch_with_curl(url, timeout=10)
if res.status_code == 200: for attempt in range(2):
data = res.json() # Enforce Nominatim's 1 req/sec policy
addr = data.get("address", {}) elapsed = time.time() - _nominatim_last_call
return { if elapsed < 1.1:
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "", time.sleep(1.1 - elapsed)
"state": addr.get("state") or addr.get("region") or "", _nominatim_last_call = time.time()
"country": addr.get("country") or "",
"country_code": (addr.get("country_code") or "").upper(), try:
"display_name": data.get("display_name", ""), # Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
} res = _requests.get(url, timeout=10, headers=headers)
except Exception as e: if res.status_code == 200:
logger.warning(f"Reverse geocode failed: {e}") data = res.json()
addr = data.get("address", {})
return {
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
"state": addr.get("state") or addr.get("region") or "",
"country": addr.get("country") or "",
"country_code": (addr.get("country_code") or "").upper(),
"display_name": data.get("display_name", ""),
}
elif res.status_code == 429:
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
time.sleep(2)
continue
else:
logger.warning(f"Nominatim returned {res.status_code}")
except Exception as e:
logger.warning(f"Reverse geocode failed: {e}")
return {} return {}
+5 -5
View File
@@ -10,7 +10,7 @@ FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them. # NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them.
# Default empty = auto-detect from browser hostname at runtime. # Default empty = auto-detect from browser hostname at runtime.
ARG NEXT_PUBLIC_API_URL="" ARG NEXT_PUBLIC_API_URL=""
@@ -19,8 +19,8 @@ RUN npm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -36,7 +36,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT=3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]
+4 -12
View File
@@ -1,9 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// BACKEND_URL is a plain (non-NEXT_PUBLIC_) env var read at server startup — // /api/* requests are proxied to the backend by the catch-all route handler at
// not baked at build time — so it can be set in docker-compose `environment`. // src/app/api/[...path]/route.ts, which reads BACKEND_URL at request time.
// Defaults to localhost for local dev where both services run on the same host. // Do NOT add rewrites for /api/* here — next.config is evaluated at build time,
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000"; // so any URL baked in here ignores the runtime BACKEND_URL env var.
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'], transpilePackages: ['react-map-gl', 'mapbox-gl', 'maplibre-gl'],
@@ -14,14 +14,6 @@ const nextConfig: NextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.5.0", "version": "0.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"", "dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
-95
View File
@@ -1,95 +0,0 @@
/**
* Catch-all proxy route — forwards /api/* requests from the browser to the
* backend server. BACKEND_URL is a plain server-side env var (not NEXT_PUBLIC_),
* so it is read at request time from the runtime environment, never baked into
* the client bundle or the build manifest.
*
* Set BACKEND_URL in docker-compose `environment:` (e.g. http://backend:8000)
* to use Docker internal networking. Defaults to http://localhost:8000 for
* local development where both services run on the same host.
*/
import { NextRequest, NextResponse } from "next/server";
// Headers that must not be forwarded to the backend.
const STRIP_REQUEST = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host",
]);
// Headers that must not be forwarded back to the browser.
// content-encoding and content-length are stripped because Node.js fetch()
// automatically decompresses gzip/br responses — forwarding these headers
// would cause ERR_CONTENT_DECODING_FAILED in the browser.
const STRIP_RESPONSE = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
"content-encoding", "content-length",
]);
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
const targetUrl = new URL(`/api/${path.join("/")}`, backendUrl);
targetUrl.search = req.nextUrl.search;
// Forward relevant request headers
const forwardHeaders = new Headers();
req.headers.forEach((value, key) => {
if (!STRIP_REQUEST.has(key.toLowerCase())) {
forwardHeaders.set(key, value);
}
});
const isBodyless = req.method === "GET" || req.method === "HEAD";
let upstream: Response;
try {
upstream = await fetch(targetUrl.toString(), {
method: req.method,
headers: forwardHeaders,
body: isBodyless ? undefined : req.body,
// Required for streaming request bodies in Node.js fetch
// @ts-ignore
duplex: "half",
});
} catch (err) {
// Backend unreachable — return a clean 502 so the UI can handle it gracefully
return new NextResponse(JSON.stringify({ error: "Backend unavailable" }), {
status: 502,
headers: { "Content-Type": "application/json" },
});
}
// Forward response headers
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
// 304 responses must have no body
if (upstream.status === 304) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
+34 -15
View File
@@ -10,6 +10,7 @@ import NewsFeed from "@/components/NewsFeed";
import MarketsPanel from "@/components/MarketsPanel"; import MarketsPanel from "@/components/MarketsPanel";
import FilterPanel from "@/components/FilterPanel"; import FilterPanel from "@/components/FilterPanel";
import FindLocateBar from "@/components/FindLocateBar"; import FindLocateBar from "@/components/FindLocateBar";
import TopRightControls from "@/components/TopRightControls";
import RadioInterceptPanel from "@/components/RadioInterceptPanel"; import RadioInterceptPanel from "@/components/RadioInterceptPanel";
import SettingsPanel from "@/components/SettingsPanel"; import SettingsPanel from "@/components/SettingsPanel";
import MapLegend from "@/components/MapLegend"; import MapLegend from "@/components/MapLegend";
@@ -28,7 +29,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]); const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
@@ -50,7 +51,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
return; return;
} }
// Geocode with Nominatim (debounced) // Geocode with Nominatim (debounced)
clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
if (q.trim().length < 2) { setResults([]); return; } if (q.trim().length < 2) { setResults([]); return; }
timerRef.current = setTimeout(async () => { timerRef.current = setTimeout(async () => {
setLoading(true); setLoading(true);
@@ -298,23 +299,32 @@ export default function Dashboard() {
const slowEtag = useRef<string | null>(null); const slowEtag = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
// Track whether we've received substantial data yet (backend may still be starting up)
let hasData = false;
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
const fetchFastData = async () => { const fetchFastData = async () => {
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current; if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers }); const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
if (res.status === 304) { setBackendStatus('connected'); return; } if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
if (res.ok) { if (res.ok) {
setBackendStatus('connected'); setBackendStatus('connected');
fastEtag.current = res.headers.get('etag') || null; fastEtag.current = res.headers.get('etag') || null;
const json = await res.json(); const json = await res.json();
dataRef.current = { ...dataRef.current, ...json }; dataRef.current = { ...dataRef.current, ...json };
setDataVersion(v => v + 1); setDataVersion(v => v + 1);
// Check if we got real data (backend finished loading)
const flights = json.commercial_flights?.length || 0;
if (flights > 100) hasData = true;
} }
} catch (e) { } catch (e) {
console.error("Failed fetching fast live data", e); console.error("Failed fetching fast live data", e);
setBackendStatus('disconnected'); setBackendStatus('disconnected');
} }
scheduleNext('fast');
}; };
const fetchSlowData = async () => { const fetchSlowData = async () => {
@@ -322,7 +332,7 @@ export default function Dashboard() {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current; if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers }); const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
if (res.status === 304) return; if (res.status === 304) { scheduleNext('slow'); return; }
if (res.ok) { if (res.ok) {
slowEtag.current = res.headers.get('etag') || null; slowEtag.current = res.headers.get('etag') || null;
const json = await res.json(); const json = await res.json();
@@ -332,19 +342,26 @@ export default function Dashboard() {
} catch (e) { } catch (e) {
console.error("Failed fetching slow live data", e); console.error("Failed fetching slow live data", e);
} }
scheduleNext('slow');
};
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
const scheduleNext = (tier: 'fast' | 'slow') => {
if (tier === 'fast') {
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
fastTimerId = setTimeout(fetchFastData, delay);
} else {
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
slowTimerId = setTimeout(fetchSlowData, delay);
}
}; };
fetchFastData(); fetchFastData();
fetchSlowData(); fetchSlowData();
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
// Slow polling: 120s (backend updates every 30min)
const fastInterval = setInterval(fetchFastData, 60000);
const slowInterval = setInterval(fetchSlowData, 120000);
return () => { return () => {
clearInterval(fastInterval); if (fastTimerId) clearTimeout(fastTimerId);
clearInterval(slowInterval); if (slowTimerId) clearTimeout(slowTimerId);
}; };
}, []); }, []);
@@ -418,7 +435,7 @@ export default function Dashboard() {
{/* LEFT HUD CONTAINER */} {/* LEFT HUD CONTAINER */}
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none"> <div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
{/* LEFT PANEL - DATA LAYERS */} {/* LEFT PANEL - DATA LAYERS */}
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} /> <WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
{/* LEFT BOTTOM - DISPLAY CONFIG */} {/* LEFT BOTTOM - DISPLAY CONFIG */}
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} /> <WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
@@ -426,6 +443,8 @@ export default function Dashboard() {
{/* RIGHT HUD CONTAINER */} {/* RIGHT HUD CONTAINER */}
<div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2"> <div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2">
<TopRightControls />
{/* FIND / LOCATE */} {/* FIND / LOCATE */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<FindLocateBar <FindLocateBar
@@ -473,8 +492,8 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* BOTTOM CENTER COORDINATE / LOCATION BAR */} {/* BOTTOM CENTER COORDINATE / LOCATION BAR — hidden when Sentinel-2 imagery overlay is open */}
<motion.div {!(selectedEntity?.type === 'region_dossier' && regionDossier?.sentinel2) && <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 1 }} transition={{ delay: 1, duration: 1 }}
@@ -530,7 +549,7 @@ export default function Dashboard() {
</div> </div>
</div> </div>
</div> </div>
</motion.div> </motion.div>}
</> </>
)} )}
+2 -4
View File
@@ -656,13 +656,11 @@ export default function CesiumViewer({ data, activeLayers, activeFilters, effect
} }
if (filters.tracked_owner?.length) { if (filters.tracked_owner?.length) {
const op = (f.alert_operator || '').toLowerCase(); const op = (f.alert_operator || '').toLowerCase();
const t1 = (f.alert_tag1 || '').toLowerCase(); const tags = (f.alert_tags || '').toLowerCase();
const t2 = (f.alert_tag2 || '').toLowerCase();
const t3 = (f.alert_tag3 || '').toLowerCase();
const cs = (f.callsign || '').toLowerCase(); const cs = (f.callsign || '').toLowerCase();
if (!filters.tracked_owner.some(sv => { if (!filters.tracked_owner.some(sv => {
const q = sv.toLowerCase(); const q = sv.toLowerCase();
return op.includes(q) || t1.includes(q) || t2.includes(q) || t3.includes(q) || cs.includes(q); return op.includes(q) || tags.includes(q) || cs.includes(q);
})) return false; })) return false;
} }
return true; return true;
+59 -24
View File
@@ -2,45 +2,60 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react"; import { X, Zap, Shield, Satellite, MapPin, Palette, ToggleRight, Bug, Heart } from "lucide-react";
const CURRENT_VERSION = "0.6"; const CURRENT_VERSION = "0.8";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [ const NEW_FEATURES = [
{ {
icon: <Rss size={14} className="text-orange-400" />, icon: <Shield size={14} className="text-pink-400" />,
title: "Custom News Feed Manager", title: "POTUS Fleet Tracking",
desc: "Add, remove, and prioritize up to 20 RSS intelligence sources directly from the Settings panel. Assign weight levels (1-5) to control feed importance. No more editing Python files — your custom feeds persist across restarts.", 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: "orange", color: "pink",
}, },
{ {
icon: <Server size={14} className="text-purple-400" />, icon: <Palette size={14} className="text-yellow-400" />,
title: "Global Data Center Map Layer", title: "Full Aircraft Color-Coding",
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.", 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: "purple",
},
{
icon: <Zap size={14} className="text-yellow-400" />,
title: "Imperative Map Rendering",
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
color: "yellow", color: "yellow",
}, },
{ {
icon: <Shield size={14} className="text-cyan-400" />, icon: <Satellite size={14} className="text-green-400" />,
title: "Enhanced Health Observability", title: "Sentinel-2 Satellite Overhaul",
desc: "The /api/health endpoint now reports per-source freshness timestamps and counts for all data layers — UAVs, FIRMS fires, LiveUAMap, GDELT, and more. Better uptime monitoring for self-hosters.", 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: <MapPin size={14} className="text-blue-400" />,
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: <Zap size={14} className="text-cyan-400" />,
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.",
color: "cyan", color: "cyan",
}, },
{
icon: <ToggleRight size={14} className="text-purple-400" />,
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",
},
]; ];
const BUG_FIXES = [ const BUG_FIXES = [
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs", "POTUS fleet ICAO codes expanded — all Air Force Two (C-32A/B) airframes now correctly identified with gold halo",
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)", "POTUS icon priority fixed — presidential aircraft always show the POTUS icon even when grounded",
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose", "Sentinel-2 imagery no longer overlaps the bottom coordinate bar",
"Start scripts warn on Python 3.13+ compatibility issues before install", "Docker ENV format warnings resolved (legacy syntax → key=value)",
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing", "Settings/Key/Version buttons now cyan in dark mode, grey only in light mode",
"Entries with invalid geocoded coordinates automatically filtered out", ];
const CONTRIBUTORS = [
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
]; ];
export function useChangelog() { export function useChangelog() {
@@ -145,6 +160,26 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
))} ))}
</div> </div>
</div> </div>
{/* Contributors */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-pink-400 font-bold mb-3 flex items-center gap-2">
<Heart size={10} className="text-pink-400" />
COMMUNITY CONTRIBUTORS
</div>
<div className="space-y-1.5">
{CONTRIBUTORS.map((c, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded-lg border border-pink-500/20 bg-pink-500/5">
<span className="text-pink-400 text-[10px] mt-0.5 flex-shrink-0">&hearts;</span>
<div>
<span className="text-[10px] font-mono text-pink-300 font-bold">{c.name}</span>
<span className="text-[9px] font-mono text-[var(--text-muted)]"> {c.desc}</span>
<span className="text-[8px] font-mono text-[var(--text-muted)]"> (PR {c.pr})</span>
</div>
</div>
))}
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}
+1 -2
View File
@@ -106,8 +106,7 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
const ops = new Set<string>(trackedOperators); const ops = new Set<string>(trackedOperators);
for (const f of data?.tracked_flights || []) { for (const f of data?.tracked_flights || []) {
if (f.alert_operator) ops.add(f.alert_operator); if (f.alert_operator) ops.add(f.alert_operator);
if (f.alert_tag1) ops.add(f.alert_tag1); if (f.alert_tags) ops.add(f.alert_tags);
if (f.alert_tag2) ops.add(f.alert_tag2);
} }
return Array.from(ops).sort(); return Array.from(ops).sort();
}, [data?.tracked_flights]); }, [data?.tracked_flights]);
+6 -4
View File
@@ -89,12 +89,13 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
}); });
} }
// Tracked flights // Tracked flights — include tags/owner/name for broad search (first name, last name, etc.)
for (const f of data?.tracked_flights || []) { for (const f of data?.tracked_flights || []) {
const uid = f.icao24 || f.registration || f.callsign || ''; const uid = f.icao24 || f.registration || f.callsign || '';
const operator = f.alert_operator || 'Unknown Operator'; const operator = f.alert_operator || 'Unknown Operator';
const category = f.alert_category || 'Tracked'; const category = f.alert_category || 'Tracked';
const type = f.alert_type || f.model || 'Unknown'; const type = f.alert_type || f.model || 'Unknown';
const extras = [f.alert_tags, f.owner, f.name, f.callsign].filter(Boolean).join(' ');
results.push({ results.push({
id: `tracked-${uid}`, id: `tracked-${uid}`,
label: operator, label: operator,
@@ -104,7 +105,8 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
lat: f.lat, lat: f.lat,
lng: f.lng, lng: f.lng,
entityType: "tracked_flight", entityType: "tracked_flight",
}); _extra: extras,
} as any);
} }
// Ships // Ships
@@ -144,7 +146,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
const q = query.toLowerCase(); const q = query.toLowerCase();
return allEntities return allEntities
.filter(e => { .filter(e => {
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase(); const searchable = `${e.label} ${e.sublabel} ${e.id} ${(e as any)._extra || ''}`.toLowerCase();
return searchable.includes(q); return searchable.includes(q);
}) })
.slice(0, 12); .slice(0, 12);
@@ -177,7 +179,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
ref={inputRef} ref={inputRef}
type="text" type="text"
value={query} value={query}
placeholder="Find aircraft or vessel..." placeholder="Find aircraft, person or vessel..."
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]" className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
+34 -5
View File
@@ -101,10 +101,23 @@ const LEGEND: LegendCategory[] = [
name: "TRACKED AIRCRAFT (ALERT)", name: "TRACKED AIRCRAFT (ALERT)",
color: "text-pink-400 border-pink-500/30", color: "text-pink-400 border-pink-500/30",
items: [ items: [
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" }, { svg: airliner("#FF1493"), label: "VIP / Celebrity / Bizjet (hot pink)" },
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" }, { svg: airliner("#FF2020"), label: "Dictator / Oligarch (red)" },
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" }, { svg: airliner("#3b82f6"), label: "Government / Police / Customs (blue)" },
{ svg: airliner("white"), label: "Alert — General (white)" }, { svg: heli("#32CD32"), label: "Medical / Fire / Rescue (lime)" },
{ svg: airliner("yellow"), label: "Military / Intelligence (yellow)" },
{ svg: airliner("#222"), label: "PIA — Privacy / Stealth (black)" },
{ svg: airliner("#FF8C00"), label: "Private Flights / Joe Cool (orange)" },
{ svg: airliner("white"), label: "Climate Crisis (white)" },
{ svg: airliner("#9B59B6"), label: "Private Jets / Historic / Other (purple)" },
],
},
{
name: "POTUS FLEET",
color: "text-yellow-400 border-yellow-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,6)"><path d="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" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Air Force One / Two (gold ring)" },
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(8,6)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Marine One (gold ring + heli)" },
], ],
}, },
{ {
@@ -138,7 +151,15 @@ const LEGEND: LegendCategory[] = [
name: "GEOPHYSICAL", name: "GEOPHYSICAL",
color: "text-orange-400 border-orange-500/30", color: "text-orange-400 border-orange-500/30",
items: [ items: [
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" }, { svg: circle("#ffcc00"), label: "Earthquake (yellow blob, size = magnitude)" },
],
},
{
name: "WILDFIRES",
color: "text-red-400 border-red-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1C8 7 5 10 5 14a7 7 0 0 0 14 0c0-4-3-7-7-13z" fill="#ff6600" stroke="#ffcc00" stroke-width="1"/></svg>`, label: "Active wildfire / hotspot" },
{ svg: clusterCircle("#cc0000", "#ff3300"), label: "Fire cluster (grouped hotspots)" },
], ],
}, },
{ {
@@ -166,6 +187,14 @@ const LEGEND: LegendCategory[] = [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" }, { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
], ],
}, },
{
name: "INFRASTRUCTURE",
color: "text-purple-400 border-purple-500/30",
items: [
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/></svg>`, label: "Data Center" },
{ svg: circle("#888"), label: "Internet Outage Zone (grey)" },
],
},
{ {
name: "SURVEILLANCE / CCTV", name: "SURVEILLANCE / CCTV",
color: "text-green-400 border-green-500/30", color: "text-green-400 border-green-500/30",
+443 -136
View File
@@ -26,10 +26,12 @@ const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="
const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF2020" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`; const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF2020" stroke="black"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#1A3A8A" stroke="#4A80D0"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`; const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#1A3A8A" stroke="#4A80D0"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000" stroke-width="2"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`; const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000" stroke-width="2"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff66b2" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff66b2" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`; const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF1493" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff0000" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`; const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff0000" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#000080" stroke="#4A80D0"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#4A80D0" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`; const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#000080" stroke="#4A80D0"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#4A80D0" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`; const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#3b82f6" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#3b82f6" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#32CD32" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#32CD32" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#666"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#999" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`; const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`; const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`; const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
@@ -88,6 +90,18 @@ function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic',
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`; return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
} }
// POTUS fleet — oversized hot pink with yellow halo ring
const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(4,4)"><path d="${AIRLINER_PATH}" fill="#FF1493" stroke="black"/><circle cx="7" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`)}`;
const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,4)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></g></svg>`)}`;
// 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 // Pre-built aircraft SVGs by type & color
const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan'); const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00'); const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00');
@@ -96,7 +110,10 @@ const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow');
const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22); const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22);
const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22); const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22);
const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22); const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22);
const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#ff0000', 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 svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan');
const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00'); const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00');
@@ -105,7 +122,10 @@ const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow');
const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22); const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22);
const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22); const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22);
const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22); const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22);
const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#ff0000', 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 svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan');
const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00'); const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00');
@@ -114,7 +134,10 @@ const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow');
const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22); const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22);
const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22); const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22);
const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22); const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22);
const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#ff0000', 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) // Grey variants for grounded/parked aircraft (altitude 0)
const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333'); const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333');
@@ -316,10 +339,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
dataTimestamp.current = Date.now(); dataTimestamp.current = Date.now();
}, [data?.commercial_flights, data?.ships, data?.satellites]); }, [data?.commercial_flights, data?.ships, data?.satellites]);
// Tick every 2s between data refreshes to animate positions // Tick every 1s between data refreshes to animate positions
// Satellites move ~7km/s so need frequent updates for smooth motion // Satellites move ~7km/s so need frequent updates for smooth motion
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setInterpTick(t => t + 1), 2000); const timer = setInterval(() => setInterpTick(t => t + 1), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -334,21 +357,26 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
let isMounted = true; let isMounted = true;
let callsign = null; let callsign = null;
let entityLat = 0;
let entityLng = 0;
if (selectedEntity && data) { if (selectedEntity && data) {
let entity = null; let entity = null;
if (selectedEntity.type === 'flight') entity = data?.commercial_flights?.[selectedEntity.id as number]; if (selectedEntity.type === 'flight') entity = data?.commercial_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number]; else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number]; else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number]; else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number];
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
if (entity && entity.callsign) { if (entity && entity.callsign) {
callsign = entity.callsign; callsign = entity.callsign;
entityLat = entity.lat ?? 0;
entityLng = entity.lng ?? 0;
} }
} }
if (callsign && callsign !== prevCallsign.current) { if (callsign && callsign !== prevCallsign.current) {
prevCallsign.current = callsign; prevCallsign.current = callsign;
fetch(`${API_BASE}/api/route/${callsign}`) fetch(`${API_BASE}/api/route/${callsign}?lat=${entityLat}&lng=${entityLng}`)
.then(res => res.json()) .then(res => res.json())
.then(routeData => { .then(routeData => {
if (isMounted) setDynamicRoute(routeData); if (isMounted) setDynamicRoute(routeData);
@@ -543,8 +571,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
type: 'datacenter', type: 'datacenter',
name: dc.name || 'Unknown', name: dc.name || 'Unknown',
company: dc.company || '', company: dc.company || '',
street: dc.street || '',
city: dc.city || '', city: dc.city || '',
country: dc.country || '', country: dc.country || '',
zip: dc.zip || '',
}, },
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] } geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
})) }))
@@ -595,6 +625,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
loadImg('svgHeliCyan', svgHeliCyan); loadImg('svgHeliCyan', svgHeliCyan);
loadImg('svgHeliOrange', svgHeliOrange); loadImg('svgHeliOrange', svgHeliOrange);
loadImg('svgHeliPurple', svgHeliPurple); loadImg('svgHeliPurple', svgHeliPurple);
loadImg('svgHeliBlue', svgHeliBlue);
loadImg('svgHeliLime', svgHeliLime);
loadImg('svgFighter', svgFighter); loadImg('svgFighter', svgFighter);
loadImg('svgTanker', svgTanker); loadImg('svgTanker', svgTanker);
loadImg('svgRecon', svgRecon); loadImg('svgRecon', svgRecon);
@@ -636,17 +668,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
loadImg('svgHeliDarkBlue', svgHeliDarkBlue); loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert); loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
loadImg('svgHeliBlack', svgHeliBlack); loadImg('svgHeliBlack', svgHeliBlack);
loadImg('svgPotusPlane', svgPotusPlane);
loadImg('svgPotusHeli', svgPotusHeli);
loadImg('svgAirlinerPink', svgAirlinerPink); loadImg('svgAirlinerPink', svgAirlinerPink);
loadImg('svgAirlinerRed', svgAirlinerRed); loadImg('svgAirlinerRed', svgAirlinerRed);
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue); loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
loadImg('svgAirlinerBlue', svgAirlinerBlue);
loadImg('svgAirlinerLime', svgAirlinerLime);
loadImg('svgAirlinerBlack', svgAirlinerBlack);
loadImg('svgAirlinerWhite', svgAirlinerWhite); loadImg('svgAirlinerWhite', svgAirlinerWhite);
loadImg('svgTurbopropPink', svgTurbopropPink); loadImg('svgTurbopropPink', svgTurbopropPink);
loadImg('svgTurbopropRed', svgTurbopropRed); loadImg('svgTurbopropRed', svgTurbopropRed);
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue); loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
loadImg('svgTurbopropBlue', svgTurbopropBlue);
loadImg('svgTurbopropLime', svgTurbopropLime);
loadImg('svgTurbopropBlack', svgTurbopropBlack);
loadImg('svgTurbopropWhite', svgTurbopropWhite); loadImg('svgTurbopropWhite', svgTurbopropWhite);
loadImg('svgBizjetPink', svgBizjetPink); loadImg('svgBizjetPink', svgBizjetPink);
loadImg('svgBizjetRed', svgBizjetRed); loadImg('svgBizjetRed', svgBizjetRed);
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue); loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
loadImg('svgBizjetBlue', svgBizjetBlue);
loadImg('svgBizjetLime', svgBizjetLime);
loadImg('svgBizjetBlack', svgBizjetBlack);
loadImg('svgBizjetWhite', svgBizjetWhite); loadImg('svgBizjetWhite', svgBizjetWhite);
loadImg('svgDrone', svgDrone); loadImg('svgDrone', svgDrone);
loadImg('svgCctv', svgCctv); loadImg('svgCctv', svgCctv);
@@ -697,10 +740,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
// Helper: interpolate a flight's position if airborne and has speed+heading // Helper: interpolate a flight's position if airborne and has speed+heading
const interpFlight = (f: any): [number, number] => { const interpFlight = (f: any): [number, number] => {
// Fast path: skip trig for stationary/grounded/no-speed aircraft
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat]; if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat]; if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
// Only interpolate if enough time has passed to matter (>1s)
if (dtSeconds < 1) return [f.lng, f.lat]; if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0; const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds); const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
@@ -716,8 +757,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}; };
// Helper: interpolate a satellite's position between API updates // Helper: interpolate a satellite's position between API updates
// Satellites have deterministic orbits so linear interpolation over 60s is accurate
// maxDt=65 allows full interval coverage (60s update + 5s buffer)
const interpSat = (s: any): [number, number] => { const interpSat = (s: any): [number, number] => {
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat]; if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65); const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
@@ -732,15 +771,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
features: data.satellites.filter((s: any) => s.lat != null && s.lng != null && inView(s.lat, s.lng)).map((s: any, i: number) => ({ features: data.satellites.filter((s: any) => s.lat != null && s.lng != null && inView(s.lat, s.lng)).map((s: any, i: number) => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { properties: {
id: s.id || i, id: s.id || i, type: 'satellite', name: s.name, mission: s.mission || 'general',
type: 'satellite', sat_type: s.sat_type || 'Satellite', country: s.country || '', alt_km: s.alt_km || 0,
name: s.name, wiki: s.wiki || '', color: MISSION_COLORS[s.mission] || '#aaaaaa',
mission: s.mission || 'general',
sat_type: s.sat_type || 'Satellite',
country: s.country || '',
alt_km: s.alt_km || 0,
wiki: s.wiki || '',
color: MISSION_COLORS[s.mission] || '#aaaaaa',
iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen' iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen'
}, },
geometry: { type: 'Point' as const, coordinates: interpSat(s) } geometry: { type: 'Point' as const, coordinates: interpSat(s) }
@@ -748,8 +781,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}; };
}, [activeLayers.satellites, data?.satellites, dtSeconds, inView]); }, [activeLayers.satellites, data?.satellites, dtSeconds, inView]);
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
const commFlightsGeoJSON = useMemo(() => { const commFlightsGeoJSON = useMemo(() => {
if (!activeLayers.flights || !data?.commercial_flights) return null; if (!activeLayers.flights || !data?.commercial_flights) return null;
return { return {
@@ -812,7 +843,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const milFlightsGeoJSON = useMemo(() => { const milFlightsGeoJSON = useMemo(() => {
if (!activeLayers.military || !data?.military_flights) return null; if (!activeLayers.military || !data?.military_flights) return null;
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: data.military_flights.map((f: any, i: number) => { features: data.military_flights.map((f: any, i: number) => {
@@ -841,34 +871,21 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const shipsGeoJSON = useMemo(() => { const shipsGeoJSON = useMemo(() => {
if (!(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) || !data?.ships) return null; if (!(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) || !data?.ships) return null;
return { return {
type: 'FeatureCollection', type: 'FeatureCollection',
features: data.ships.map((s: any, i: number) => { features: data.ships.map((s: any, i: number) => {
if (s.lat == null || s.lng == null) return null; if (s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) 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 isImportant = s.type === 'carrier' || s.type === 'military_vessel' || s.type === 'tanker' || s.type === 'cargo';
const isPassenger = s.type === 'passenger'; const isPassenger = s.type === 'passenger';
// Carriers are now handled by a dedicated unclustered source
if (s.type === 'carrier') return null; if (s.type === 'carrier') return null;
if (isImportant && activeLayers?.ships_important === false) return null; if (isImportant && activeLayers?.ships_important === false) return null;
if (isPassenger && activeLayers?.ships_passenger === false) return null; if (isPassenger && activeLayers?.ships_passenger === false) return null;
if (!isImportant && !isPassenger && activeLayers?.ships_civilian === false) return null; if (!isImportant && !isPassenger && activeLayers?.ships_civilian === false) return null;
let iconId = 'svgShipBlue'; let iconId = 'svgShipBlue';
if (s.type === 'carrier') { if (s.type === 'tanker' || s.type === 'cargo') iconId = 'svgShipRed';
iconId = 'svgCarrier'; else if (s.type === 'yacht' || s.type === 'passenger') iconId = 'svgShipWhite';
} else if (s.type === 'tanker' || s.type === 'cargo') { else if (s.type === 'military_vessel') iconId = 'svgShipYellow';
iconId = 'svgShipRed';
} else if (s.type === 'yacht' || s.type === 'passenger') {
iconId = 'svgShipWhite';
} else if (s.type === 'military_vessel') {
iconId = 'svgShipYellow';
}
const [iLng, iLat] = interpShip(s); const [iLng, iLat] = interpShip(s);
return { return {
type: 'Feature', type: 'Feature',
@@ -958,11 +975,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
type: 'FeatureCollection', type: 'FeatureCollection',
features: data.ships.map((s: any, i: number) => { features: data.ships.map((s: any, i: number) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null; if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
const [iLng, iLat] = interpShip(s);
return { return {
type: 'Feature', type: 'Feature',
properties: { id: i, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' }, properties: { id: i, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' },
geometry: { type: 'Point', coordinates: [iLng, iLat] } geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
}; };
}).filter(Boolean) }).filter(Boolean)
}; };
@@ -976,6 +992,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number]; else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number]; else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number]; else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number];
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
else if (selectedEntity.type === 'ship') entity = data?.ships?.[selectedEntity.id as number]; else if (selectedEntity.type === 'ship') entity = data?.ships?.[selectedEntity.id as number];
if (!entity) return null; if (!entity) return null;
@@ -987,15 +1004,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
if (dynamicRoute && dynamicRoute.orig_loc && dynamicRoute.dest_loc) { if (dynamicRoute && dynamicRoute.orig_loc && dynamicRoute.dest_loc) {
originLoc = dynamicRoute.orig_loc; originLoc = dynamicRoute.orig_loc;
destLoc = dynamicRoute.dest_loc; destLoc = dynamicRoute.dest_loc;
// Also override display names so NewsFeed shows the resolved airport info
if (dynamicRoute.origin_name) entity.origin_name = dynamicRoute.origin_name;
if (dynamicRoute.dest_name) entity.dest_name = dynamicRoute.dest_name;
} }
const features = []; const features = [];
// Extract IATA codes from "IATA: Airport Name" format
const originCode = (entity.origin_name || '').split(':')[0]?.trim() || '';
const destCode = (entity.dest_name || '').split(':')[0]?.trim() || '';
if (originLoc) { if (originLoc) {
features.push({ features.push({
type: 'Feature', type: 'Feature',
properties: { type: 'route-origin' }, properties: { type: 'route-origin' },
geometry: { type: 'LineString', coordinates: [currentLoc, originLoc] } geometry: { type: 'LineString', coordinates: [currentLoc, originLoc] }
}); });
// Airport dot at origin
features.push({
type: 'Feature',
properties: { type: 'airport', code: originCode, role: 'DEP' },
geometry: { type: 'Point', coordinates: originLoc }
});
} }
if (destLoc) { if (destLoc) {
features.push({ features.push({
@@ -1003,6 +1033,12 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
properties: { type: 'route-dest' }, properties: { type: 'route-dest' },
geometry: { type: 'LineString', coordinates: [currentLoc, destLoc] } geometry: { type: 'LineString', coordinates: [currentLoc, destLoc] }
}); });
// Airport dot at destination
features.push({
type: 'Feature',
properties: { type: 'airport', code: destCode, role: 'ARR' },
geometry: { type: 'Point', coordinates: destLoc }
});
} }
if (features.length === 0) return null; if (features.length === 0) return null;
@@ -1145,37 +1181,40 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
})); }));
}, [data?.news, Math.round(viewState.zoom)]); }, [data?.news, Math.round(viewState.zoom)]);
// Tracked flights GeoJSON with interpolation
const trackedFlightsGeoJSON = useMemo(() => { const trackedFlightsGeoJSON = useMemo(() => {
if (!activeLayers.tracked || !data?.tracked_flights) return null; if (!activeLayers.tracked || !data?.tracked_flights) return null;
// Tracked icon maps by aircraft shape and alert color // Tracked icon maps by aircraft shape and alert color
const trackedIconMap: Record<string, Record<string, string>> = { const trackedIconMap: Record<string, Record<string, string>> = {
heli: { pink: 'svgHeliPink', red: 'svgHeliAlertRed', darkblue: 'svgHeliDarkBlue', white: 'svgHeliWhiteAlert' }, heli: { '#ff1493': 'svgHeliPink', pink: 'svgHeliPink', red: 'svgHeliAlertRed', blue: 'svgHeliBlue', darkblue: 'svgHeliDarkBlue', yellow: 'svgHeli', orange: 'svgHeliOrange', purple: 'svgHeliPurple', '#32cd32': 'svgHeliLime', black: 'svgHeliBlack', white: 'svgHeliWhiteAlert' },
airliner: { pink: 'svgAirlinerPink', red: 'svgAirlinerRed', darkblue: 'svgAirlinerDarkBlue', white: 'svgAirlinerWhite' }, airliner: { '#ff1493': 'svgAirlinerPink', pink: 'svgAirlinerPink', red: 'svgAirlinerRed', blue: 'svgAirlinerBlue', darkblue: 'svgAirlinerDarkBlue', yellow: 'svgAirlinerYellow', orange: 'svgAirlinerOrange', purple: 'svgAirlinerPurple', '#32cd32': 'svgAirlinerLime', black: 'svgAirlinerBlack', white: 'svgAirlinerWhite' },
turboprop: { pink: 'svgTurbopropPink', red: 'svgTurbopropRed', darkblue: 'svgTurbopropDarkBlue', white: 'svgTurbopropWhite' }, turboprop: { '#ff1493': 'svgTurbopropPink', pink: 'svgTurbopropPink', red: 'svgTurbopropRed', blue: 'svgTurbopropBlue', darkblue: 'svgTurbopropDarkBlue', yellow: 'svgTurbopropYellow', orange: 'svgTurbopropOrange', purple: 'svgTurbopropPurple', '#32cd32': 'svgTurbopropLime', black: 'svgTurbopropBlack', white: 'svgTurbopropWhite' },
bizjet: { pink: 'svgBizjetPink', red: 'svgBizjetRed', darkblue: 'svgBizjetDarkBlue', white: 'svgBizjetWhite' }, bizjet: { '#ff1493': 'svgBizjetPink', pink: 'svgBizjetPink', red: 'svgBizjetRed', blue: 'svgBizjetBlue', darkblue: 'svgBizjetDarkBlue', yellow: 'svgBizjetYellow', orange: 'svgBizjetOrange', purple: 'svgBizjetPurple', '#32cd32': 'svgBizjetLime', black: 'svgBizjetBlack', white: 'svgBizjetWhite' },
}; };
return { const features: any[] = [];
type: 'FeatureCollection', for (let i = 0; i < data.tracked_flights.length; i++) {
features: data.tracked_flights.map((f: any, i: number) => { const f = data.tracked_flights[i];
if (f.lat == null || f.lng == null) return null; if (f.lat == null || f.lng == null) continue;
const alertColor = f.alert_color || 'white'; const [lng, lat] = interpFlight(f);
const acType = classifyAircraft(f.model, f.aircraft_category); const alertColor = f.alert_color || 'white';
const grounded = f.alt != null && f.alt <= 100; const acType = classifyAircraft(f.model, f.aircraft_category);
const iconId = grounded ? GROUNDED_ICON_MAP[acType] : (trackedIconMap[acType]?.[alertColor] || trackedIconMap.airliner[alertColor] || 'svgAirlinerWhite'); const grounded = f.alt != null && f.alt <= 100;
const icaoHex = (f.icao24 || '').toUpperCase();
const isPotus = POTUS_ICAOS.has(icaoHex);
const potusIcon = acType === 'heli' ? 'svgPotusHeli' : 'svgPotusPlane';
const iconId = isPotus ? potusIcon : grounded ? GROUNDED_ICON_MAP[acType] : (trackedIconMap[acType]?.[alertColor] || trackedIconMap.airliner[alertColor] || 'svgAirlinerWhite');
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN"; features.push({
type: 'Feature',
const [iLng, iLat] = interpFlight(f); properties: { id: i, type: 'tracked_flight', callsign: String(displayName), rotation: f.heading || 0, iconId },
return { geometry: { type: 'Point', coordinates: [lng, lat] }
type: 'Feature', });
properties: { id: i, type: 'tracked_flight', callsign: String(displayName), rotation: f.heading || 0, iconId }, }
geometry: { type: 'Point', coordinates: [iLng, iLat] } return { type: 'FeatureCollection', features };
};
}).filter(Boolean)
};
}, [activeLayers.tracked, data?.tracked_flights, dtSeconds]); }, [activeLayers.tracked, data?.tracked_flights, dtSeconds]);
const uavGeoJSON = useMemo(() => { const uavGeoJSON = useMemo(() => {
@@ -1250,6 +1289,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
// Interactive layer IDs for click handling
const activeInteractiveLayerIds = [ const activeInteractiveLayerIds = [
commFlightsGeoJSON && 'commercial-flights-layer', commFlightsGeoJSON && 'commercial-flights-layer',
privFlightsGeoJSON && 'private-flights-layer', privFlightsGeoJSON && 'private-flights-layer',
@@ -1273,19 +1313,15 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
// --- Imperative source updates for high-volume layers --- // --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
// Bypasses React reconciliation of huge GeoJSON FeatureCollections.
// The <Source data={EMPTY_FC}> mounts the source; the hook pushes real data.
const mapForHook = mapReady ? mapRef.current : null; const mapForHook = mapReady ? mapRef.current : null;
// Flights & UAVs: immediate (they move fast, stale = visually wrong)
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON); useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON); useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON); useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON); useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON); useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
useImperativeSource(mapForHook, 'uavs', uavGeoJSON); useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
// Satellites & fires: 2s debounce (slow-changing, high feature count) useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON);
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON, 2000);
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000); useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000);
const handleMouseMove = useCallback((evt: any) => { const handleMouseMove = useCallback((evt: any) => {
@@ -1309,7 +1345,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
onViewStateChange?.({ zoom: evt.viewState.zoom, latitude: evt.viewState.latitude }); onViewStateChange?.({ zoom: evt.viewState.zoom, latitude: evt.viewState.latitude });
// Debounce bounds update to avoid thrashing during drag // Debounce bounds update to avoid thrashing during drag
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current); if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(updateBounds, 300); boundsTimerRef.current = setTimeout(updateBounds, 500);
}} }}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onContextMenu={(evt) => { onContextMenu={(evt) => {
@@ -1365,6 +1401,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
/> />
</Source> </Source>
)} )}
{/* Esri Reference Overlay — borders, labels, cities on top of satellite imagery */}
{activeLayers.highres_satellite && (
<Source
id="esri-reference-overlay"
type="raster"
tiles={['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}']}
tileSize={256}
maxzoom={18}
>
<Layer
id="esri-reference-overlay-layer"
type="raster"
paint={{
'raster-opacity': 0.9,
'raster-fade-duration': 300
}}
/>
</Source>
)}
{/* NASA GIBS MODIS Terra — daily satellite imagery overlay */} {/* NASA GIBS MODIS Terra — daily satellite imagery overlay */}
{activeLayers.gibs_imagery && gibsDate && ( {activeLayers.gibs_imagery && gibsDate && (
@@ -1591,12 +1646,13 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
<Layer <Layer
id="active-route-layer" id="active-route-layer"
type="line" type="line"
filter={['in', ['get', 'type'], ['literal', ['route-origin', 'route-dest']]]}
paint={{ paint={{
'line-color': [ 'line-color': [
'match', 'match',
['get', 'type'], ['get', 'type'],
'route-origin', '#38bdf8', // light blue 'route-origin', '#38bdf8',
'route-dest', '#fcd34d', // yellow 'route-dest', '#fcd34d',
'#ffffff' '#ffffff'
], ],
'line-width': 2, 'line-width': 2,
@@ -1604,6 +1660,38 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
'line-opacity': 0.8 'line-opacity': 0.8
}} }}
/> />
{/* Airport dots at origin/destination */}
<Layer
id="airport-dots"
type="circle"
filter={['==', ['get', 'type'], 'airport']}
paint={{
'circle-radius': 5,
'circle-color': ['match', ['get', 'role'], 'DEP', '#38bdf8', 'ARR', '#fcd34d', '#ffffff'],
'circle-stroke-color': '#000',
'circle-stroke-width': 1.5,
'circle-opacity': 0.9
}}
/>
{/* IATA code labels at airports */}
<Layer
id="airport-labels"
type="symbol"
filter={['==', ['get', 'type'], 'airport']}
layout={{
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Bold'],
'text-size': 11,
'text-offset': [0, -1.4],
'text-anchor': 'bottom',
'text-allow-overlap': true,
}}
paint={{
'text-color': ['match', ['get', 'role'], 'DEP', '#38bdf8', 'ARR', '#fcd34d', '#ffffff'],
'text-halo-color': '#000',
'text-halo-width': 1.5,
}}
/>
</Source> </Source>
)} )}
@@ -1624,12 +1712,33 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
{/* tracked-flights & UAVs: data pushed imperatively */} {/* tracked-flights & UAVs: data pushed imperatively */}
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}> <Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
{/* Gold halo ring — POTUS aircraft only (Air Force One/Two, Marine One) */}
<Layer
id="tracked-flights-halo"
type="circle"
filter={['any',
['==', ['get', 'iconId'], 'svgPotusPlane'],
['==', ['get', 'iconId'], 'svgPotusHeli'],
]}
paint={{
'circle-radius': 18,
'circle-color': 'transparent',
'circle-stroke-width': 2,
'circle-stroke-color': 'gold',
'circle-stroke-opacity': opacityFilter,
'circle-opacity': 0,
}}
/>
<Layer <Layer
id="tracked-flights-layer" id="tracked-flights-layer"
type="symbol" type="symbol"
layout={{ layout={{
'icon-image': ['get', 'iconId'], 'icon-image': ['get', 'iconId'],
'icon-size': 0.8, 'icon-size': ['case',
['==', ['get', 'iconId'], 'svgPotusPlane'], 1.3,
['==', ['get', 'iconId'], 'svgPotusHeli'], 1.3,
0.8
],
'icon-allow-overlap': true, 'icon-allow-overlap': true,
'icon-rotate': ['get', 'rotation'], 'icon-rotate': ['get', 'rotation'],
'icon-rotation-alignment': 'map' 'icon-rotation-alignment': 'map'
@@ -1696,16 +1805,46 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Marker> </Marker>
))} ))}
{/* HTML labels for tracked flights (pink names, grey when grounded) */} {/* HTML labels for tracked flights — color-matched, zoom-gated for non-HVA */}
{trackedFlightsGeoJSON && !selectedEntity && data?.tracked_flights?.map((f: any, i: number) => { {trackedFlightsGeoJSON && !selectedEntity && data?.tracked_flights?.map((f: any, i: number) => {
if (f.lat == null || f.lng == null) return null; if (f.lat == null || f.lng == null) return null;
if (!inView(f.lat, f.lng)) return null; if (!inView(f.lat, f.lng)) return null;
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
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<string, string> = {
'#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 grounded = f.alt != null && f.alt <= 100;
const labelColor = grounded ? '#888' : (labelColorMap[alertColor] || alertColor);
const [iLng, iLat] = interpFlight(f); const [iLng, iLat] = interpFlight(f);
return ( return (
<Marker key={`tf-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}> <Marker key={`tf-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
<div style={{ color: grounded ? '#888' : '#ff1493', fontSize: '10px', fontFamily: 'monospace', fontWeight: 'bold', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none' }}> <div style={{
color: labelColor,
fontSize: '10px',
fontFamily: 'monospace',
fontWeight: 'bold',
textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000',
whiteSpace: 'nowrap',
pointerEvents: 'none'
}}>
{String(displayName)} {String(displayName)}
</div> </div>
</Marker> </Marker>
@@ -2389,12 +2528,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span> Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span>
</div> </div>
)} )}
{ship.heading != null && ( <div style={{ marginBottom: 4 }}>
<div style={{ marginBottom: 4 }}> Heading: <span style={{ color: ship.heading != null ? '#888' : '#ff6644' }}>
Heading: <span style={{ color: '#888' }}>{Math.round(ship.heading)}°</span> {ship.heading != null ? `${Math.round(ship.heading)}°` : 'UNKNOWN'}
</span>
</div>
{ship.type === 'carrier' && ship.source && (
<div style={{ marginTop: 6, padding: '5px 7px', background: 'rgba(255,170,0,0.08)', border: '1px solid rgba(255,170,0,0.3)', borderRadius: 4, fontSize: 9, letterSpacing: 1 }}>
<div style={{ color: '#ffaa00', marginBottom: 3 }}>
SOURCE: {ship.source_url ? (
<a href={ship.source_url} target="_blank" rel="noopener noreferrer"
style={{ color: '#00e5ff', textDecoration: 'underline' }}>{ship.source}</a>
) : (
<span style={{ color: '#fff' }}>{ship.source}</span>
)}
</div>
{ship.last_osint_update && (
<div style={{ color: '#888' }}>LAST OSINT UPDATE: {new Date(ship.last_osint_update).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
)}
{ship.desc && (
<div style={{ color: '#aaa', marginTop: 3, fontSize: 8, lineHeight: 1.3 }}>{ship.desc}</div>
)}
</div> </div>
)} )}
{ship.last_osint_update && ( {ship.type !== 'carrier' && ship.last_osint_update && (
<div style={{ marginBottom: 4 }}> <div style={{ marginBottom: 4 }}>
Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span> Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span>
</div> </div>
@@ -2431,6 +2588,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span> Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span>
</div> </div>
)} )}
{dc.street && (
<div style={{ marginBottom: 4 }}>
Address: <span style={{ color: '#fff' }}>{dc.street}{dc.zip ? ` ${dc.zip}` : ''}</span>
</div>
)}
{dc.city && ( {dc.city && (
<div style={{ marginBottom: 4 }}> <div style={{ marginBottom: 4 }}>
Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span> Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span>
@@ -2649,64 +2811,209 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Marker> </Marker>
)} )}
{/* SENTINEL-2 IMAGERY — floating intel card on map near right-click */} {/* SENTINEL-2 IMAGERY — fullscreen overlay modal */}
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && ( {selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (() => {
<Popup const s2 = regionDossier.sentinel2;
longitude={selectedEntity.extra.lng} const imgUrl = s2.fullres_url || s2.thumbnail_url;
latitude={selectedEntity.extra.lat} return (
anchor="top-left" <div
offset={[20, -10]} style={{
closeButton={false} position: 'fixed',
closeOnClick={false} top: 0,
className="sentinel-popup" left: 0,
maxWidth="320px" right: 0,
> bottom: 0,
<div className="bg-black/90 backdrop-blur-md border border-blue-500/50 rounded-lg overflow-hidden shadow-[0_0_25px_rgba(59,130,246,0.3)] pointer-events-auto" style={{ width: 300 }}> zIndex: 9999,
{/* Header bar */} background: 'rgba(0,0,0,0.85)',
<div className="flex items-center justify-between px-3 py-1.5 bg-blue-950/60 border-b border-blue-500/30"> backdropFilter: 'blur(8px)',
<div className="flex items-center gap-2"> display: 'flex',
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" /> alignItems: 'center',
<span className="text-[9px] text-blue-400 font-mono tracking-[0.2em] font-bold">SENTINEL-2 IMAGERY</span> justifyContent: 'center',
padding: '60px 20px 80px 20px',
}}
onClick={(e) => { if (e.target === e.currentTarget) onEntityClick(null); }}
onKeyDown={(e: any) => { if (e.key === 'Escape') onEntityClick(null); }}
tabIndex={-1}
ref={(el) => el?.focus()}
>
<div style={{
background: 'rgba(0,0,0,0.95)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 12,
overflow: 'hidden',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100vh - 80px)',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 0 60px rgba(34,197,94,0.3)',
}}>
{/* Header bar */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'rgba(20,83,45,0.4)',
borderBottom: '1px solid rgba(34,197,94,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', animation: 'pulse 2s infinite' }} />
<span style={{ fontSize: 11, color: '#4ade80', fontFamily: 'monospace', letterSpacing: '0.2em', fontWeight: 'bold' }}>
SENTINEL-2 IMAGERY
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 10, color: 'rgba(134,239,172,0.6)', fontFamily: 'monospace' }}>
{selectedEntity.extra.lat.toFixed(4)}, {selectedEntity.extra.lng.toFixed(4)}
</span>
<button
onClick={() => onEntityClick(null)}
style={{
background: 'rgba(239,68,68,0.2)',
border: '1px solid rgba(239,68,68,0.4)',
borderRadius: 6,
color: '#ef4444',
fontSize: 10,
fontFamily: 'monospace',
padding: '4px 10px',
cursor: 'pointer',
letterSpacing: '0.1em',
}}
>
CLOSE
</button>
</div>
</div> </div>
<span className="text-[8px] text-blue-300/60 font-mono">{selectedEntity.extra.lat.toFixed(3)}, {selectedEntity.extra.lng.toFixed(3)}</span>
{s2.found ? (
<>
{/* Metadata row */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
fontSize: 11,
fontFamily: 'monospace',
borderBottom: '1px solid rgba(20,83,45,0.4)',
}}>
<span style={{ color: '#86efac' }}>{s2.platform}</span>
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>{s2.datetime?.slice(0, 10)}</span>
<span style={{ color: '#86efac' }}>{s2.cloud_cover?.toFixed(0)}% cloud</span>
</div>
{/* Image */}
{imgUrl ? (
<div style={{ flex: 1, overflow: 'auto', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<img
src={imgUrl}
alt="Sentinel-2 scene"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 220px)',
objectFit: 'contain',
display: 'block',
}}
/>
</div>
) : (
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(134,239,172,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
Scene found no preview available
</div>
)}
{/* Action buttons */}
{imgUrl && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: '10px 16px',
background: 'rgba(20,83,45,0.3)',
borderTop: '1px solid rgba(34,197,94,0.2)',
}}>
<a
href={imgUrl}
download={`sentinel2_${selectedEntity.extra.lat.toFixed(4)}_${selectedEntity.extra.lng.toFixed(4)}.jpg`}
target="_blank"
rel="noopener noreferrer"
style={{
background: 'rgba(34,197,94,0.2)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 6,
color: '#4ade80',
fontSize: 10,
fontFamily: 'monospace',
padding: '6px 16px',
cursor: 'pointer',
textDecoration: 'none',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
DOWNLOAD
</a>
<button
onClick={async () => {
try {
const resp = await fetch(imgUrl);
const blob = await resp.blob();
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
]);
} catch {
// fallback: copy URL
await navigator.clipboard.writeText(imgUrl);
}
}}
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.4)',
borderRadius: 6,
color: '#4ade80',
fontSize: 10,
fontFamily: 'monospace',
padding: '6px 16px',
cursor: 'pointer',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
📋 COPY
</button>
<a
href={imgUrl}
target="_blank"
rel="noopener noreferrer"
style={{
background: 'rgba(16,185,129,0.15)',
border: '1px solid rgba(16,185,129,0.4)',
borderRadius: 6,
color: '#10b981',
fontSize: 10,
fontFamily: 'monospace',
padding: '6px 16px',
cursor: 'pointer',
textDecoration: 'none',
letterSpacing: '0.15em',
fontWeight: 'bold',
}}
>
OPEN FULL RES
</a>
</div>
)}
</>
) : (
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(134,239,172,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
No clear imagery in last 30 days
</div>
)}
</div> </div>
{regionDossier.sentinel2.found ? (
<>
{/* Metadata row */}
<div className="flex items-center justify-between px-3 py-1.5 text-[9px] font-mono border-b border-blue-900/40">
<span className="text-blue-300">{regionDossier.sentinel2.platform}</span>
<span className="text-cyan-400 font-bold">{regionDossier.sentinel2.datetime?.slice(0, 10)}</span>
<span className="text-blue-300">{regionDossier.sentinel2.cloud_cover?.toFixed(0)}% cloud</span>
</div>
{/* Thumbnail */}
{regionDossier.sentinel2.thumbnail_url ? (
<a href={regionDossier.sentinel2.fullres_url || regionDossier.sentinel2.thumbnail_url} target="_blank" rel="noopener noreferrer">
<img
src={regionDossier.sentinel2.thumbnail_url}
alt="Sentinel-2 scene"
className="w-full block hover:brightness-110 transition-all cursor-pointer"
style={{ maxHeight: 220, objectFit: 'cover' }}
/>
</a>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">Scene found no preview available</div>
)}
{/* Footer */}
<div className="px-3 py-1 bg-blue-950/40 text-[7px] text-blue-400/50 font-mono tracking-widest text-center">
CLICK IMAGE TO OPEN FULL RESOLUTION
</div>
</>
) : (
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">
No clear imagery in last 30 days
</div>
)}
</div> </div>
</Popup> );
)} })()}
{/* MEASUREMENT LINES */} {/* MEASUREMENT LINES */}
{measurePoints && measurePoints.length >= 2 && ( {measurePoints && measurePoints.length >= 2 && (
+51 -43
View File
@@ -260,28 +260,40 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
if (flight) { if (flight) {
const callsign = flight.callsign || "UNKNOWN"; const callsign = flight.callsign || "UNKNOWN";
const alertColorMap: Record<string, string> = { const alertColorMap: Record<string, string> = {
'pink': 'text-pink-400', 'red': 'text-red-400', '#ff1493': 'text-[#ff1493]', pink: 'text-[#ff1493]', red: 'text-red-400', yellow: 'text-yellow-400',
'darkblue': 'text-blue-400', 'white': 'text-white' blue: 'text-blue-400', orange: 'text-orange-400', '#32cd32': 'text-[#32cd32]', purple: 'text-purple-400',
black: 'text-gray-400', white: 'text-white'
}; };
const alertBorderMap: Record<string, string> = { const alertBorderMap: Record<string, string> = {
'pink': 'border-pink-500/30', 'red': 'border-red-500/30', '#ff1493': 'border-[#ff1493]/30', pink: 'border-[#ff1493]/30', red: 'border-red-500/30', yellow: 'border-yellow-500/30',
'darkblue': 'border-blue-500/30', 'white': 'border-[var(--border-primary)]/30' blue: 'border-blue-500/30', orange: 'border-orange-500/30', '#32cd32': 'border-[#32cd32]/30', purple: 'border-purple-500/30',
black: 'border-gray-500/30', white: 'border-[var(--border-primary)]/30'
}; };
const alertBgMap: Record<string, string> = { const alertBgMap: Record<string, string> = {
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40', '#ff1493': 'bg-[#ff1493]/10', pink: 'bg-[#ff1493]/10', red: 'bg-red-950/40', yellow: 'bg-yellow-950/40',
'darkblue': 'bg-blue-950/40', 'white': 'bg-[var(--bg-panel)]' blue: 'bg-blue-950/40', orange: 'bg-orange-950/40', '#32cd32': 'bg-lime-950/40', purple: 'bg-purple-950/40',
black: 'bg-gray-900/40', white: 'bg-[var(--bg-panel)]'
}; };
const ac = flight.alert_color || 'white'; const ac = flight.alert_color || 'white';
const headerColor = alertColorMap[ac] || 'text-white'; const headerColor = alertColorMap[ac] || 'text-white';
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30'; const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]'; const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
const shadowColor = (ac === 'pink' || ac === '#ff1493') ? 'rgba(255,20,147,0.4)'
: ac === 'red' ? 'rgba(255,32,32,0.2)'
: ac === 'yellow' ? 'rgba(255,255,0,0.2)'
: ac === 'blue' ? 'rgba(59,130,246,0.2)'
: ac === 'orange' ? 'rgba(255,140,0,0.3)'
: ac === '#32cd32' ? 'rgba(50,205,50,0.2)'
: ac === 'purple' ? 'rgba(155,89,182,0.2)'
: 'rgba(255,255,255,0.1)';
return ( return (
<motion.div <motion.div
initial={{ y: 50, opacity: 0 }} initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`} className={`w-full bg-black/60 backdrop-blur-md border ${(ac === 'pink' || ac === '#ff1493') ? 'border-[#ff1493]' : ac === 'red' ? 'border-red-800' : ac === 'yellow' ? 'border-yellow-800' : ac === 'blue' ? 'border-blue-800' : ac === 'orange' ? 'border-orange-800' : ac === '#32cd32' ? 'border-lime-800' : ac === 'purple' ? 'border-purple-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden flex-shrink-0`}
> >
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}> <div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}> <h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
@@ -293,31 +305,39 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<div className="p-4 flex flex-col gap-3"> <div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span> <span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? ( {flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (() => {
<a const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`} const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
target="_blank" return (
rel="noreferrer" <a
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`} href={wikiHref}
title={`Search Wikipedia for ${flight.alert_operator}`} target="_blank"
> rel="noreferrer"
{flight.alert_operator} className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
</a> title={`Search Wikipedia for ${flight.alert_operator}`}
) : ( >
{flight.alert_operator}
</a>
);
})() : (
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span> <span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
)} )}
</div> </div>
{/* Owner/Operator Wikipedia photo */} {/* Owner/Operator Wikipedia photo */}
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && ( {flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
<div className="border-b border-[var(--border-primary)] pb-2"> const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
<WikiImage const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`} return (
label={flight.alert_operator} <div className="border-b border-[var(--border-primary)] pb-2">
maxH="max-h-36" <WikiImage
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'} wikiUrl={wikiHref}
/> label={flight.alert_operator}
</div> maxH="max-h-36"
)} accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
/>
</div>
);
})()}
{/* Aircraft model Wikipedia photo */} {/* Aircraft model Wikipedia photo */}
{aircraftImgUrl && ( {aircraftImgUrl && (
<div className="border-b border-[var(--border-primary)] pb-2"> <div className="border-b border-[var(--border-primary)] pb-2">
@@ -348,22 +368,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span> <span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span> <span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
</div> </div>
{flight.alert_tag1 && ( {flight.alert_tags && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span> <span className="text-[var(--text-muted)] text-[10px]">INTEL TAGS</span>
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span> <span className={`text-xs font-bold text-right max-w-[200px] ${headerColor}`}>{flight.alert_tags}</span>
</div>
)}
{flight.alert_tag2 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
</div>
)}
{flight.alert_tag3 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
</div> </div>
)} )}
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2"> <div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { Github, MessageSquare, Download, AlertCircle, CheckCircle2 } from "lucide-react";
import packageJson from "../../package.json";
export default function TopRightControls() {
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "uptodate" | "error">("idle");
const [latestVersion, setLatestVersion] = useState<string>("");
const currentVersion = packageJson.version;
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', '');
if (latest && latest !== current) {
setLatestVersion(latest);
setUpdateStatus("available");
} else {
setUpdateStatus("uptodate");
setTimeout(() => setUpdateStatus("idle"), 3000);
}
} catch (err) {
console.error("Update check failed:", err);
setUpdateStatus("error");
setTimeout(() => setUpdateStatus("idle"), 3000);
}
};
return (
<div className="flex items-center gap-2 mb-1 justify-end">
<a
href="https://github.com/BigBodyCobain/Shadowbroker/discussions"
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer"
>
<MessageSquare size={12} className="text-cyan-400 w-3 h-3" />
<span className="tracking-widest">DISCUSSIONS</span>
</a>
{updateStatus === "available" ? (
<a
href="https://github.com/BigBodyCobain/Shadowbroker/releases/latest"
target="_blank"
rel="noreferrer"
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)]"
>
<Download size={12} className="w-3 h-3" />
<span className="tracking-widest animate-pulse">v{latestVersion} UPDATE!</span>
</a>
) : (
<button
onClick={checkForUpdates}
disabled={updateStatus === "checking"}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--bg-primary)]/50 backdrop-blur-md border border-[var(--border-primary)] rounded-lg hover:border-cyan-500/50 hover:bg-[var(--hover-accent)] transition-all text-[10px] text-[var(--text-secondary)] font-mono cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateStatus === "checking" && <Github size={12} className="w-3 h-3 animate-spin text-cyan-400" />}
{updateStatus === "idle" && <Github size={12} className="w-3 h-3 text-cyan-400" />}
{updateStatus === "uptodate" && <CheckCircle2 size={12} className="w-3 h-3 text-green-400" />}
{updateStatus === "error" && <AlertCircle size={12} className="w-3 h-3 text-red-400" />}
<span className="tracking-widest">
{updateStatus === "checking" ? "CHECKING..." :
updateStatus === "uptodate" ? "UP TO DATE" :
updateStatus === "error" ? "CHECK FAILED" :
"CHECK UPDATES"}
</span>
</button>
)}
</div>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ const _cache: Record<string, { url: string | null; done: boolean }> = {};
* maxH: Max height class (default "max-h-32") * maxH: Max height class (default "max-h-32")
* accent: Border hover color class (default "hover:border-cyan-500/50") * accent: Border hover color class (default "hover:border-cyan-500/50")
*/ */
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: { export default function WikiImage({ wikiUrl, label, maxH = 'max-h-52', accent = 'hover:border-cyan-500/50' }: {
wikiUrl: string; wikiUrl: string;
label?: string; label?: string;
maxH?: string; maxH?: string;
@@ -56,7 +56,7 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
<img <img
src={imgUrl} src={imgUrl}
alt={label || title.replace(/_/g, ' ')} alt={label || title.replace(/_/g, ' ')}
className={`w-full h-auto ${maxH} object-cover rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`} className={`w-full h-auto ${maxH} object-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
/> />
</a> </a>
)} )}
+144 -11
View File
@@ -2,7 +2,8 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server } from "lucide-react"; import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext"; import { useTheme } from "@/lib/ThemeContext";
function relativeTime(iso: string | undefined): string { function relativeTime(iso: string | undefined): string {
@@ -40,10 +41,29 @@ const FRESHNESS_MAP: Record<string, string> = {
datacenters: "datacenters", datacenters: "datacenters",
}; };
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) { // POTUS fleet ICAO hex codes for client-side filtering
const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
'ADFDF8': { label: 'Air Force One (82-8000)', type: 'AF1' },
'ADFDF9': { label: 'Air Force One (92-9000)', type: 'AF1' },
'ADFEB7': { label: 'Air Force Two (98-0001)', type: 'AF2' },
'ADFEB8': { label: 'Air Force Two (98-0002)', type: 'AF2' },
'ADFEB9': { label: 'Air Force Two (99-0003)', type: 'AF2' },
'ADFEBA': { label: 'Air Force Two (99-0004)', type: 'AF2' },
'AE4AE6': { label: 'Air Force Two (09-0015)', type: 'AF2' },
'AE4AE8': { label: 'Air Force Two (09-0016)', type: 'AF2' },
'AE4AEA': { label: 'Air Force Two (09-0017)', type: 'AF2' },
'AE4AEC': { label: 'Air Force Two (19-0018)', type: 'AF2' },
'AE0865': { label: 'Marine One (VH-3D)', type: 'M1' },
'AE5E76': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
};
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: { type: string; id: number; extra?: any }) => void; onFlyTo?: (lat: number, lng: number) => void }) {
const [isMinimized, setIsMinimized] = useState(false); const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false); const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation // GIBS time slider play/pause animation
@@ -84,6 +104,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian }; return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
}, [data?.ships]); }, [data?.ships]);
// Find POTUS fleet planes currently airborne from tracked flights
const potusFlights = useMemo(() => {
const tracked = data?.tracked_flights;
if (!tracked) return [];
const results: { index: number; flight: any; meta: { label: string; type: string } }[] = [];
for (let i = 0; i < tracked.length; i++) {
const f = tracked[i];
const icao = (f.icao24 || '').toUpperCase();
if (POTUS_ICAOS[icao]) {
results.push({ index: i, flight: f, meta: POTUS_ICAOS[icao] });
}
}
return results;
}, [data?.tracked_flights]);
const layers = [ const layers = [
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane }, { id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
{ id: "private", name: "Private Flights", source: "adsb.lol", count: data?.private_flights?.length || 0, icon: Plane }, { id: "private", name: "Private Flights", source: "adsb.lol", count: data?.private_flights?.length || 0, icon: Plane },
@@ -91,7 +126,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle }, { id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye }, { 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: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
{ id: "satellites", name: "Satellites", source: "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite }, { 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_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor }, { 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: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
@@ -125,7 +160,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1> <h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]" className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'} title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
> >
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />} {theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
@@ -133,7 +168,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onSettingsClick && ( {onSettingsClick && (
<button <button
onClick={onSettingsClick} onClick={onSettingsClick}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)] group" className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
title="System Settings" title="System Settings"
> >
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" /> <Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
@@ -142,13 +177,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onLegendClick && ( {onLegendClick && (
<button <button
onClick={onLegendClick} onClick={onLegendClick}
className="h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]" className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title="Map Legend / Icon Key" title="Map Legend / Icon Key"
> >
<BookOpen size={12} /> <BookOpen size={12} />
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span> <span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
</button> </button>
)} )}
<span className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}>
v{packageJson.version}
</span>
</div> </div>
</div> </div>
@@ -158,12 +196,30 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{/* Header / Toggle */} {/* Header / Toggle */}
<div <div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50" className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
> >
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span> <span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest" onClick={() => setIsMinimized(!isMinimized)}>DATA LAYERS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"> <div className="flex items-center gap-2">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />} <button
</button> title={Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? "Disable all layers" : "Enable all layers"}
className={`${Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const allOn = Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v);
setActiveLayers((prev: any) => {
const next: any = {};
for (const k of Object.keys(prev)) {
next[k] = k === 'gibs_imagery' ? false : !allOn;
}
return next;
});
}}
>
{Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
</button>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setIsMinimized(!isMinimized)}>
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
</div> </div>
<AnimatePresence> <AnimatePresence>
@@ -175,6 +231,61 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
className="overflow-y-auto styled-scrollbar" className="overflow-y-auto styled-scrollbar"
> >
<div className="flex flex-col gap-6 p-4 pt-2 pb-6"> <div className="flex flex-col gap-6 p-4 pt-2 pb-6">
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
{potusEnabled && potusFlights.length > 0 && (
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[#ff1493]" />
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(false); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
title="Hide POTUS Fleet tracker"
>
HIDE
</button>
</div>
<div className="flex flex-col gap-2">
{potusFlights.map((pf) => {
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
const speed = pf.flight.gs || pf.flight.speed || 0;
return (
<div
key={pf.flight.icao24}
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
style={{ borderColor: `${color}40`, background: `${color}10` }}
onClick={() => {
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
onFlyTo(pf.flight.lat, pf.flight.lng);
}
if (onEntityClick) {
onEntityClick({ type: 'tracked_flight', id: pf.index });
}
}}
>
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
</div>
</div>
);
})}
</div>
</div>
)}
{layers.map((layer, idx) => { {layers.map((layer, idx) => {
const Icon = layer.icon; const Icon = layer.icon;
const active = activeLayers[layer.id as keyof typeof activeLayers] || false; const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
@@ -260,6 +371,28 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
</div> </div>
) )
})} })}
{/* POTUS Fleet — bottom section when inactive or hidden */}
{(potusFlights.length === 0 || !potusEnabled) && (
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">POTUS FLEET</span>
</div>
{!potusEnabled ? (
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(true); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
>
SHOW
</button>
) : (
<span className="text-[8px] font-mono text-[var(--text-muted)]">NO ACTIVE AIRCRAFT</span>
)}
</div>
</div>
)}
</div> </div>
</motion.div> </motion.div>
)} )}
+3 -2
View File
@@ -1,6 +1,7 @@
// All API calls use relative paths (e.g. /api/flights). // All API calls use relative paths (e.g. /api/flights).
// Next.js rewrites them at the server level to BACKEND_URL (set in docker-compose // The catch-all route handler at src/app/api/[...path]/route.ts proxies them
// or .env.local for dev). This means: // to BACKEND_URL at runtime (set in docker-compose or .env.local for dev).
// This means:
// - No build-time baking of the backend URL into the client bundle // - No build-time baking of the backend URL into the client bundle
// - BACKEND_URL=http://backend:8000 works via Docker internal networking // - BACKEND_URL=http://backend:8000 works via Docker internal networking
// - Only port 3000 needs to be exposed externally // - Only port 3000 needs to be exposed externally