import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Ship, ShipCategory } from '../types'; import maplibregl from 'maplibre-gl'; interface Props { ships: Ship[]; militaryOnly: boolean; koreanOnly?: boolean; } // ── MarineTraffic-style vessel type colors (CSS variable references) ── const MT_TYPE_COLORS: Record = { cargo: 'var(--kcg-ship-cargo)', tanker: 'var(--kcg-ship-tanker)', passenger: 'var(--kcg-ship-passenger)', fishing: 'var(--kcg-ship-fishing)', pleasure: 'var(--kcg-ship-pleasure)', military: 'var(--kcg-ship-military)', tug_special: 'var(--kcg-ship-tug)', other: 'var(--kcg-ship-other)', unknown: 'var(--kcg-ship-unknown)', }; // Resolved hex colors for MapLibre paint (which cannot use CSS vars) const MT_TYPE_HEX: Record = { cargo: '#f0a830', tanker: '#e74c3c', passenger: '#4caf50', fishing: '#42a5f5', pleasure: '#e91e8c', military: '#d32f2f', tug_special: '#2e7d32', other: '#5c6bc0', unknown: '#9e9e9e', }; // Map our internal ShipCategory + typecode → MT visual type function getMTType(ship: Ship): string { const tc = (ship.typecode || '').toUpperCase(); const cat = ship.category; // Military first if (cat === 'carrier' || cat === 'destroyer' || cat === 'warship' || cat === 'submarine' || cat === 'patrol') return 'military'; if (tc === 'DDG' || tc === 'DDH' || tc === 'CVN' || tc === 'FFG' || tc === 'LCS' || tc === 'MCM' || tc === 'PC' || tc === 'LPH') return 'military'; // Tanker if (cat === 'tanker') return 'tanker'; if (tc === 'VLCC' || tc === 'LNG' || tc === 'LPG') return 'tanker'; if (tc.startsWith('A1')) return 'tanker'; // Cargo if (cat === 'cargo') return 'cargo'; if (tc === 'CONT' || tc === 'BULK') return 'cargo'; if (tc.startsWith('A2') || tc.startsWith('A3')) return 'cargo'; // Passenger if (tc === 'PASS' || tc.startsWith('B')) return 'passenger'; // Fishing if (tc.startsWith('C')) return 'fishing'; // Tug / Special if (tc.startsWith('D') || tc.startsWith('E')) return 'tug_special'; // Pleasure if (tc === 'SAIL' || tc === 'YACHT') return 'pleasure'; if (cat === 'civilian') return 'other'; return 'unknown'; } // Legacy navy flag colors (for popup header accent only) const NAVY_COLORS: Record = { US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff', IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43', }; const FLAG_EMOJI: Record = { US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', KR: '\u{1F1F0}\u{1F1F7}', IR: '\u{1F1EE}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', AU: '\u{1F1E6}\u{1F1FA}', DE: '\u{1F1E9}\u{1F1EA}', IN: '\u{1F1EE}\u{1F1F3}', CN: '\u{1F1E8}\u{1F1F3}', PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', MH: '\u{1F1F2}\u{1F1ED}', HK: '\u{1F1ED}\u{1F1F0}', SG: '\u{1F1F8}\u{1F1EC}', BZ: '\u{1F1E7}\u{1F1FF}', OM: '\u{1F1F4}\u{1F1F2}', AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', BH: '\u{1F1E7}\u{1F1ED}', QA: '\u{1F1F6}\u{1F1E6}', }; // icon-size multiplier (symbol layer, base=64px) const SIZE_MAP: Record = { carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16, tanker: 0.16, cargo: 0.16, civilian: 0.14, unknown: 0.12, }; const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol']; function isMilitary(category: ShipCategory): boolean { return MIL_CATEGORIES.includes(category); } function getShipColor(ship: Ship): string { return MT_TYPE_COLORS[getMTType(ship)] || MT_TYPE_COLORS.unknown; } function getShipHex(ship: Ship): string { return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown; } // ── Local Korean ship photos ── const LOCAL_SHIP_PHOTOS: Record = { '440034000': '/ships/440034000.jpg', '440150000': '/ships/440150000.jpg', '440272000': '/ships/440272000.jpg', '440274000': '/ships/440274000.jpg', '440323000': '/ships/440323000.jpg', '440384000': '/ships/440384000.jpg', '440880000': '/ships/440880000.jpg', '441046000': '/ships/441046000.jpg', '441345000': '/ships/441345000.jpg', '441353000': '/ships/441353000.jpg', '441393000': '/ships/441393000.jpg', '441423000': '/ships/441423000.jpg', '441548000': '/ships/441548000.jpg', '441708000': '/ships/441708000.png', '441866000': '/ships/441866000.jpg', }; interface VesselPhotoData { url: string; } const vesselPhotoCache = new Map(); type PhotoSource = 'signal-batch' | 'marinetraffic'; interface VesselPhotoProps { mmsi: string; imo?: string; shipImagePath?: string | null; } function VesselPhoto({ mmsi, shipImagePath }: VesselPhotoProps) { const { t } = useTranslation('ships'); const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; // Determine available tabs const hasSignalBatch = !!shipImagePath; const defaultTab: PhotoSource = hasSignalBatch ? 'signal-batch' : 'marinetraffic'; const [activeTab, setActiveTab] = useState(defaultTab); // MarineTraffic image state (lazy loaded) const [mtPhoto, setMtPhoto] = useState(() => { return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; }); useEffect(() => { if (activeTab !== 'marinetraffic') return; if (mtPhoto !== undefined) return; const imgUrl = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`; const img = new Image(); img.onload = () => { const result = { url: imgUrl }; vesselPhotoCache.set(mmsi, result); setMtPhoto(result); }; img.onerror = () => { vesselPhotoCache.set(mmsi, null); setMtPhoto(null); }; img.src = imgUrl; }, [mmsi, activeTab, mtPhoto]); // Resolve current image URL let currentUrl: string | null = null; if (localUrl) { currentUrl = localUrl; } else if (activeTab === 'signal-batch' && shipImagePath) { currentUrl = shipImagePath; } else if (activeTab === 'marinetraffic' && mtPhoto) { currentUrl = mtPhoto.url; } // If local photo exists, show it directly without tabs if (localUrl) { return (
Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} />
); } return (
{hasSignalBatch && (
setActiveTab('signal-batch')} > signal-batch
)}
setActiveTab('marinetraffic')} > MarineTraffic
{currentUrl ? ( Vessel { (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : ( activeTab === 'marinetraffic' && mtPhoto === undefined ?
{t('popup.loading')}
: null )}
); } function formatCoord(lat: number, lng: number): string { const latDir = lat >= 0 ? 'N' : 'S'; const lngDir = lng >= 0 ? 'E' : 'W'; return `${Math.abs(lat).toFixed(3)}${latDir}, ${Math.abs(lng).toFixed(3)}${lngDir}`; } // Create triangle SDF image for MapLibre symbol layer const TRIANGLE_SIZE = 64; function ensureTriangleImage(map: maplibregl.Map) { if (map.hasImage('ship-triangle')) return; const s = TRIANGLE_SIZE; const canvas = document.createElement('canvas'); canvas.width = s; canvas.height = s; const ctx = canvas.getContext('2d')!; // Draw upward-pointing triangle (heading 0 = north) ctx.beginPath(); ctx.moveTo(s / 2, 2); // top center ctx.lineTo(s * 0.12, s - 2); // bottom left ctx.lineTo(s / 2, s * 0.62); // inner notch ctx.lineTo(s * 0.88, s - 2); // bottom right ctx.closePath(); ctx.fillStyle = '#ffffff'; ctx.fill(); const imgData = ctx.getImageData(0, 0, s, s); map.addImage('ship-triangle', { width: s, height: s, data: new Uint8Array(imgData.data.buffer) }, { sdf: true }); } // ── Main layer (WebGL symbol rendering — triangles) ── export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) { const { current: map } = useMap(); const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); const filtered = useMemo(() => { let result = ships; if (koreanOnly) result = result.filter(s => s.flag === 'KR'); if (militaryOnly) result = result.filter(s => isMilitary(s.category)); return result; }, [ships, militaryOnly, koreanOnly]); // Add triangle image to map useEffect(() => { if (!map) return; const m = map.getMap(); const addIcon = () => { try { ensureTriangleImage(m); } catch { /* already added */ } setImageReady(true); }; if (m.isStyleLoaded()) { addIcon(); } else { m.once('load', addIcon); } return () => { m.off('load', addIcon); }; }, [map]); // Build GeoJSON for all ships const shipGeoJson = useMemo(() => { const features: GeoJSON.Feature[] = filtered.map(ship => ({ type: 'Feature' as const, properties: { mmsi: ship.mmsi, color: getShipHex(ship), size: SIZE_MAP[ship.category], isMil: isMilitary(ship.category) ? 1 : 0, isKorean: ship.flag === 'KR' ? 1 : 0, isCheonghae: ship.mmsi === '440001981' ? 1 : 0, heading: ship.heading, }, geometry: { type: 'Point' as const, coordinates: [ship.lng, ship.lat], }, })); return { type: 'FeatureCollection' as const, features }; }, [filtered]); // Register click and cursor handlers useEffect(() => { if (!map) return; const m = map.getMap(); const layerId = 'ships-triangles'; const handleClick = (e: maplibregl.MapLayerMouseEvent) => { if (e.features && e.features.length > 0) { const mmsi = e.features[0].properties?.mmsi; if (mmsi) setSelectedMmsi(mmsi); } }; const handleEnter = () => { m.getCanvas().style.cursor = 'pointer'; }; const handleLeave = () => { m.getCanvas().style.cursor = ''; }; m.on('click', layerId, handleClick); m.on('mouseenter', layerId, handleEnter); m.on('mouseleave', layerId, handleLeave); return () => { m.off('click', layerId, handleClick); m.off('mouseenter', layerId, handleEnter); m.off('mouseleave', layerId, handleLeave); }; }, [map]); const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null; // Carrier labels — only a few, so DOM markers are fine const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]); if (!imageReady) return null; return ( <> {/* Korean ship outer ring (circle behind triangle) */} {/* Main ship triangles */} {/* Carrier labels as DOM markers (very few) */} {carriers.map(ship => (
{ship.name}
))} {/* Popup for selected ship */} {selectedShip && ( setSelectedMmsi(null)} /> )} ); } const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) { const { t } = useTranslation('ships'); const mtType = getMTType(ship); const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown; const isMil = isMilitary(ship.category); const navyLabel = isMil && ship.flag ? t(`navyLabel.${ship.flag}`, { defaultValue: '' }) : undefined; const navyAccent = isMil && ship.flag && NAVY_COLORS[ship.flag] ? NAVY_COLORS[ship.flag] : undefined; const flagEmoji = ship.flag ? FLAG_EMOJI[ship.flag] || '' : ''; return (
{flagEmoji && {flagEmoji}} {ship.name} {navyLabel && ( {navyLabel} )}
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} {t(`categoryLabel.${ship.category}`)} {ship.typeDesc && ( {ship.typeDesc} )}
{t('popup.mmsi')} : {ship.mmsi}
{ship.callSign &&
{t('popup.callSign')} : {ship.callSign}
} {ship.imo &&
{t('popup.imo')} : {ship.imo}
} {ship.status &&
{t('popup.status')} : {ship.status}
} {ship.length &&
{t('popup.length')} : {ship.length}m
} {ship.width &&
{t('popup.width')} : {ship.width}m
} {ship.draught &&
{t('popup.draught')} : {ship.draught}m
}
{t('popup.heading')} : {ship.heading.toFixed(1)}°
{t('popup.course')} : {ship.course.toFixed(1)}°
{t('popup.speed')} : {ship.speed.toFixed(1)} kn
{t('popup.lat')} : {formatCoord(ship.lat, 0).split(',')[0]}
{t('popup.lon')} : {formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}
{ship.destination &&
{t('popup.destination')} : {ship.destination}
} {ship.eta &&
{t('popup.eta')} : {new Date(ship.eta).toLocaleString()}
}
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
); });