mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-04-29 22:37:49 +02:00
feat: add military bases map layer for Western Pacific
Add 18 US military bases (Japan, Guam, South Korea, Hawaii, Diego Garcia) as a toggleable map layer. Follows the existing data center layer pattern: static JSON → backend fetcher → slow-tier API → frontend GeoJSON layer. Includes red circle markers with labels, click popups showing operator and branch info, and a toggle in the left panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"name": "Kadena Air Base",
|
||||
"country": "Japan",
|
||||
"operator": "USAF 18th Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 26.351,
|
||||
"lng": 127.767
|
||||
},
|
||||
{
|
||||
"name": "Marine Corps Air Station Futenma",
|
||||
"country": "Japan",
|
||||
"operator": "USMC",
|
||||
"branch": "marines",
|
||||
"lat": 26.274,
|
||||
"lng": 127.755
|
||||
},
|
||||
{
|
||||
"name": "Fleet Activities Yokosuka",
|
||||
"country": "Japan",
|
||||
"operator": "USN 7th Fleet",
|
||||
"branch": "navy",
|
||||
"lat": 35.283,
|
||||
"lng": 139.671
|
||||
},
|
||||
{
|
||||
"name": "Fleet Activities Sasebo",
|
||||
"country": "Japan",
|
||||
"operator": "USN",
|
||||
"branch": "navy",
|
||||
"lat": 33.159,
|
||||
"lng": 129.722
|
||||
},
|
||||
{
|
||||
"name": "Misawa Air Base",
|
||||
"country": "Japan",
|
||||
"operator": "USAF 35th Fighter Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 40.703,
|
||||
"lng": 141.368
|
||||
},
|
||||
{
|
||||
"name": "MCAS Iwakuni",
|
||||
"country": "Japan",
|
||||
"operator": "USMC / USN",
|
||||
"branch": "marines",
|
||||
"lat": 34.144,
|
||||
"lng": 132.236
|
||||
},
|
||||
{
|
||||
"name": "Yokota Air Base",
|
||||
"country": "Japan",
|
||||
"operator": "USAF 374th Airlift Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 35.748,
|
||||
"lng": 139.348
|
||||
},
|
||||
{
|
||||
"name": "Camp Zama",
|
||||
"country": "Japan",
|
||||
"operator": "US Army Japan",
|
||||
"branch": "army",
|
||||
"lat": 35.488,
|
||||
"lng": 139.395
|
||||
},
|
||||
{
|
||||
"name": "NAF Atsugi",
|
||||
"country": "Japan",
|
||||
"operator": "USN",
|
||||
"branch": "navy",
|
||||
"lat": 35.455,
|
||||
"lng": 139.449
|
||||
},
|
||||
{
|
||||
"name": "Camp Hansen",
|
||||
"country": "Japan",
|
||||
"operator": "USMC",
|
||||
"branch": "marines",
|
||||
"lat": 26.441,
|
||||
"lng": 127.775
|
||||
},
|
||||
{
|
||||
"name": "White Beach Naval Facility",
|
||||
"country": "Japan",
|
||||
"operator": "USN",
|
||||
"branch": "navy",
|
||||
"lat": 26.334,
|
||||
"lng": 127.897
|
||||
},
|
||||
{
|
||||
"name": "Andersen Air Force Base",
|
||||
"country": "Guam",
|
||||
"operator": "USAF 36th Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 13.584,
|
||||
"lng": 144.930
|
||||
},
|
||||
{
|
||||
"name": "Naval Base Guam",
|
||||
"country": "Guam",
|
||||
"operator": "USN",
|
||||
"branch": "navy",
|
||||
"lat": 13.444,
|
||||
"lng": 144.653
|
||||
},
|
||||
{
|
||||
"name": "Osan Air Base",
|
||||
"country": "South Korea",
|
||||
"operator": "USAF 51st Fighter Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 37.090,
|
||||
"lng": 127.030
|
||||
},
|
||||
{
|
||||
"name": "Camp Humphreys",
|
||||
"country": "South Korea",
|
||||
"operator": "US Army / USFK HQ",
|
||||
"branch": "army",
|
||||
"lat": 36.963,
|
||||
"lng": 127.031
|
||||
},
|
||||
{
|
||||
"name": "Kunsan Air Base",
|
||||
"country": "South Korea",
|
||||
"operator": "USAF 8th Fighter Wing",
|
||||
"branch": "air_force",
|
||||
"lat": 35.904,
|
||||
"lng": 126.616
|
||||
},
|
||||
{
|
||||
"name": "Naval Station Pearl Harbor",
|
||||
"country": "Hawaii",
|
||||
"operator": "USN / USINDOPACOM HQ",
|
||||
"branch": "navy",
|
||||
"lat": 21.345,
|
||||
"lng": -157.974
|
||||
},
|
||||
{
|
||||
"name": "Diego Garcia",
|
||||
"country": "BIOT",
|
||||
"operator": "USN / USAF",
|
||||
"branch": "navy",
|
||||
"lat": -7.316,
|
||||
"lng": 72.411
|
||||
}
|
||||
]
|
||||
@@ -305,6 +305,7 @@ async def live_data_slow(request: Request,
|
||||
"internet_outages": _f(d.get("internet_outages", [])),
|
||||
"firms_fires": _f(d.get("firms_fires", [])),
|
||||
"datacenters": _f(d.get("datacenters", [])),
|
||||
"military_bases": _f(d.get("military_bases", [])),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
bbox_tag = f"{s},{w},{n},{e}" if has_bbox else "full"
|
||||
|
||||
@@ -40,7 +40,7 @@ from services.fetchers.earth_observation import ( # noqa: F401
|
||||
fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
|
||||
)
|
||||
from services.fetchers.infrastructure import ( # noqa: F401
|
||||
fetch_internet_outages, fetch_datacenters, fetch_cctv, fetch_kiwisdr,
|
||||
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_cctv, fetch_kiwisdr,
|
||||
)
|
||||
from services.fetchers.geo import ( # noqa: F401
|
||||
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
|
||||
@@ -85,6 +85,7 @@ def update_slow_data():
|
||||
fetch_frontlines,
|
||||
fetch_gdelt,
|
||||
fetch_datacenters,
|
||||
fetch_military_bases,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||
futures = [executor.submit(func) for func in slow_funcs]
|
||||
|
||||
@@ -30,7 +30,8 @@ latest_data = {
|
||||
"space_weather": None,
|
||||
"internet_outages": [],
|
||||
"firms_fires": [],
|
||||
"datacenters": []
|
||||
"datacenters": [],
|
||||
"military_bases": []
|
||||
}
|
||||
|
||||
# Per-source freshness timestamps
|
||||
|
||||
@@ -143,6 +143,43 @@ def fetch_datacenters():
|
||||
_mark_fresh("datacenters")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Military Bases (static JSON — Western Pacific)
|
||||
# ---------------------------------------------------------------------------
|
||||
_MILITARY_BASES_PATH = Path(__file__).parent.parent.parent / "data" / "military_bases.json"
|
||||
|
||||
|
||||
def fetch_military_bases():
|
||||
"""Load static military base locations (Western Pacific focus)."""
|
||||
bases = []
|
||||
try:
|
||||
if not _MILITARY_BASES_PATH.exists():
|
||||
logger.warning(f"Military bases file not found: {_MILITARY_BASES_PATH}")
|
||||
return
|
||||
raw = json.loads(_MILITARY_BASES_PATH.read_text(encoding="utf-8"))
|
||||
for entry in raw:
|
||||
lat = entry.get("lat")
|
||||
lng = entry.get("lng")
|
||||
if lat is None or lng is None:
|
||||
continue
|
||||
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
|
||||
continue
|
||||
bases.append({
|
||||
"name": entry.get("name", "Unknown"),
|
||||
"country": entry.get("country", ""),
|
||||
"operator": entry.get("operator", ""),
|
||||
"branch": entry.get("branch", ""),
|
||||
"lat": lat, "lng": lng,
|
||||
})
|
||||
logger.info(f"Military bases: {len(bases)} locations loaded")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading military bases: {e}")
|
||||
with _data_lock:
|
||||
latest_data["military_bases"] = bases
|
||||
if bases:
|
||||
_mark_fresh("military_bases")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CCTV Cameras
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for military bases data and fetcher."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.fetchers._store import latest_data, _data_lock
|
||||
|
||||
|
||||
BASES_PATH = Path(__file__).parent.parent / "data" / "military_bases.json"
|
||||
|
||||
|
||||
class TestMilitaryBasesData:
|
||||
"""Validate the static military_bases.json file."""
|
||||
|
||||
def test_json_file_exists(self):
|
||||
assert BASES_PATH.exists()
|
||||
|
||||
def test_all_entries_have_required_fields(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
assert len(raw) > 0
|
||||
for entry in raw:
|
||||
assert "name" in entry and entry["name"]
|
||||
assert "country" in entry and entry["country"]
|
||||
assert "operator" in entry and entry["operator"]
|
||||
assert "branch" in entry and entry["branch"]
|
||||
assert "lat" in entry and isinstance(entry["lat"], (int, float))
|
||||
assert "lng" in entry and isinstance(entry["lng"], (int, float))
|
||||
|
||||
def test_coordinates_in_valid_range(self):
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
for entry in raw:
|
||||
assert -90 <= entry["lat"] <= 90, f"{entry['name']} has invalid lat"
|
||||
assert -180 <= entry["lng"] <= 180, f"{entry['name']} has invalid lng"
|
||||
|
||||
def test_branch_values_are_known(self):
|
||||
known_branches = {"air_force", "navy", "marines", "army"}
|
||||
raw = json.loads(BASES_PATH.read_text(encoding="utf-8"))
|
||||
for entry in raw:
|
||||
assert entry["branch"] in known_branches, f"{entry['name']} has unknown branch: {entry['branch']}"
|
||||
|
||||
|
||||
class TestFetchMilitaryBases:
|
||||
"""Test the fetcher populates latest_data correctly."""
|
||||
|
||||
def test_fetch_populates_store(self):
|
||||
from services.fetchers.infrastructure import fetch_military_bases
|
||||
fetch_military_bases()
|
||||
with _data_lock:
|
||||
bases = latest_data["military_bases"]
|
||||
assert len(bases) > 0
|
||||
assert all("name" in b and "lat" in b and "lng" in b for b in bases)
|
||||
|
||||
def test_includes_key_bases(self):
|
||||
from services.fetchers.infrastructure import fetch_military_bases
|
||||
fetch_military_bases()
|
||||
with _data_lock:
|
||||
names = {b["name"] for b in latest_data["military_bases"]}
|
||||
assert "Kadena Air Base" in names
|
||||
assert "Fleet Activities Yokosuka" in names
|
||||
assert "Andersen Air Force Base" in names
|
||||
@@ -162,6 +162,7 @@ export default function Dashboard() {
|
||||
firms: false,
|
||||
internet_outages: false,
|
||||
datacenters: false,
|
||||
military_bases: false,
|
||||
});
|
||||
|
||||
// NASA GIBS satellite imagery state
|
||||
|
||||
@@ -50,7 +50,7 @@ import { useClusterLabels } from "@/components/map/hooks/useClusterLabels";
|
||||
import { spreadAlertItems } from "@/utils/alertSpread";
|
||||
import {
|
||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON, buildMilitaryBasesGeoJSON,
|
||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON,
|
||||
buildFlightLayerGeoJSON, buildUavGeoJSON,
|
||||
buildSatellitesGeoJSON, buildShipsGeoJSON, buildCarriersGeoJSON,
|
||||
@@ -222,6 +222,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
activeLayers.datacenters ? buildDataCentersGeoJSON(data?.datacenters) : null,
|
||||
[activeLayers.datacenters, data?.datacenters]);
|
||||
|
||||
const militaryBasesGeoJSON = useMemo(() =>
|
||||
activeLayers.military_bases ? buildMilitaryBasesGeoJSON(data?.military_bases) : null,
|
||||
[activeLayers.military_bases, data?.military_bases]);
|
||||
|
||||
// Load Images into the Map Style once loaded
|
||||
const onMapLoad = useCallback((e: any) => {
|
||||
const map = e.target;
|
||||
@@ -588,6 +592,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||
dataCentersGeoJSON && 'datacenters-layer',
|
||||
militaryBasesGeoJSON && 'military-bases-layer',
|
||||
firmsGeoJSON && 'firms-viirs-layer'
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
@@ -1463,6 +1468,40 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Military Base positions */}
|
||||
{militaryBasesGeoJSON && (
|
||||
<Source id="military-bases" type="geojson" data={militaryBasesGeoJSON as any}>
|
||||
<Layer
|
||||
id="military-bases-layer"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-color': '#ef4444',
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 7, 10, 10],
|
||||
'circle-opacity': 0.8,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#fca5a5',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="military-bases-label"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': ['step', ['zoom'], '', 5, ['get', 'name']],
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-size': 10,
|
||||
'text-offset': [0, 1.4],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#fca5a5',
|
||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Satellite positions — mission-type icons */}
|
||||
{/* satellites: data pushed imperatively */}
|
||||
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
|
||||
@@ -1846,6 +1885,40 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
);
|
||||
})()}
|
||||
|
||||
{selectedEntity?.type === 'military_base' && (() => {
|
||||
const base = data?.military_bases?.find((_: any, i: number) => `milbase-${i}` === selectedEntity.id);
|
||||
if (!base) return null;
|
||||
const branchLabel: Record<string, string> = {
|
||||
air_force: 'AIR FORCE', navy: 'NAVY', marines: 'MARINES', army: 'ARMY',
|
||||
};
|
||||
return (
|
||||
<Popup
|
||||
longitude={base.lng}
|
||||
latitude={base.lat}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
onClose={() => onEntityClick?.(null)}
|
||||
className="threat-popup"
|
||||
maxWidth="280px"
|
||||
>
|
||||
<div className="map-popup bg-[#1a1035] border border-red-400/40 text-[#fca5a5] min-w-[200px]">
|
||||
<div className="map-popup-title text-red-400 border-b border-red-400/20 pb-1">
|
||||
{base.name}
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Operator: <span className="text-white">{base.operator}</span>
|
||||
</div>
|
||||
<div className="map-popup-row">
|
||||
Location: <span className="text-white">{base.country}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[9px] text-red-600 tracking-wider">
|
||||
MILITARY BASE — {branchLabel[base.branch] || base.branch.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
if (selectedEntity?.type !== 'gdelt' || !data?.gdelt) return null;
|
||||
const item = data.gdelt.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
||||
|
||||
@@ -147,6 +147,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
|
||||
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
|
||||
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
|
||||
{ id: "military_bases", name: "Military Bases", source: "OSINT (Static)", count: data?.military_bases?.length || 0, icon: Shield },
|
||||
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Extracted from MaplibreViewer to reduce component size and enable unit testing.
|
||||
// Each function takes data arrays + optional helpers and returns a GeoJSON FeatureCollection or null.
|
||||
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, MilitaryBase, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
|
||||
import { classifyAircraft } from "@/utils/aircraftClassification";
|
||||
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
|
||||
|
||||
@@ -195,6 +195,27 @@ export function buildDataCentersGeoJSON(datacenters?: DataCenter[]): FC {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Military Bases ─────────────────────────────────────────────────────────
|
||||
|
||||
export function buildMilitaryBasesGeoJSON(bases?: MilitaryBase[]): FC {
|
||||
if (!bases?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: bases.map((base, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `milbase-${i}`,
|
||||
type: 'military_base',
|
||||
name: base.name || 'Unknown',
|
||||
country: base.country || '',
|
||||
operator: base.operator || '',
|
||||
branch: base.branch || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [base.lng, base.lat] }
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GDELT Incidents ────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
|
||||
|
||||
@@ -224,6 +224,15 @@ export interface DataCenter {
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface MilitaryBase {
|
||||
name: string;
|
||||
country: string;
|
||||
operator: string;
|
||||
branch: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
// ─── NEWS / GLOBAL INCIDENTS ────────────────────────────────────────────────
|
||||
|
||||
export interface NewsArticle {
|
||||
@@ -404,6 +413,7 @@ export interface DashboardData {
|
||||
internet_outages?: InternetOutage[];
|
||||
firms_fires?: FireHotspot[];
|
||||
datacenters?: DataCenter[];
|
||||
military_bases?: MilitaryBase[];
|
||||
}
|
||||
|
||||
// ─── COMPONENT PROPS ────────────────────────────────────────────────────────
|
||||
@@ -432,6 +442,7 @@ export interface ActiveLayers {
|
||||
firms: boolean;
|
||||
internet_outages: boolean;
|
||||
datacenters: boolean;
|
||||
military_bases: boolean;
|
||||
}
|
||||
|
||||
export interface SelectedEntity {
|
||||
|
||||
Reference in New Issue
Block a user