From 05de14af9d33be756aaa8082e871e601539e3f91 Mon Sep 17 00:00:00 2001 From: adust09 Date: Mon, 16 Mar 2026 00:33:35 +0900 Subject: [PATCH] feat: add military bases map layer for Western Pacific MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/data/military_bases.json | 146 ++++++++++++++++++ backend/main.py | 1 + backend/services/data_fetcher.py | 3 +- backend/services/fetchers/_store.py | 3 +- backend/services/fetchers/infrastructure.py | 37 +++++ backend/tests/test_military_bases.py | 62 ++++++++ frontend/src/app/page.tsx | 1 + frontend/src/components/MaplibreViewer.tsx | 75 ++++++++- .../src/components/WorldviewLeftPanel.tsx | 1 + .../src/components/map/geoJSONBuilders.ts | 23 ++- frontend/src/types/dashboard.ts | 11 ++ 11 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 backend/data/military_bases.json create mode 100644 backend/tests/test_military_bases.py diff --git a/backend/data/military_bases.json b/backend/data/military_bases.json new file mode 100644 index 0000000..d4ea047 --- /dev/null +++ b/backend/data/military_bases.json @@ -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 + } +] diff --git a/backend/main.py b/backend/main.py index 42c1e49..a3cf4ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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" diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index eb6daad..4f51dac 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -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] diff --git a/backend/services/fetchers/_store.py b/backend/services/fetchers/_store.py index dc52fd9..3e6bbeb 100644 --- a/backend/services/fetchers/_store.py +++ b/backend/services/fetchers/_store.py @@ -30,7 +30,8 @@ latest_data = { "space_weather": None, "internet_outages": [], "firms_fires": [], - "datacenters": [] + "datacenters": [], + "military_bases": [] } # Per-source freshness timestamps diff --git a/backend/services/fetchers/infrastructure.py b/backend/services/fetchers/infrastructure.py index 2eca0b6..aa0f7aa 100644 --- a/backend/services/fetchers/infrastructure.py +++ b/backend/services/fetchers/infrastructure.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/tests/test_military_bases.py b/backend/tests/test_military_bases.py new file mode 100644 index 0000000..eecd967 --- /dev/null +++ b/backend/tests/test_military_bases.py @@ -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 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ba1097e..610d455 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -162,6 +162,7 @@ export default function Dashboard() { firms: false, internet_outages: false, datacenters: false, + military_bases: false, }); // NASA GIBS satellite imagery state diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index f30ed9b..ca65d6b 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -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 )} + {/* Military Base positions */} + {militaryBasesGeoJSON && ( + + + + + )} + {/* Satellite positions — mission-type icons */} {/* satellites: data pushed imperatively */} @@ -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 = { + air_force: 'AIR FORCE', navy: 'NAVY', marines: 'MARINES', army: 'ARMY', + }; + return ( + onEntityClick?.(null)} + className="threat-popup" + maxWidth="280px" + > +
+
+ {base.name} +
+
+ Operator: {base.operator} +
+
+ Location: {base.country} +
+
+ MILITARY BASE — {branchLabel[base.branch] || base.branch.toUpperCase()} +
+
+
+ ); + })()} + {(() => { if (selectedEntity?.type !== 'gdelt' || !data?.gdelt) return null; const item = data.gdelt.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id); diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 30429ab..6a8d4ad 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -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 }, ]; diff --git a/frontend/src/components/map/geoJSONBuilders.ts b/frontend/src/components/map/geoJSONBuilders.ts index 3f74429..0e25a1e 100644 --- a/frontend/src/components/map/geoJSONBuilders.ts +++ b/frontend/src/components/map/geoJSONBuilders.ts @@ -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 { diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 4c11201..7596735 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -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 {