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/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/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/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_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/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/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) 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/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/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..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; @@ -224,6 +226,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 +415,7 @@ export interface DashboardData { internet_outages?: InternetOutage[]; firms_fires?: FireHotspot[]; datacenters?: DataCenter[]; + military_bases?: MilitaryBase[]; } // ─── COMPONENT PROPS ──────────────────────────────────────────────────────── @@ -432,6 +444,7 @@ export interface ActiveLayers { firms: boolean; internet_outages: boolean; datacenters: boolean; + military_bases: boolean; } export interface SelectedEntity {