feat: USNI vessel rendering with homeport resolution (#1525)

* feat: USNI vessel rendering with homeport resolution and cluster markers

Enhanced vessel rendering on GlobeMap with USNI fleet data enrichment:
- Homeport resolution with fallback chain: parsed USNI homePort text →
  hull-number lookup table (HULL_HOMEPORT) → deployment theater coords
- Port-specific scatter offsets for in-port vessels (tighter 1-4km spread)
- USNI deployment status badges in vessel popups (escaped via esc())
- Vessel cluster markers with count + type composition indicators
- Data source badge in MapPopup for USNI-enriched vs AIS-only vessels
- Added usniHomePort field to MilitaryVessel type
- USNI_REGION_COORDINATES expanded with additional homeports/shipyards

* fix: address review findings on vessel rendering

- hideTooltip() now also hides MapPopup to prevent orphaned popups
  when switching between vessel clicks and non-vessel hovers
- Escape cluster activityType in tooltip HTML for defense-in-depth
- Add verification date to HULL_HOMEPORT lookup table

* fix: populate usniHomePort for AIS-matched vessels and localize badges

- Resolve homeport in both hull-number and name-match enrichment paths,
  not just synthetic USNI vessels. AIS vessels matched to USNI records
  now get usniHomePort populated via the same resolvePortCoords chain.
- Replace hard-coded "EST. POSITION" / "AIS LIVE" strings in MapPopup
  with i18n keys so non-English locales render correctly.

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
Jon Torrez
2026-03-13 01:35:53 -05:00
committed by GitHub
parent b7b0ca196a
commit 019b2a00a5
8 changed files with 354 additions and 29 deletions

View File

@@ -31,9 +31,10 @@ import { getCountryBbox, getCountriesGeoJson, getCountryAtCoordinates, getCountr
import { escapeHtml } from '@/utils/sanitize';
import { showLayerWarning } from '@/utils/layer-warning';
import type { FeatureCollection, Geometry } from 'geojson';
import type { MapLayers, Hotspot, MilitaryFlight, MilitaryVessel, NaturalEvent, InternetOutage, CyberThreat, SocialUnrestEvent, UcdpGeoEvent, MilitaryBase, GammaIrradiator, Spaceport, EconomicCenter, StrategicWaterway, CriticalMineralProject, AIDataCenter, UnderseaCable, Pipeline, CableAdvisory, RepairShip, AisDisruptionEvent, AisDensityZone, AisDisruptionType } from '@/types';
import type { MapLayers, Hotspot, MilitaryFlight, MilitaryVessel, MilitaryVesselCluster, NaturalEvent, InternetOutage, CyberThreat, SocialUnrestEvent, UcdpGeoEvent, MilitaryBase, GammaIrradiator, Spaceport, EconomicCenter, StrategicWaterway, CriticalMineralProject, AIDataCenter, UnderseaCable, Pipeline, CableAdvisory, RepairShip, AisDisruptionEvent, AisDensityZone, AisDisruptionType } from '@/types';
import type { Earthquake } from '@/services/earthquakes';
import type { AirportDelayAlert } from '@/services/aviation';
import { MapPopup } from './MapPopup';
import type { MapContainerState, MapView, TimeRange } from './MapContainer';
import type { CountryClickPayload } from './DeckGLMap';
import type { WeatherAlert } from '@/services/weather';
@@ -80,7 +81,27 @@ interface VesselMarker extends BaseMarker {
_kind: 'vessel';
id: string;
name: string;
type: string;
type: string; // raw enum key: 'carrier'|'destroyer' etc — color/icon lookup
typeLabel: string; // human-readable: 'Aircraft Carrier' etc — display only
hullNumber?: string;
operator?: string;
operatorCountry?: string;
isDark?: boolean;
usniStrikeGroup?: string;
usniRegion?: string;
usniDeploymentStatus?: string;
usniHomePort?: string;
usniActivityDescription?: string;
usniArticleDate?: string;
usniSource?: boolean;
}
interface ClusterMarker extends BaseMarker {
_kind: 'cluster';
id: string;
name: string;
vesselCount: number;
activityType?: string;
region?: string;
}
interface WeatherMarker extends BaseMarker {
_kind: 'weather';
@@ -339,7 +360,7 @@ interface GlobePolygon {
previewUrl?: string;
}
type GlobeMarker =
| ConflictMarker | HotspotMarker | FlightMarker | VesselMarker
| ConflictMarker | HotspotMarker | FlightMarker | VesselMarker | ClusterMarker
| WeatherMarker | NaturalMarker | IranMarker | OutageMarker
| CyberMarker | FireMarker | ProtestMarker
| UcdpMarker | DisplacementMarker | ClimateMarker | GpsJamMarker | TechMarker
@@ -396,6 +417,10 @@ export class GlobeMap {
private hotspots: HotspotMarker[] = [];
private flights: FlightMarker[] = [];
private vessels: VesselMarker[] = [];
private vesselData: Map<string, MilitaryVessel> = new Map();
private clusterMarkers: ClusterMarker[] = [];
private clusterData: Map<string, MilitaryVesselCluster> = new Map();
private popup: MapPopup | null = null;
private weatherMarkers: WeatherMarker[] = [];
private naturalMarkers: NaturalMarker[] = [];
private iranMarkers: IranMarker[] = [];
@@ -477,6 +502,7 @@ export class GlobeMap {
constructor(container: HTMLElement, initialState: MapContainerState) {
this.container = container;
this.popup = new MapPopup(this.container);
this.layers = { ...initialState.layers };
this.timeRange = initialState.timeRange;
this.currentView = initialState.view;
@@ -644,7 +670,7 @@ export class GlobeMap {
const m = d as GlobeMarker;
if (m._kind === 'satFootprint') return 0;
if (m._kind === 'satellite') return (m as SatelliteMarker).alt / 6371;
if (m._kind === 'flight' || m._kind === 'vessel') return 0.012;
if (m._kind === 'flight' || m._kind === 'vessel' || m._kind === 'cluster') return 0.012;
if (m._kind === 'hotspot') return 0.005;
return 0.003;
})
@@ -883,25 +909,42 @@ export class GlobeMap {
el.title = d.name;
} else if (d._kind === 'flight') {
const heading = d.heading ?? 0;
const typeColors: Record<string, string> = {
fighter: '#ff4444', bomber: '#ff8800', recon: '#44aaff',
tanker: '#88ff44', transport: '#aaaaff', helicopter: '#ffff44',
drone: '#ff44ff', maritime: '#44ffff',
};
const color = typeColors[d.type] ?? '#cccccc';
const color = GlobeMap.FLIGHT_TYPE_COLORS[d.type] ?? '#cccccc';
el.innerHTML = GlobeMap.wrapHit(`
<div style="transform:rotate(${heading}deg);font-size:11px;color:${color};text-shadow:0 0 4px ${color}88;line-height:1;">
</div>`);
el.title = `${d.callsign} (${d.type})`;
} else if (d._kind === 'vessel') {
const typeColors: Record<string, string> = {
carrier: '#ff4444', destroyer: '#ff8800', submarine: '#8844ff',
frigate: '#44aaff', amphibious: '#88ff44', support: '#aaaaaa',
};
const c = typeColors[d.type] ?? '#44aaff';
el.innerHTML = GlobeMap.wrapHit(`<div style="font-size:10px;color:${c};text-shadow:0 0 4px ${c}88;">⛴</div>`);
el.title = `${d.name} (${d.type})`;
const c = GlobeMap.VESSEL_TYPE_COLORS[d.type] ?? '#44aaff';
const icon = GlobeMap.VESSEL_TYPE_ICONS[d.type] ?? '\u26f4';
const isCarrier = d.type === 'carrier';
const sz = isCarrier ? 15 : 10;
const glow = isCarrier ? `0 0 10px 4px ${c}bb` : `0 0 4px ${c}88`;
const darkRing = d.isDark
? `<div style="position:absolute;inset:-6px;border-radius:50%;border:2px solid #ff444499;${this.pulseStyle('1.5s')}"></div>`
: '';
const usniRing = d.usniSource
? `<div style="position:absolute;inset:-4px;border-radius:50%;border:2px dashed #ffaa4466;"></div>`
: '';
el.innerHTML = GlobeMap.wrapHit(
`<div style="position:relative;display:inline-flex;align-items:center;justify-content:center;">` +
darkRing +
usniRing +
`<div style="font-size:${sz}px;color:${c};text-shadow:${glow};line-height:1;${d.usniSource ? 'opacity:0.8;' : ''}">${icon}</div>` +
`</div>`
);
el.title = `${d.name}${d.hullNumber ? ` (${d.hullNumber})` : ''} \u00b7 ${d.typeLabel} \u00b7 ${d.usniSource ? 'EST. POSITION' : 'AIS LIVE'}`;
} else if (d._kind === 'cluster') {
const cc = GlobeMap.CLUSTER_ACTIVITY_COLORS[d.activityType ?? 'unknown'] ?? '#6688aa';
const sz = Math.max(14, Math.min(26, 12 + d.vesselCount * 2));
el.innerHTML = GlobeMap.wrapHit(
`<div style="position:relative;display:inline-flex;align-items:center;justify-content:center;width:${sz}px;height:${sz}px;">` +
`<div style="position:absolute;inset:0;border-radius:50%;background:${cc}22;border:2px solid ${cc}bb;${this.pulseStyle('2.5s')}"></div>` +
`<span style="position:relative;font-size:9px;color:${cc};font-weight:bold;line-height:1;">${d.vesselCount}</span>` +
`</div>`
);
el.title = `${d.name} \u00b7 ${d.vesselCount} vessel${d.vesselCount !== 1 ? 's' : ''}`;
} else if (d._kind === 'weather') {
const severityColors: Record<string, string> = {
Extreme: '#ff0044', Severe: '#ff6600', Moderate: '#ffaa00', Minor: '#88aaff',
@@ -1098,6 +1141,33 @@ export class GlobeMap {
escalationScore: d.escalationScore as Hotspot['escalationScore'],
});
}
if (d._kind === 'vessel' && this.popup) {
const vessel = this.vesselData.get(d.id);
if (vessel) {
const aRect = anchor.getBoundingClientRect();
const cRect = this.container.getBoundingClientRect();
const x = aRect.left - cRect.left + aRect.width / 2;
const y = aRect.top - cRect.top;
this.hideTooltip();
this.popup.show({ type: 'militaryVessel', data: vessel, x, y });
return;
}
}
if (d._kind === 'cluster' && this.popup) {
const cluster = this.clusterData.get(d.id);
if (cluster) {
const aRect = anchor.getBoundingClientRect();
const cRect = this.container.getBoundingClientRect();
const x = aRect.left - cRect.left + aRect.width / 2;
const y = aRect.top - cRect.top;
this.hideTooltip();
this.popup.show({ type: 'militaryVesselCluster', data: cluster, x, y });
return;
}
}
this.showMarkerTooltip(d, anchor);
}
@@ -1134,7 +1204,44 @@ export class GlobeMap {
} else if (d._kind === 'flight') {
html = `<span style="font-weight:bold;">✈ ${esc(d.callsign)}</span><br><span style="opacity:.7;">${esc(d.type)}</span>`;
} else if (d._kind === 'vessel') {
html = `<span style="font-weight:bold;">⛴ ${esc(d.name)}</span><br><span style="opacity:.7;">${esc(d.type)}</span>`;
const deployStatus = d.usniDeploymentStatus && d.usniDeploymentStatus !== 'unknown'
? ` <span style="opacity:.6;font-size:10px;">[${esc(d.usniDeploymentStatus.toUpperCase().replace('-', ' '))}]</span>`
: '';
const darkWarning = d.isDark
? `<br><span style="color:#ff4444;font-size:10px;font-weight:bold;">⚠ AIS DARK</span>`
: '';
const operatorLine = d.operatorCountry || d.operator
? `<br><span style="opacity:.6;font-size:10px;">${esc(d.operatorCountry || d.operator || '')}</span>`
: '';
const hullLine = d.hullNumber
? ` <span style="opacity:.5;font-size:10px;">(${esc(d.hullNumber)})</span>`
: '';
const articleDate = d.usniArticleDate
? ` · ${new Date(d.usniArticleDate).toLocaleDateString()}`
: '';
const inPort = d.usniDeploymentStatus === 'in-port';
const portLine = inPort && d.usniHomePort
? `<br><span style="color:#44aaff;font-size:10px;">🏠 ${esc(d.usniHomePort)}</span>`
: '';
html = `<span style="font-weight:bold;">⛴ ${esc(d.name)}${hullLine}${deployStatus}</span>`
+ darkWarning
+ `<br><span style="opacity:.7;">${esc(d.typeLabel)}</span>`
+ operatorLine
+ portLine
+ (!inPort && d.usniStrikeGroup ? `<br><span style="opacity:.85;">⚓ ${esc(d.usniStrikeGroup)}</span>` : '')
+ (d.usniRegion ? `<br><span style="opacity:.6;font-size:10px;">${esc(d.usniRegion)}</span>` : '')
+ (d.usniActivityDescription ? `<br><span style="opacity:.6;font-size:10px;white-space:normal;display:block;max-width:200px;">${esc(d.usniActivityDescription.slice(0, 120))}</span>` : '')
+ (d.usniSource
? `<br><span style="color:#ffaa44;font-size:9px;">⚠ EST. POSITION — ${inPort ? 'In-port' : 'Approx.'} via USNI${articleDate}</span>`
: `<br><span style="color:#44ff88;font-size:9px;">● AIS LIVE</span>`);
} else if (d._kind === 'cluster') {
const cc = GlobeMap.CLUSTER_ACTIVITY_COLORS[d.activityType ?? 'unknown'] ?? '#6688aa';
const actLabel = d.activityType && d.activityType !== 'unknown'
? d.activityType.charAt(0).toUpperCase() + d.activityType.slice(1) : '';
html = `<span style="color:${cc};font-weight:bold;">⚓ ${esc(d.name)}</span>`
+ `<br><span style="opacity:.7;">${d.vesselCount} vessel${d.vesselCount !== 1 ? 's' : ''}</span>`
+ (actLabel ? `<br><span style="opacity:.6;font-size:10px;">Activity: ${esc(actLabel)}</span>` : '')
+ (d.region ? `<br><span style="opacity:.6;font-size:10px;">${esc(d.region)}</span>` : '');
} else if (d._kind === 'weather') {
const wc = d.severity === 'Extreme' ? '#ff0044' : d.severity === 'Severe' ? '#ff6600' : '#88aaff';
html = `<span style="color:${wc};font-weight:bold;">⚡ ${esc(d.severity)}</span>` +
@@ -1327,6 +1434,7 @@ export class GlobeMap {
if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); this.tooltipHideTimer = null; }
this.tooltipEl?.remove();
this.tooltipEl = null;
this.popup?.hide();
}
// ─── Overlay UI: zoom controls & layer panel ─────────────────────────────
@@ -1476,6 +1584,7 @@ export class GlobeMap {
if (this.layers.military) {
markers.push(...this.flights);
markers.push(...this.vessels);
markers.push(...this.clusterMarkers);
}
if (this.layers.weather) markers.push(...this.weatherMarkers);
if (this.layers.natural) {
@@ -1767,14 +1876,91 @@ export class GlobeMap {
this.flushMarkers();
}
public setMilitaryVessels(vessels: MilitaryVessel[]): void {
private static readonly FLIGHT_TYPE_COLORS: Record<string, string> = {
fighter: '#ff4444', bomber: '#ff8800', recon: '#44aaff',
tanker: '#88ff44', transport: '#aaaaff', helicopter: '#ffff44',
drone: '#ff44ff', maritime: '#44ffff',
};
private static readonly VESSEL_TYPE_COLORS: Record<string, string> = {
carrier: '#ff4444',
destroyer: '#ff8800',
frigate: '#ffcc00',
submarine: '#8844ff',
amphibious: '#44cc88',
patrol: '#44aaff',
auxiliary: '#aaaaaa',
research: '#44ffff',
icebreaker: '#88ccff',
special: '#ff44ff',
};
private static readonly VESSEL_TYPE_ICONS: Record<string, string> = {
carrier: '\u26f4',
destroyer: '\u25b2',
frigate: '\u25b2',
submarine: '\u25c6',
amphibious: '\u2b21',
patrol: '\u25b6',
auxiliary: '\u25cf',
research: '\u25ce',
icebreaker: '\u2745',
special: '\u2605',
};
private static readonly CLUSTER_ACTIVITY_COLORS: Record<string, string> = {
deployment: '#ff4444', exercise: '#ff8800', transit: '#ffcc00', unknown: '#6688aa',
};
private static readonly VESSEL_TYPE_LABELS: Record<string, string> = {
carrier: 'Aircraft Carrier',
destroyer: 'Destroyer',
frigate: 'Frigate',
submarine: 'Submarine',
amphibious: 'Amphibious',
patrol: 'Patrol',
auxiliary: 'Auxiliary',
research: 'Research',
icebreaker: 'Icebreaker',
special: 'Special Mission',
unknown: 'Unknown',
};
public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void {
this.vesselData.clear();
for (const v of vessels) this.vesselData.set(v.id, v);
this.clusterData.clear();
for (const c of clusters) this.clusterData.set(c.id, c);
this.vessels = vessels.map(v => ({
_kind: 'vessel' as const,
_lat: v.lat,
_lng: v.lon,
id: v.id,
name: (v as any).name ?? 'vessel',
type: (v as any).vesselType ?? 'destroyer',
name: v.name ?? 'vessel',
type: v.vesselType, // raw enum — color/icon key
typeLabel: GlobeMap.VESSEL_TYPE_LABELS[v.vesselType] ?? v.vesselType, // display string
hullNumber: v.hullNumber,
operator: v.operator !== 'other' ? v.operator : undefined,
operatorCountry: v.operatorCountry,
isDark: v.isDark,
usniStrikeGroup: v.usniStrikeGroup,
usniRegion: v.usniRegion,
usniDeploymentStatus: v.usniDeploymentStatus,
usniHomePort: v.usniHomePort,
usniActivityDescription: v.usniActivityDescription,
usniArticleDate: v.usniArticleDate,
usniSource: v.usniSource,
}));
this.clusterMarkers = clusters.map(c => ({
_kind: 'cluster' as const,
_lat: c.lat,
_lng: c.lon,
id: c.id,
name: c.name,
vesselCount: c.vesselCount,
activityType: c.activityType,
region: c.region,
}));
this.flushMarkers();
}
@@ -2783,6 +2969,10 @@ export class GlobeMap {
// ─── Destroy ──────────────────────────────────────────────────────────────
public destroy(): void {
this.popup?.hide();
this.popup = null;
this.vesselData.clear();
this.clusterData.clear();
this.container.removeEventListener('contextmenu', this.handleContextMenu);
this.unsubscribeGlobeQuality?.();
this.unsubscribeGlobeQuality = null;

View File

@@ -483,7 +483,7 @@ export class MapContainer {
public setMilitaryVessels(vessels: MilitaryVessel[], clusters: MilitaryVesselCluster[] = []): void {
this.cachedMilitaryVessels = vessels;
this.cachedMilitaryVesselClusters = clusters;
if (this.useGlobe) { this.globeMap?.setMilitaryVessels(vessels); return; }
if (this.useGlobe) { this.globeMap?.setMilitaryVessels(vessels, clusters); return; }
if (this.useDeckGL) { this.deckGLMap?.setMilitaryVessels(vessels, clusters); } else { this.svgMap?.setMilitaryVessels(vessels, clusters); }
}

View File

@@ -2177,6 +2177,10 @@ export class MapPopup {
? `<span class="popup-badge high">${t('popups.militaryVessel.aisDark')}</span>`
: '';
const dataSourceBadge = vessel.usniSource
? `<span class="popup-badge" style="background:rgba(255,170,50,0.15);border:1px solid rgba(255,170,50,0.5);color:#ffaa44;">${t('popups.militaryVessel.estPosition')}</span>`
: `<span class="popup-badge" style="background:rgba(68,255,136,0.15);border:1px solid rgba(68,255,136,0.5);color:#44ff88;">${t('popups.militaryVessel.aisLive')}</span>`;
// USNI deployment status badge
const deploymentBadge = vessel.usniDeploymentStatus && vessel.usniDeploymentStatus !== 'unknown'
? `<span class="popup-badge ${vessel.usniDeploymentStatus === 'deployed' ? 'high' : vessel.usniDeploymentStatus === 'underway' ? 'elevated' : 'low'}">${vessel.usniDeploymentStatus.toUpperCase().replace('-', ' ')}</span>`
@@ -2201,6 +2205,7 @@ export class MapPopup {
<div class="popup-header military-vessel ${vessel.operator}">
<span class="popup-title">${vesselName}</span>
${darkWarning}
${dataSourceBadge}
${deploymentBadge}
<span class="popup-badge elevated">${vesselBadgeType}</span>
<button class="popup-close" aria-label="Close">×</button>

View File

@@ -526,6 +526,53 @@ export const USNI_REGION_COORDINATES: Record<string, { lat: number; lon: number
'Bangor': { lat: 47.73, lon: -122.71 },
'Djibouti': { lat: 11.55, lon: 43.15 },
'Singapore': { lat: 1.35, lon: 103.82 },
// Additional homeports / shipyards
'Newport News': { lat: 37.00, lon: -76.43 }, // Huntington Ingalls / NNSY — carrier RCOH
'Puget Sound': { lat: 47.57, lon: -122.63 }, // alias for Bremerton / PSNS
'Naval Station Kitsap': { lat: 47.57, lon: -122.63 },
'Kitsap': { lat: 47.57, lon: -122.63 },
'Portsmouth': { lat: 43.07, lon: -70.76 }, // Portsmouth Naval Shipyard (Kittery, ME — submarine)
'Groton': { lat: 41.35, lon: -72.09 }, // Naval Submarine Base New London
'New London': { lat: 41.35, lon: -72.09 },
'Pascagoula': { lat: 30.37, lon: -88.55 }, // Ingalls shipbuilding
'Jacksonville': { lat: 30.39, lon: -81.40 }, // NAS Jax / Mayport area
'Pensacola': { lat: 30.35, lon: -87.30 },
'Corpus Christi': { lat: 27.80, lon: -97.40 },
'Deveselu': { lat: 44.10, lon: 24.09 }, // NATO BMD site, Romania
};
/**
* Fallback homeport lookup keyed by normalized hull number (e.g. "CVN-68").
* Used when deploymentStatus === 'in-port' but the USNI article text doesn't
* explicitly name the port. Only covers ships whose homeports are stable and
* well-documented; keep this list concise — Option A (parsed homePort text)
* is preferred and this is the fallback.
* Last verified: March 2026 (USNI Fleet Tracker)
*/
export const HULL_HOMEPORT: Record<string, string> = {
// Aircraft Carriers
'CVN-68': 'Bremerton', // USS Nimitz — Naval Station Kitsap / PSNS RCOH
'CVN-69': 'Norfolk', // USS Dwight D. Eisenhower
'CVN-70': 'San Diego', // USS Carl Vinson
'CVN-71': 'San Diego', // USS Theodore Roosevelt
'CVN-72': 'Everett', // USS Abraham Lincoln — Naval Station Everett
'CVN-73': 'Norfolk', // USS George Washington — returned from Newport News RCOH
'CVN-74': 'Bremerton', // USS John C. Stennis — PSNS RCOH
'CVN-75': 'Norfolk', // USS Harry S. Truman
'CVN-76': 'San Diego', // USS Ronald Reagan — returning from Yokosuka
'CVN-77': 'Norfolk', // USS George H.W. Bush
'CVN-78': 'Norfolk', // USS Gerald R. Ford
'CVN-79': 'Norfolk', // USS John F. Kennedy — commissioning
// Amphibious Assault
'LHD-1': 'Norfolk', // USS Wasp
'LHD-2': 'Sasebo', // USS Essex — forward deployed Japan
'LHD-3': 'Norfolk', // USS Kearsarge
'LHD-4': 'San Diego', // USS Boxer
'LHD-5': 'Norfolk', // USS Bataan
'LHD-7': 'Norfolk', // USS Iwo Jima
'LHD-8': 'San Diego', // USS Makin Island
'LHA-6': 'San Diego', // USS America
'LHA-7': 'San Diego', // USS Tripoli
};
export function normalizeUSNIRegion(regionText: string): string {

View File

@@ -2101,7 +2101,9 @@
"usniIntel": "USNI Intel",
"usniSource": "Source: USNI News Fleet Tracker",
"approximatePosition": "Position approximate — based on USNI weekly report, not real-time AIS.",
"darkDescription": "⚠ Vessel has gone dark - AIS signal lost. May indicate sensitive operations."
"darkDescription": "⚠ Vessel has gone dark - AIS signal lost. May indicate sensitive operations.",
"estPosition": "EST. POSITION",
"aisLive": "AIS LIVE"
},
"militaryCluster": {
"flightActivity": {

View File

@@ -1,7 +1,7 @@
import type { MilitaryVessel, MilitaryVesselCluster, USNIFleetReport, USNIVesselEntry } from '@/types';
import { getRpcBaseUrl } from '@/services/rpc-client';
import { createCircuitBreaker } from '@/utils';
import { getUSNIRegionApproxCoords, getUSNIRegionCoords } from '@/config/military';
import { getUSNIRegionApproxCoords, getUSNIRegionCoords, HULL_HOMEPORT } from '@/config/military';
import {
MilitaryServiceClient,
type GetUSNIFleetReportResponse,
@@ -88,6 +88,44 @@ function scatterOffset(hullNumber: string, index: number): { lat: number; lon: n
return { lat: Math.sin(angle) * dist, lon: Math.cos(angle) * dist };
}
/** Tighter scatter for in-port ships — just enough to separate icons at the same pier. */
function portScatterOffset(hullNumber: string, index: number): { lat: number; lon: number } {
let hash = 0;
const str = hullNumber || String(index);
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
const angle = (hash % 360) * (Math.PI / 180);
const dist = 0.01 + (Math.abs(hash) % 10) * 0.003; // 0.010.04 deg ≈ 14 km
return { lat: Math.sin(angle) * dist, lon: Math.cos(angle) * dist };
}
/** Resolve homeport coordinates for an in-port vessel.
* Option A: USNI text supplied an explicit homePort string.
* Option B: Fall back to hull-number lookup table.
* Returns undefined if neither resolves. */
function resolvePortCoords(
homePort: string | undefined,
hullNumber: string | undefined,
): { lat: number; lon: number; portName: string } | undefined {
// Option A — use what USNI actually told us
if (homePort) {
const coords = getUSNIRegionCoords(homePort);
if (coords) return { ...coords, portName: homePort };
}
// Option B — hull number fallback table
if (hullNumber) {
const normalized = hullNumber.toUpperCase().replace(/\s+/g, '').replace(/[–—]/g, '-');
const portName = HULL_HOMEPORT[normalized];
if (portName) {
const coords = getUSNIRegionCoords(portName);
if (coords) return { ...coords, portName };
}
}
return undefined;
}
export function mergeUSNIWithAIS(
aisVessels: MilitaryVessel[],
usniReport: USNIFleetReport,
@@ -110,6 +148,10 @@ export function mergeUSNIWithAIS(
vessel.usniActivityDescription = usniVessel.activityDescription;
vessel.usniArticleUrl = usniVessel.usniArticleUrl;
vessel.usniArticleDate = usniVessel.usniArticleDate;
const portRes = usniVessel.deploymentStatus === 'in-port'
? resolvePortCoords(usniVessel.homePort, usniVessel.hullNumber)
: undefined;
vessel.usniHomePort = portRes?.portName ?? usniVessel.homePort;
matchedHulls.add(normalizeHull(usniVessel.hullNumber));
break;
}
@@ -132,6 +174,10 @@ export function mergeUSNIWithAIS(
vessel.usniActivityDescription = usniVessel.activityDescription;
vessel.usniArticleUrl = usniVessel.usniArticleUrl;
vessel.usniArticleDate = usniVessel.usniArticleDate;
const portRes = usniVessel.deploymentStatus === 'in-port'
? resolvePortCoords(usniVessel.homePort, usniVessel.hullNumber)
: undefined;
vessel.usniHomePort = portRes?.portName ?? usniVessel.homePort;
matchedHulls.add(normalizeHull(usniVessel.hullNumber));
break;
}
@@ -143,14 +189,33 @@ export function mergeUSNIWithAIS(
for (const usniVessel of usniReport.vessels) {
if (matchedHulls.has(normalizeHull(usniVessel.hullNumber))) continue;
const coords = getUSNIRegionCoords(usniVessel.region);
// Resolve position: in-port ships use port coords (Option A + B),
// deployed/underway ships use deployment theater coords.
const inPort = usniVessel.deploymentStatus === 'in-port';
const portResolution = inPort
? resolvePortCoords(usniVessel.homePort, usniVessel.hullNumber)
: undefined;
const regionCoords = getUSNIRegionCoords(usniVessel.region);
const hasParsedCoords = Number.isFinite(usniVessel.regionLat)
&& Number.isFinite(usniVessel.regionLon)
&& !(usniVessel.regionLat === 0 && usniVessel.regionLon === 0);
const fallbackCoords = getUSNIRegionApproxCoords(usniVessel.region);
const baseLat = coords?.lat ?? (hasParsedCoords ? usniVessel.regionLat : fallbackCoords.lat);
const baseLon = coords?.lon ?? (hasParsedCoords ? usniVessel.regionLon : fallbackCoords.lon);
const offset = scatterOffset(usniVessel.hullNumber, syntheticIndex++);
const baseLat = portResolution?.lat
?? regionCoords?.lat
?? (hasParsedCoords ? usniVessel.regionLat : fallbackCoords.lat);
const baseLon = portResolution?.lon
?? regionCoords?.lon
?? (hasParsedCoords ? usniVessel.regionLon : fallbackCoords.lon);
const offset = portResolution
? portScatterOffset(usniVessel.hullNumber, syntheticIndex++)
: scatterOffset(usniVessel.hullNumber, syntheticIndex++);
const noteBase = portResolution
? `In port — ${portResolution.portName} (USNI)`
: `USNI position — ${usniVessel.region} (approximate)`;
merged.push({
id: `usni-${usniVessel.hullNumber || usniVessel.name}`,
@@ -167,9 +232,10 @@ export function mergeUSNIWithAIS(
lastAisUpdate: new Date(usniVessel.usniArticleDate),
confidence: 'low',
isInteresting: usniVessel.vesselType === 'carrier' || usniVessel.vesselType === 'amphibious',
note: `USNI position — ${usniVessel.region} (approximate)`,
note: noteBase,
usniRegion: usniVessel.region,
usniDeploymentStatus: usniVessel.deploymentStatus,
usniHomePort: portResolution?.portName ?? usniVessel.homePort,
usniStrikeGroup: usniVessel.strikeGroup,
usniActivityDescription: usniVessel.activityDescription,
usniArticleUrl: usniVessel.usniArticleUrl,

View File

@@ -819,6 +819,7 @@ export interface MilitaryVessel {
note?: string;
usniRegion?: string;
usniDeploymentStatus?: USNIDeploymentStatus;
usniHomePort?: string;
usniStrikeGroup?: string;
usniActivityDescription?: string;
usniArticleUrl?: string;

14
src/utils/distance.ts Normal file
View File

@@ -0,0 +1,14 @@
export function haversineKm(
lat1: number, lon1: number,
lat2: number, lon2: number,
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}