mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-23 17:30:46 +02:00
Merge pull request #72 from adust09/feat/military-bases-layer
feat: East Asia military tracking — ICAO enrichment, model classification, force display
This commit is contained in:
@@ -39,6 +39,31 @@
|
|||||||
"name": "Mercopress",
|
"name": "Mercopress",
|
||||||
"url": "https://en.mercopress.com/rss/",
|
"url": "https://en.mercopress.com/rss/",
|
||||||
"weight": 3
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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", [])),
|
"internet_outages": _f(d.get("internet_outages", [])),
|
||||||
"firms_fires": _f(d.get("firms_fires", [])),
|
"firms_fires": _f(d.get("firms_fires", [])),
|
||||||
"datacenters": _f(d.get("datacenters", [])),
|
"datacenters": _f(d.get("datacenters", [])),
|
||||||
|
"military_bases": _f(d.get("military_bases", [])),
|
||||||
"freshness": dict(source_timestamps),
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
bbox_tag = f"{s},{w},{n},{e}" if has_bbox else "full"
|
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,
|
fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
|
||||||
)
|
)
|
||||||
from services.fetchers.infrastructure import ( # noqa: F401
|
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
|
from services.fetchers.geo import ( # noqa: F401
|
||||||
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
|
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
|
||||||
@@ -85,6 +85,7 @@ def update_slow_data():
|
|||||||
fetch_frontlines,
|
fetch_frontlines,
|
||||||
fetch_gdelt,
|
fetch_gdelt,
|
||||||
fetch_datacenters,
|
fetch_datacenters,
|
||||||
|
fetch_military_bases,
|
||||||
]
|
]
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||||
futures = [executor.submit(func) for func in slow_funcs]
|
futures = [executor.submit(func) for func in slow_funcs]
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ latest_data = {
|
|||||||
"space_weather": None,
|
"space_weather": None,
|
||||||
"internet_outages": [],
|
"internet_outages": [],
|
||||||
"firms_fires": [],
|
"firms_fires": [],
|
||||||
"datacenters": []
|
"datacenters": [],
|
||||||
|
"military_bases": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-source freshness timestamps
|
# Per-source freshness timestamps
|
||||||
|
|||||||
@@ -143,6 +143,43 @@ def fetch_datacenters():
|
|||||||
_mark_fresh("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
|
# CCTV Cameras
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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):
|
def _classify_uav(model: str, callsign: str):
|
||||||
"""Check if an aircraft is a UAV based on type code, callsign prefix, or model keywords.
|
"""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)."""
|
Returns (is_uav, uav_type, wiki_url) or (False, None, None)."""
|
||||||
@@ -106,10 +156,13 @@ def fetch_military_flights():
|
|||||||
gs_knots = f.get("gs")
|
gs_knots = f.get("gs")
|
||||||
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
|
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)
|
is_uav, uav_type, wiki_url = _classify_uav(model, callsign)
|
||||||
if is_uav:
|
if is_uav:
|
||||||
|
uav_country, uav_force = _enrich_country(icao_hex, f.get("flag", ""))
|
||||||
detected_uavs.append({
|
detected_uavs.append({
|
||||||
"id": f"uav-{f.get('hex', '')}",
|
"id": f"uav-{icao_hex}",
|
||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"aircraft_model": f.get("t", "Unknown"),
|
"aircraft_model": f.get("t", "Unknown"),
|
||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
@@ -117,31 +170,24 @@ def fetch_military_flights():
|
|||||||
"alt": alt_value,
|
"alt": alt_value,
|
||||||
"heading": heading,
|
"heading": heading,
|
||||||
"speed_knots": speed_knots,
|
"speed_knots": speed_knots,
|
||||||
"country": f.get("flag", "Unknown"),
|
"country": uav_country,
|
||||||
|
"force": uav_force,
|
||||||
"uav_type": uav_type,
|
"uav_type": uav_type,
|
||||||
"wiki": wiki_url or "",
|
"wiki": wiki_url or "",
|
||||||
"type": "uav",
|
"type": "uav",
|
||||||
"registration": f.get("r", "N/A"),
|
"registration": f.get("r", "N/A"),
|
||||||
"icao24": f.get("hex", ""),
|
"icao24": icao_hex,
|
||||||
"squawk": f.get("squawk", ""),
|
"squawk": f.get("squawk", ""),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mil_cat = "default"
|
mil_country, mil_force = _enrich_country(icao_hex, f.get("flag", ""))
|
||||||
if "H" in model and any(c.isdigit() for c in model):
|
mil_cat = _classify_military_type(f.get("t", "UNKNOWN"))
|
||||||
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"
|
|
||||||
|
|
||||||
military_flights.append({
|
military_flights.append({
|
||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"country": f.get("flag", "Military Asset"),
|
"country": mil_country,
|
||||||
|
"force": mil_force,
|
||||||
"lng": float(lng),
|
"lng": float(lng),
|
||||||
"lat": float(lat),
|
"lat": float(lat),
|
||||||
"alt": alt_value,
|
"alt": alt_value,
|
||||||
@@ -154,7 +200,7 @@ def fetch_military_flights():
|
|||||||
"dest_name": "UNKNOWN",
|
"dest_name": "UNKNOWN",
|
||||||
"registration": f.get("r", "N/A"),
|
"registration": f.get("r", "N/A"),
|
||||||
"model": f.get("t", "Unknown"),
|
"model": f.get("t", "Unknown"),
|
||||||
"icao24": f.get("hex", ""),
|
"icao24": icao_hex,
|
||||||
"speed_knots": speed_knots,
|
"speed_knots": speed_knots,
|
||||||
"squawk": f.get("squawk", "")
|
"squawk": f.get("squawk", "")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,29 @@ _KEYWORD_COORDS = {
|
|||||||
"lebanon": (33.854, 35.862),
|
"lebanon": (33.854, 35.862),
|
||||||
"syria": (34.802, 38.996),
|
"syria": (34.802, 38.996),
|
||||||
"yemen": (15.552, 48.516),
|
"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),
|
"china": (35.861, 104.195),
|
||||||
"beijing": (39.904, 116.407),
|
"beijing": (39.904, 116.407),
|
||||||
"taiwan": (23.697, 120.960),
|
"taiwan": (23.697, 120.960),
|
||||||
@@ -90,6 +113,27 @@ _KEYWORD_COORDS = {
|
|||||||
"jakarta": (-6.208, 106.845),
|
"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)
|
@with_retry(max_retries=1, base_delay=2)
|
||||||
def fetch_news():
|
def fetch_news():
|
||||||
@@ -140,8 +184,6 @@ def fetch_news():
|
|||||||
risk_score += 2
|
risk_score += 2
|
||||||
risk_score = min(10, risk_score)
|
risk_score = min(10, risk_score)
|
||||||
|
|
||||||
keyword_coords = _KEYWORD_COORDS
|
|
||||||
|
|
||||||
lat, lng = None, None
|
lat, lng = None, None
|
||||||
|
|
||||||
if 'georss_point' in entry:
|
if 'georss_point' in entry:
|
||||||
@@ -153,18 +195,10 @@ def fetch_news():
|
|||||||
lat, lng = coords[1], coords[0]
|
lat, lng = coords[1], coords[0]
|
||||||
|
|
||||||
if lat is None:
|
if lat is None:
|
||||||
# text may not be defined yet for GDACS path
|
|
||||||
text = (title + " " + summary).lower()
|
text = (title + " " + summary).lower()
|
||||||
padded_text = f" {text} "
|
result = _resolve_coords(text)
|
||||||
for kw, coords in keyword_coords.items():
|
if result:
|
||||||
if kw.startswith(" ") or kw.endswith(" "):
|
lat, lng = result
|
||||||
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
|
|
||||||
|
|
||||||
if lat is not None:
|
if lat is not None:
|
||||||
key = None
|
key = None
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ DEFAULT_FEEDS = [
|
|||||||
{"name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3},
|
{"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": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3},
|
||||||
{"name": "Mercopress", "url": "https://en.mercopress.com/rss/", "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},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -162,6 +162,7 @@ export default function Dashboard() {
|
|||||||
firms: false,
|
firms: false,
|
||||||
internet_outages: false,
|
internet_outages: false,
|
||||||
datacenters: false,
|
datacenters: false,
|
||||||
|
military_bases: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// NASA GIBS satellite imagery state
|
// NASA GIBS satellite imagery state
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import { useClusterLabels } from "@/components/map/hooks/useClusterLabels";
|
|||||||
import { spreadAlertItems } from "@/utils/alertSpread";
|
import { spreadAlertItems } from "@/utils/alertSpread";
|
||||||
import {
|
import {
|
||||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON, buildMilitaryBasesGeoJSON,
|
||||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON,
|
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON,
|
||||||
buildFlightLayerGeoJSON, buildUavGeoJSON,
|
buildFlightLayerGeoJSON, buildUavGeoJSON,
|
||||||
buildSatellitesGeoJSON, buildShipsGeoJSON, buildCarriersGeoJSON,
|
buildSatellitesGeoJSON, buildShipsGeoJSON, buildCarriersGeoJSON,
|
||||||
@@ -222,6 +222,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
activeLayers.datacenters ? buildDataCentersGeoJSON(data?.datacenters) : null,
|
activeLayers.datacenters ? buildDataCentersGeoJSON(data?.datacenters) : null,
|
||||||
[activeLayers.datacenters, data?.datacenters]);
|
[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
|
// Load Images into the Map Style once loaded
|
||||||
const onMapLoad = useCallback((e: any) => {
|
const onMapLoad = useCallback((e: any) => {
|
||||||
const map = e.target;
|
const map = e.target;
|
||||||
@@ -588,6 +592,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||||
internetOutagesGeoJSON && 'internet-outages-layer',
|
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||||
dataCentersGeoJSON && 'datacenters-layer',
|
dataCentersGeoJSON && 'datacenters-layer',
|
||||||
|
militaryBasesGeoJSON && 'military-bases-layer',
|
||||||
firmsGeoJSON && 'firms-viirs-layer'
|
firmsGeoJSON && 'firms-viirs-layer'
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
@@ -1463,6 +1468,40 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
</Source>
|
</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 */}
|
{/* Satellite positions — mission-type icons */}
|
||||||
{/* satellites: data pushed imperatively */}
|
{/* satellites: data pushed imperatively */}
|
||||||
<Source id="satellites" type="geojson" data={EMPTY_FC as any}>
|
<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;
|
if (selectedEntity?.type !== 'gdelt' || !data?.gdelt) return null;
|
||||||
const item = data.gdelt.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
const item = data.gdelt.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
|
||||||
|
|||||||
@@ -427,7 +427,12 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
let airline = "UNKNOWN";
|
let airline = "UNKNOWN";
|
||||||
|
|
||||||
if (selectedEntity.type === 'military_flight') {
|
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') {
|
} else if (selectedEntity.type === 'private_jet') {
|
||||||
airline = "PRIVATE JET";
|
airline = "PRIVATE JET";
|
||||||
} else if (selectedEntity.type === 'private_flight') {
|
} else if (selectedEntity.type === 'private_flight') {
|
||||||
|
|||||||
@@ -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: "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: "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: "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 },
|
{ 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.
|
// 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.
|
// 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 { classifyAircraft } from "@/utils/aircraftClassification";
|
||||||
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
|
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 ────────────────────────────────────────────────────────
|
// ─── GDELT Incidents ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
|
export function buildGdeltGeoJSON(gdelt?: GDELTIncident[], inView?: InViewFilter): FC {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface PrivateJet extends FlightBase {
|
|||||||
export interface MilitaryFlight extends FlightBase {
|
export interface MilitaryFlight extends FlightBase {
|
||||||
type: "military_flight";
|
type: "military_flight";
|
||||||
military_type?: "heli" | "fighter" | "tanker" | "cargo" | "recon" | "default";
|
military_type?: "heli" | "fighter" | "tanker" | "cargo" | "recon" | "default";
|
||||||
|
force?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackedFlight extends FlightBase {
|
export interface TrackedFlight extends FlightBase {
|
||||||
@@ -68,6 +69,7 @@ export interface UAV extends FlightBase {
|
|||||||
uav_type?: string;
|
uav_type?: string;
|
||||||
aircraft_model?: string;
|
aircraft_model?: string;
|
||||||
wiki?: string;
|
wiki?: string;
|
||||||
|
force?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Flight = CommercialFlight | PrivateFlight | PrivateJet | MilitaryFlight | TrackedFlight | UAV;
|
export type Flight = CommercialFlight | PrivateFlight | PrivateJet | MilitaryFlight | TrackedFlight | UAV;
|
||||||
@@ -224,6 +226,15 @@ export interface DataCenter {
|
|||||||
lng: number;
|
lng: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MilitaryBase {
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
operator: string;
|
||||||
|
branch: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── NEWS / GLOBAL INCIDENTS ────────────────────────────────────────────────
|
// ─── NEWS / GLOBAL INCIDENTS ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface NewsArticle {
|
export interface NewsArticle {
|
||||||
@@ -404,6 +415,7 @@ export interface DashboardData {
|
|||||||
internet_outages?: InternetOutage[];
|
internet_outages?: InternetOutage[];
|
||||||
firms_fires?: FireHotspot[];
|
firms_fires?: FireHotspot[];
|
||||||
datacenters?: DataCenter[];
|
datacenters?: DataCenter[];
|
||||||
|
military_bases?: MilitaryBase[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── COMPONENT PROPS ────────────────────────────────────────────────────────
|
// ─── COMPONENT PROPS ────────────────────────────────────────────────────────
|
||||||
@@ -432,6 +444,7 @@ export interface ActiveLayers {
|
|||||||
firms: boolean;
|
firms: boolean;
|
||||||
internet_outages: boolean;
|
internet_outages: boolean;
|
||||||
datacenters: boolean;
|
datacenters: boolean;
|
||||||
|
military_bases: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectedEntity {
|
export interface SelectedEntity {
|
||||||
|
|||||||
Reference in New Issue
Block a user