mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-14 11:06:21 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.01–0.04 deg ≈ 1–4 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,
|
||||
|
||||
@@ -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
14
src/utils/distance.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user