import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types'; import maplibregl from 'maplibre-gl'; interface Props { ships: Ship[]; militaryOnly: boolean; koreanOnly?: boolean; hoveredMmsi?: string | null; focusMmsi?: string | null; onFocusClear?: () => void; analysisMap?: Map; } // ── 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)', fishing_gear: '#f97316', 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', fishing_gear: '#f97316', 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, fishing: 0.14, 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 = 'spglobal' | 'marinetraffic'; interface VesselPhotoProps { mmsi: string; imo?: string; shipImagePath?: string | null; shipImageCount?: number; } /** * S&P Global 이미지 목록 API 응답 * GET /signal-batch/api/v1/shipimg/{imo} * path에 _1.jpg(썸네일) / _2.jpg(원본) 을 붙여서 사용 */ interface SpgImageInfo { picId: number; path: string; // e.g. "/shipimg/22738/2273823" copyright: string; date: string; } // IMO별 이미지 목록 캐시 const spgImageCache = new Map(); async function fetchSpgImages(imo: string): Promise { if (spgImageCache.has(imo)) return spgImageCache.get(imo) || []; try { const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`); if (!res.ok) throw new Error(`${res.status}`); const data: SpgImageInfo[] = await res.json(); spgImageCache.set(imo, data); return data; } catch { spgImageCache.set(imo, null); return []; } } function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) { const localUrl = LOCAL_SHIP_PHOTOS[mmsi]; const hasSPGlobal = !!shipImagePath; const [activeTab, setActiveTab] = useState(hasSPGlobal ? 'spglobal' : 'marinetraffic'); const [spgSlideIdx, setSpgSlideIdx] = useState(0); const [spgErrors, setSpgErrors] = useState>(new Set()); const [spgImages, setSpgImages] = useState([]); // 모달이 다른 선박으로 변경될 때 리셋 + 이미지 목록 조회 useEffect(() => { setActiveTab(hasSPGlobal ? 'spglobal' : 'marinetraffic'); setSpgSlideIdx(0); setSpgErrors(new Set()); setSpgImages([]); if (imo && hasSPGlobal) { fetchSpgImages(imo).then(setSpgImages); } else if (shipImagePath) { // IMO 없으면 shipImagePath 단일 이미지 사용 setSpgImages([{ picId: 0, path: shipImagePath.replace(/_[12]\.\w+$/, ''), copyright: '', date: '' }]); } }, [mmsi, imo, hasSPGlobal, shipImagePath]); // S&P Global slide URLs: 각 이미지의 path + _2.jpg (원본) const spgUrls = useMemo( () => spgImages.map(img => `${img.path}_2.jpg`), [spgImages], ); const validSpgCount = spgUrls.length; // MarineTraffic image state (lazy loaded) const [mtPhoto, setMtPhoto] = useState(() => { return vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined; }); useEffect(() => { setMtPhoto(vesselPhotoCache.has(mmsi) ? vesselPhotoCache.get(mmsi) : undefined); }, [mmsi]); 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 === 'spglobal' && spgUrls.length > 0 && !spgErrors.has(spgSlideIdx)) { currentUrl = spgUrls[spgSlideIdx]; } 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'; }} />
); } const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i)); const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null; return (
{hasSPGlobal && (
setActiveTab('spglobal')} > S&P Global
)}
setActiveTab('marinetraffic')} > MarineTraffic
{/* 고정 높이 사진 영역 */}
{currentUrl ? ( Vessel { if (activeTab === 'spglobal') { setSpgErrors(prev => new Set(prev).add(spgSlideIdx)); } }} /> ) : noPhoto ? (
No photo available
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
Loading...
) : (
No photo available
)} {/* S&P Global 슬라이드 네비게이션 */} {activeTab === 'spglobal' && validSpgCount > 1 && ( <>
{spgUrls.map((_, i) => ( ))}
)}
); } // 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, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) { const { current: map } = useMap(); const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); const highlightKorean = !!koreanOnly; const prevHoveredRef = useRef(null); // focusMmsi로 외부에서 모달 열기 useEffect(() => { if (focusMmsi) { setSelectedMmsi(focusMmsi); onFocusClear?.(); } }, [focusMmsi, onFocusClear]); const filtered = useMemo(() => { let result = ships; if (militaryOnly) result = result.filter(s => isMilitary(s.category)); return result; }, [ships, militaryOnly]); // 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, name: ship.name, color: getShipHex(ship), size: SIZE_MAP[ship.category] ?? 0.12, 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]); // hoveredMmsi 변경 시 feature-state로 hover 표시 (GeoJSON 재생성 없이) useEffect(() => { if (!map) return; const m = map.getMap(); if (!m.getSource('ships-source')) return; if (prevHoveredRef.current != null) { try { m.removeFeatureState({ source: 'ships-source', id: prevHoveredRef.current }); } catch { /* source not ready */ } } if (hoveredMmsi) { try { m.setFeatureState({ source: 'ships-source', id: hoveredMmsi }, { hovered: true }); } catch { /* source not ready */ } } prevHoveredRef.current = hoveredMmsi ?? null; }, [map, hoveredMmsi]); // 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; // Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑) const selectedFleetMembers = useMemo(() => { if (!selectedMmsi || !analysisMap) return []; const dto = analysisMap.get(selectedMmsi); if (!dto) return []; const clusterId = dto.algorithms.cluster.clusterId; if (clusterId < 0) return []; // 같은 cluster_id를 가진 모든 선박 const members: { ship: Ship; role: string; roleKo: string }[] = []; for (const [mmsi, d] of analysisMap) { if (d.algorithms.cluster.clusterId !== clusterId) continue; const ship = ships.find(s => s.mmsi === mmsi); if (!ship) continue; const isLeader = d.algorithms.fleetRole.isLeader; members.push({ ship, role: d.algorithms.fleetRole.role, roleKo: isLeader ? '본선' : '선단원', }); } return members; }, [selectedMmsi, analysisMap, ships]); // 선단 연결선 GeoJSON — 선택 선박과 같은 cluster 멤버 연결 const fleetLineGeoJson = useMemo(() => { if (selectedFleetMembers.length < 2) return { type: 'FeatureCollection' as const, features: [] }; // 중심점 계산 const cLat = selectedFleetMembers.reduce((s, m) => s + m.ship.lat, 0) / selectedFleetMembers.length; const cLng = selectedFleetMembers.reduce((s, m) => s + m.ship.lng, 0) / selectedFleetMembers.length; return { type: 'FeatureCollection' as const, features: selectedFleetMembers.map(m => ({ type: 'Feature' as const, properties: { role: m.role }, geometry: { type: 'LineString' as const, coordinates: [[cLng, cLat], [m.ship.lng, m.ship.lat]], }, })), }; }, [selectedFleetMembers]); // Carrier labels — only a few, so DOM markers are fine const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]); // 선단 역할별 색상 const FLEET_ROLE_COLORS: Record = { pair: '#ef4444', carrier: '#f97316', lighting: '#eab308', mothership: '#dc2626', subsidiary: '#6b7280', }; if (!imageReady) return null; return ( <> {/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */} {/* Korean ship outer ring — enlarged when highlighted */} {/* Korean ship label — always mounted, visibility으로 제어 */} {/* Main ship triangles */} {/* Carrier labels as DOM markers (very few) */} {carriers.map(ship => (
{ship.name}
))} {/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */} {selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && ( )} {/* Fleet member markers — Python cluster 기반 */} {selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
{m.role === 'LEADER' ? 'L' : '●'}
{m.roleKo}
))} {/* Popup for selected ship */} {selectedShip && ( setSelectedMmsi(null)} fleetGroup={null} /> )} ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: any }) { 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] || '' : ''; // Draggable popup const popupRef = useRef(null); const dragging = useRef(false); const dragOffset = useRef({ x: 0, y: 0 }); const onMouseDown = useCallback((e: React.MouseEvent) => { // Only drag from header area const target = e.target as HTMLElement; if (!target.closest('.ship-popup-header')) return; e.preventDefault(); dragging.current = true; const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null; if (!popupEl) return; const rect = popupEl.getBoundingClientRect(); dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; popupEl.style.transition = 'none'; }, []); useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const popupEl = popupRef.current?.closest('.maplibregl-popup') as HTMLElement | null; if (!popupEl) return; // Switch to fixed positioning for free drag popupEl.style.transform = 'none'; popupEl.style.position = 'fixed'; popupEl.style.left = `${e.clientX - dragOffset.current.x}px`; popupEl.style.top = `${e.clientY - dragOffset.current.y}px`; }; const onMouseUp = () => { dragging.current = false; }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); return () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; }, []); return (
{/* Header — draggable handle */}
{flagEmoji && {flagEmoji}} {ship.name} {navyLabel && ( {navyLabel} )}
{/* Photo */} {/* Type tags */}
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })} {t(`categoryLabel.${ship.category}`)} {ship.typeDesc && ( {ship.typeDesc} )}
{/* Data grid — paired rows */}
{/* Identity */}
MMSI {ship.mmsi}
IMO {ship.imo || '-'}
{ship.callSign && ( <>
{t('popup.callSign')} {ship.callSign}
)} {/* Position — paired */}
Lat {ship.lat.toFixed(4)}
Lon {ship.lng.toFixed(4)}
{/* Navigation — paired */}
HDG {ship.heading.toFixed(1)}°
COG {ship.course.toFixed(1)}°
SOG {ship.speed.toFixed(1)} kn
Draught {ship.draught ? `${ship.draught.toFixed(2)}m` : '-'}
{/* Dimensions — paired */}
Length {ship.length ? `${ship.length}m` : '-'}
Width {ship.width ? `${ship.width}m` : '-'}
{/* Long-value fields — full width below grid */} {ship.status && (
Status {ship.status}
)} {ship.destination && (
Dest {ship.destination}
)} {ship.eta && (
ETA {new Date(ship.eta).toLocaleString()}
)} {/* Fleet info (선단 그룹 소속 시) */} {fleetGroup && fleetGroup.members.length > 0 && (
🔗 {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결
{fleetGroup.members.slice(0, 5).map(m => (
{m.roleKo} {m.ship.name || m.ship.mmsi}
))} {fleetGroup.members.length > 5 && (
...외 {fleetGroup.members.length - 5}척
)}
)} {/* Footer */}
{t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()} MarineTraffic →
); });