feat: add military bases map layer for Western Pacific

Add 18 US military bases (Japan, Guam, South Korea, Hawaii, Diego Garcia)
as a toggleable map layer. Follows the existing data center layer pattern:
static JSON → backend fetcher → slow-tier API → frontend GeoJSON layer.

Includes red circle markers with labels, click popups showing operator
and branch info, and a toggle in the left panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adust09
2026-03-16 00:33:35 +09:00
parent 130287bb49
commit 05de14af9d
11 changed files with 359 additions and 4 deletions
+74 -1
View File
@@ -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
</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 */}
{/* satellites: data pushed imperatively */}
<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;
const item = data.gdelt.find((g: any) => (g.properties?.name || String(g.geometry?.coordinates)) === selectedEntity.id);
@@ -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 },
];
+22 -1
View File
@@ -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 {