From 130287bb49ff5995ba3098a86f0ef9c10ceb2d62 Mon Sep 17 00:00:00 2001 From: adust09 Date: Sun, 15 Mar 2026 23:19:55 +0900 Subject: [PATCH 1/3] feat: add East Asia news sources and improve geocoding for Taiwan contingency Add 5 East Asia-focused RSS feeds (FocusTaiwan, Kyodo, SCMP, The Diplomat, Stars and Stripes) and 22 geographic keywords (Taiwan Strait, South/East China Sea, Okinawa, Guam, military bases, etc.) to improve coverage of Taiwan contingency scenarios. Refactor keyword matching into a pure _resolve_coords() function with longest-match-first sorting so specific locations like "Taiwan Strait" are not absorbed by generic "Taiwan". Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/config/news_feeds.json | 25 +++++++ backend/services/fetchers/news.py | 60 ++++++++++++---- backend/services/news_feed_config.py | 5 ++ backend/tests/test_news_keywords.py | 102 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_news_keywords.py diff --git a/backend/config/news_feeds.json b/backend/config/news_feeds.json index 1af3a0f..4c3a906 100644 --- a/backend/config/news_feeds.json +++ b/backend/config/news_feeds.json @@ -39,6 +39,31 @@ "name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3 + }, + { + "name": "FocusTaiwan", + "url": "https://focustaiwan.tw/rss", + "weight": 5 + }, + { + "name": "Kyodo", + "url": "https://english.kyodonews.net/rss/news.xml", + "weight": 4 + }, + { + "name": "SCMP", + "url": "https://www.scmp.com/rss/91/feed", + "weight": 4 + }, + { + "name": "The Diplomat", + "url": "https://thediplomat.com/feed/", + "weight": 4 + }, + { + "name": "Stars and Stripes", + "url": "https://www.stripes.com/feeds/pacific.rss", + "weight": 4 } ] } \ No newline at end of file diff --git a/backend/services/fetchers/news.py b/backend/services/fetchers/news.py index ce92a56..aeb7270 100644 --- a/backend/services/fetchers/news.py +++ b/backend/services/fetchers/news.py @@ -33,6 +33,29 @@ _KEYWORD_COORDS = { "lebanon": (33.854, 35.862), "syria": (34.802, 38.996), "yemen": (15.552, 48.516), + # East Asia — specific locations (longer keywords matched first via _SORTED_KEYWORDS) + "taiwan strait": (24.0, 119.5), + "south china sea": (15.0, 115.0), + "east china sea": (28.0, 125.0), + "philippine sea": (20.0, 130.0), + "senkaku": (25.740, 123.474), + "diaoyu": (25.740, 123.474), + "ryukyu": (26.334, 127.800), + "okinawa": (26.334, 127.800), + "kadena": (26.351, 127.767), + "naha": (26.212, 127.679), + "yokosuka": (35.283, 139.671), + "sasebo": (33.159, 129.722), + "misawa": (40.682, 141.368), + "iwakuni": (34.144, 132.236), + "guam": (13.444, 144.793), + "taipei": (25.033, 121.565), + "kaohsiung": (22.616, 120.313), + "xiamen": (24.479, 118.089), + "fujian": (26.074, 119.296), + "guangdong": (23.379, 113.763), + "zhejiang": (29.141, 119.788), + "hainan": (19.200, 109.999), "china": (35.861, 104.195), "beijing": (39.904, 116.407), "taiwan": (23.697, 120.960), @@ -90,6 +113,27 @@ _KEYWORD_COORDS = { "jakarta": (-6.208, 106.845), } +# Immutable after module load — sort by descending keyword length so +# specific locations ("taiwan strait") match before generic ones ("taiwan") +_SORTED_KEYWORDS = sorted(_KEYWORD_COORDS.items(), key=lambda x: len(x[0]), reverse=True) + + +def _resolve_coords(text: str) -> tuple[float, float] | None: + """Return (lat, lng) for the most specific keyword match, or None. + + Longer keywords are tried first. Space-padded keywords (" us ", " uk ") + use substring matching on padded text; all others use word-boundary regex. + """ + padded_text = f" {text} " + for kw, coords in _SORTED_KEYWORDS: + if kw.startswith(" ") or kw.endswith(" "): + if kw in padded_text: + return coords + else: + if re.search(r'\b' + re.escape(kw) + r'\b', text): + return coords + return None + @with_retry(max_retries=1, base_delay=2) def fetch_news(): @@ -140,8 +184,6 @@ def fetch_news(): risk_score += 2 risk_score = min(10, risk_score) - keyword_coords = _KEYWORD_COORDS - lat, lng = None, None if 'georss_point' in entry: @@ -153,18 +195,10 @@ def fetch_news(): lat, lng = coords[1], coords[0] if lat is None: - # text may not be defined yet for GDACS path text = (title + " " + summary).lower() - padded_text = f" {text} " - for kw, coords in keyword_coords.items(): - if kw.startswith(" ") or kw.endswith(" "): - if kw in padded_text: - lat, lng = coords - break - else: - if re.search(r'\b' + re.escape(kw) + r'\b', text): - lat, lng = coords - break + result = _resolve_coords(text) + if result: + lat, lng = result if lat is not None: key = None diff --git a/backend/services/news_feed_config.py b/backend/services/news_feed_config.py index b26ba1e..586ed4d 100644 --- a/backend/services/news_feed_config.py +++ b/backend/services/news_feed_config.py @@ -20,6 +20,11 @@ DEFAULT_FEEDS = [ {"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3}, {"name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3}, {"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3}, + {"name": "FocusTaiwan", "url": "https://focustaiwan.tw/rss", "weight": 5}, + {"name": "Kyodo", "url": "https://english.kyodonews.net/rss/news.xml", "weight": 4}, + {"name": "SCMP", "url": "https://www.scmp.com/rss/91/feed", "weight": 4}, + {"name": "The Diplomat", "url": "https://thediplomat.com/feed/", "weight": 4}, + {"name": "Stars and Stripes", "url": "https://www.stripes.com/feeds/pacific.rss", "weight": 4}, ] diff --git a/backend/tests/test_news_keywords.py b/backend/tests/test_news_keywords.py new file mode 100644 index 0000000..bb19823 --- /dev/null +++ b/backend/tests/test_news_keywords.py @@ -0,0 +1,102 @@ +"""Regression tests for news geocoding keywords and feed configuration.""" +import json +from pathlib import Path + +import pytest + +from services.fetchers.news import _resolve_coords +from services.news_feed_config import DEFAULT_FEEDS + + +CONFIG_PATH = Path(__file__).parent.parent / "config" / "news_feeds.json" + + +# -- Keyword resolution: East Asia specific locations -------------------------- + +class TestResolveCoords: + """_resolve_coords should prefer longer (more specific) keywords.""" + + def test_taiwan_strait_not_absorbed_by_taiwan(self): + result = _resolve_coords("tensions in the taiwan strait") + assert result == (24.0, 119.5) + + def test_south_china_sea_not_absorbed_by_china(self): + result = _resolve_coords("south china sea patrol") + assert result == (15.0, 115.0) + + def test_east_china_sea(self): + result = _resolve_coords("east china sea tensions") + assert result == (28.0, 125.0) + + def test_philippine_sea(self): + result = _resolve_coords("philippine sea exercises") + assert result == (20.0, 130.0) + + def test_generic_china_still_works(self): + result = _resolve_coords("china deploys forces") + assert result == (35.861, 104.195) + + def test_generic_taiwan_still_works(self): + result = _resolve_coords("taiwan elections") + assert result == (23.697, 120.960) + + def test_taipei(self): + result = _resolve_coords("protests in taipei") + assert result == (25.033, 121.565) + + def test_okinawa(self): + result = _resolve_coords("okinawa base expansion") + assert result == (26.334, 127.800) + + # -- Existing inclusion-relationship regressions --------------------------- + + def test_new_delhi_not_absorbed_by_delhi(self): + result = _resolve_coords("new delhi summit") + assert result == (28.613, 77.209) + + def test_south_america_not_absorbed_by_america(self): + result = _resolve_coords("south america trade deal") + assert result == (-14.200, -51.900) + + def test_north_korea_not_absorbed_by_south_korea(self): + result = _resolve_coords("north korea missile launch") + assert result == (40.339, 127.510) + + # -- Space-padded keywords ------------------------------------------------- + + def test_us_with_spaces(self): + result = _resolve_coords("the us military") + assert result == (38.907, -77.036) + + def test_uk_with_spaces(self): + result = _resolve_coords("visit the uk soon") + assert result == (55.378, -3.435) + + # -- No match -------------------------------------------------------------- + + def test_no_match_returns_none(self): + result = _resolve_coords("unknown location xyz") + assert result is None + + +# -- Feed configuration consistency ------------------------------------------- + +class TestFeedConfig: + """DEFAULT_FEEDS and news_feeds.json must stay in sync.""" + + def test_default_feeds_match_json(self): + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + json_feeds = data["feeds"] + + def normalize(feeds): + return sorted( + [{"name": f["name"], "url": f["url"], "weight": f["weight"]} for f in feeds], + key=lambda f: f["name"], + ) + + assert normalize(DEFAULT_FEEDS) == normalize(json_feeds) + + def test_new_east_asia_feeds_present(self): + names = {f["name"] for f in DEFAULT_FEEDS} + expected = {"FocusTaiwan", "Kyodo", "SCMP", "The Diplomat", "Stars and Stripes"} + assert expected.issubset(names) From 05de14af9d33be756aaa8082e871e601539e3f91 Mon Sep 17 00:00:00 2001 From: adust09 Date: Mon, 16 Mar 2026 00:33:35 +0900 Subject: [PATCH 2/3] 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 { From 4b9765791f2cca46e4b666ddd54499cce036b8a6 Mon Sep 17 00:00:00 2001 From: adust09 Date: Mon, 16 Mar 2026 01:05:44 +0900 Subject: [PATCH 3/3] feat: enrich military aircraft with ICAO country/force and East Asia model classification Infer country and military force (PLA, JSDF, ROK, ROC) from ICAO hex address blocks when the flag field is Unknown. Extract and extend aircraft model classification to cover East Asian fighters, cargo, recon, and tanker types with hyphen-normalized matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/services/fetchers/military.py | 78 +++++++++++++++++++++------ backend/tests/test_icao_military.py | 57 ++++++++++++++++++++ frontend/src/components/NewsFeed.tsx | 7 ++- frontend/src/types/dashboard.ts | 2 + 4 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_icao_military.py diff --git a/backend/services/fetchers/military.py b/backend/services/fetchers/military.py index 6780e2c..374bf8d 100644 --- a/backend/services/fetchers/military.py +++ b/backend/services/fetchers/military.py @@ -33,6 +33,56 @@ _UAV_WIKI = { } +_ICAO_COUNTRY_RANGES = [ + (0x780000, 0x7BFFFF, "China", "PLA"), + (0x840000, 0x87FFFF, "Japan", "JSDF"), + (0x700000, 0x71FFFF, "South Korea", "ROK"), + (0xE80000, 0xE80FFF, "Taiwan", "ROC"), +] + + +def _enrich_country(icao_hex: str, flag: str) -> tuple[str, str]: + """If flag is Unknown/empty, infer country and force from ICAO range.""" + if flag and flag not in ("Unknown", "Military Asset", ""): + return flag, "" + try: + addr = int(icao_hex, 16) + except (ValueError, TypeError): + return flag or "Military Asset", "" + for start, end, country, force in _ICAO_COUNTRY_RANGES: + if start <= addr <= end: + return country, force + return flag or "Military Asset", "" + + +def _classify_military_type(raw_model: str) -> str: + model = raw_model.upper().replace("-", "").replace(" ", "") + if "H" in model and any(c.isdigit() for c in model): + return "heli" + if any(k in model for k in [ + "K35", "K46", "A33", "YY20", + ]): + return "tanker" + if any(k in model for k in [ + "F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10", + "J10", "J11", "J15", "J16", "J20", "JF17", + "SU27", "SU30", "SU35", + "F15J", "F2", "IDF", "FA50", "KF21", + ]): + return "fighter" + if any(k in model for k in [ + "C17", "C5", "C130", "C30", "A400", "V22", + "Y20", "Y9", "Y8", "C2", + ]): + return "cargo" + if any(k in model for k in [ + "P8", "E3", "E8", "U2", + "KJ500", "KJ200", "GX11", "P1", "E767", "E2K", "E2C", + ]): + return "recon" + return "default" + + def _classify_uav(model: str, callsign: str): """Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords. Returns (is_uav, uav_type, wiki_url) or (False, None, None).""" @@ -106,10 +156,13 @@ def fetch_military_flights(): gs_knots = f.get("gs") speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None + icao_hex = f.get("hex", "") + is_uav, uav_type, wiki_url = _classify_uav(model, callsign) if is_uav: + uav_country, uav_force = _enrich_country(icao_hex, f.get("flag", "")) detected_uavs.append({ - "id": f"uav-{f.get('hex', '')}", + "id": f"uav-{icao_hex}", "callsign": callsign, "aircraft_model": f.get("t", "Unknown"), "lat": float(lat), @@ -117,31 +170,24 @@ def fetch_military_flights(): "alt": alt_value, "heading": heading, "speed_knots": speed_knots, - "country": f.get("flag", "Unknown"), + "country": uav_country, + "force": uav_force, "uav_type": uav_type, "wiki": wiki_url or "", "type": "uav", "registration": f.get("r", "N/A"), - "icao24": f.get("hex", ""), + "icao24": icao_hex, "squawk": f.get("squawk", ""), }) continue - mil_cat = "default" - if "H" in model and any(c.isdigit() for c in model): - mil_cat = "heli" - elif any(k in model for k in ["K35", "K46", "A33"]): - mil_cat = "tanker" - elif any(k in model for k in ["F16", "F35", "F22", "F15", "F18", "T38", "T6", "A10"]): - mil_cat = "fighter" - elif any(k in model for k in ["C17", "C5", "C130", "C30", "A400", "V22"]): - mil_cat = "cargo" - elif any(k in model for k in ["P8", "E3", "E8", "U2"]): - mil_cat = "recon" + mil_country, mil_force = _enrich_country(icao_hex, f.get("flag", "")) + mil_cat = _classify_military_type(f.get("t", "UNKNOWN")) military_flights.append({ "callsign": callsign, - "country": f.get("flag", "Military Asset"), + "country": mil_country, + "force": mil_force, "lng": float(lng), "lat": float(lat), "alt": alt_value, @@ -154,7 +200,7 @@ def fetch_military_flights(): "dest_name": "UNKNOWN", "registration": f.get("r", "N/A"), "model": f.get("t", "Unknown"), - "icao24": f.get("hex", ""), + "icao24": icao_hex, "speed_knots": speed_knots, "squawk": f.get("squawk", "") }) diff --git a/backend/tests/test_icao_military.py b/backend/tests/test_icao_military.py new file mode 100644 index 0000000..829b7fe --- /dev/null +++ b/backend/tests/test_icao_military.py @@ -0,0 +1,57 @@ +"""Tests for ICAO country enrichment and military type classification.""" +import pytest +from services.fetchers.military import _enrich_country, _classify_military_type + + +class TestEnrichCountry: + def test_china_range(self): + assert _enrich_country("780000", "Unknown") == ("China", "PLA") + + def test_japan_range(self): + assert _enrich_country("840000", "Unknown") == ("Japan", "JSDF") + + def test_taiwan_range(self): + assert _enrich_country("E80000", "Unknown") == ("Taiwan", "ROC") + + def test_south_korea_range(self): + assert _enrich_country("700000", "Unknown") == ("South Korea", "ROK") + + def test_out_of_range_unknown_flag(self): + assert _enrich_country("A00000", "Unknown") == ("Unknown", "") + + def test_valid_flag_preserved(self): + country, force = _enrich_country("780000", "United States") + assert country == "United States" + assert force == "" + + def test_empty_flag_uses_icao(self): + assert _enrich_country("840000", "") == ("Japan", "JSDF") + + def test_military_asset_flag_uses_icao(self): + assert _enrich_country("E80000", "Military Asset") == ("Taiwan", "ROC") + + def test_invalid_hex_with_unknown(self): + assert _enrich_country("ZZZZ", "Unknown") == ("Unknown", "") + + def test_invalid_hex_with_empty(self): + assert _enrich_country("ZZZZ", "") == ("Military Asset", "") + + +class TestClassifyMilitaryType: + @pytest.mark.parametrize("model,expected", [ + ("J-20", "fighter"), + ("Y-20", "cargo"), + ("KJ-500", "recon"), + ("YY-20", "tanker"), + ("F-15J", "fighter"), + ("FA-50", "fighter"), + ("E-2K", "recon"), + ("F16", "fighter"), + ("C17", "cargo"), + ("P8", "recon"), + ("H60", "heli"), + ("K35", "tanker"), + ("Boeing 737", "default"), + ]) + def test_classification(self, model: str, expected: str): + assert _classify_military_type(model) == expected diff --git a/frontend/src/components/NewsFeed.tsx b/frontend/src/components/NewsFeed.tsx index cf8f3cf..c9dfeff 100644 --- a/frontend/src/components/NewsFeed.tsx +++ b/frontend/src/components/NewsFeed.tsx @@ -427,7 +427,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi let airline = "UNKNOWN"; if (selectedEntity.type === 'military_flight') { - airline = "MILITARY ASSET"; + const mil = flight as import('@/types/dashboard').MilitaryFlight; + const milCountry = mil.country; + airline = mil.force + ? `${milCountry} ${mil.force}`.trim() + : (milCountry && milCountry !== 'Military Asset' && milCountry !== 'Unknown' + ? milCountry : "MILITARY ASSET"); } else if (selectedEntity.type === 'private_jet') { airline = "PRIVATE JET"; } else if (selectedEntity.type === 'private_flight') { diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 7596735..6dae3df 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -44,6 +44,7 @@ export interface PrivateJet extends FlightBase { export interface MilitaryFlight extends FlightBase { type: "military_flight"; military_type?: "heli" | "fighter" | "tanker" | "cargo" | "recon" | "default"; + force?: string; } export interface TrackedFlight extends FlightBase { @@ -68,6 +69,7 @@ export interface UAV extends FlightBase { uav_type?: string; aircraft_model?: string; wiki?: string; + force?: string; } export type Flight = CommercialFlight | PrivateFlight | PrivateJet | MilitaryFlight | TrackedFlight | UAV;