mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-11 08:37:54 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34db99deaf | |||
| a0d0a449eb | |||
| 26a72f4f95 | |||
| 3eff24c6ed | |||
| bb345ed665 | |||
| dec5b0da9c | |||
| 68cacc0fed | |||
| 40e89ac30b | |||
| 350ec11725 | |||
| 5d4dd0560d | |||
| 345f3c7451 | |||
| dde527821c | |||
| 5bee764614 | |||
| c986de9e35 | |||
| d2fa45c6a6 | |||
| d78bf61256 | |||
| b10d6e6e00 | |||
| afdc626bdb | |||
| 5ab02e821f | |||
| ac62e4763f | |||
| beadce5dae | |||
| f99cc669f5 | |||
| 82715c79a6 |
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
@@ -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 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
38a18cbbf1acbec5eb9266b809c28d31e2941c53
|
||||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|||||||
+290
-209
@@ -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,6 +322,11 @@ _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()
|
||||||
|
|
||||||
@@ -255,11 +337,22 @@ def enrich_with_tracked_names(flight: dict) -> dict:
|
|||||||
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,6 +467,7 @@ 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):
|
||||||
@@ -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:
|
||||||
@@ -1219,12 +1318,20 @@ def fetch_ships():
|
|||||||
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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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\"",
|
||||||
|
|||||||
@@ -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
@@ -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>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">♥</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 */}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user