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:
adust09
2026-03-16 00:33:35 +09:00
co-authored by Claude Opus 4.6
parent 130287bb49
commit 05de14af9d
11 changed files with 359 additions and 4 deletions
+146
View File
@@ -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
}
]
+1
View File
@@ -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"
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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
# ---------------------------------------------------------------------------
+62
View File
@@ -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