kcg-monitoring/frontend/src/components/layers/ShipLayer.tsx
htlee caaedfa5e2 feat: 어구/어망 마름모 아이콘 분리 + 리플레이 모선 색상 구분
- gear-diamond SDF 이미지 등록 (ShipLayer.tsx)
- 라이브/가상/히스토리 전 레이어에서 어구 패턴 → 마름모, 회전 없음
- 모선/선단 선박은 삼각형 유지 (isGear 속성 기반 분기)
- 어구 아이콘 크기 80% 축소 (baseSize 0.14→0.11, 히스토리 0.7→0.55)
- 리플레이 시 모선 아이콘/라벨 노란색(#fbbf24) 구분

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:33:10 +09:00

813 lines
31 KiB
TypeScript

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, VesselAnalysisDto } from '../../types';
import maplibregl from 'maplibre-gl';
import { MT_TYPE_COLORS, MT_TYPE_HEX, getMTType, NAVY_COLORS, FLAG_EMOJI, SIZE_MAP, isMilitary, getShipColor } from '../../utils/shipClassification';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { getNationalityGroup } from '../../hooks/useKoreaData';
import { useFontScale } from '../../hooks/useFontScale';
interface Props {
ships: Ship[];
militaryOnly: boolean;
koreanOnly?: boolean;
hoveredMmsi?: string | null;
focusMmsi?: string | null;
onFocusClear?: () => void;
analysisMap?: Map<string, VesselAnalysisDto>;
hiddenShipCategories?: Set<string>;
hiddenNationalities?: Set<string>;
}
function getShipHex(ship: Ship): string {
return MT_TYPE_HEX[getMTType(ship)] || MT_TYPE_HEX.unknown;
}
// ── Local Korean ship photos ──
const LOCAL_SHIP_PHOTOS: Record<string, string> = {
'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<string, VesselPhotoData | null>();
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<string, SpgImageInfo[] | null>();
async function fetchSpgImages(imo: string): Promise<SpgImageInfo[]> {
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<PhotoSource>(hasSPGlobal ? 'spglobal' : 'marinetraffic');
const [spgSlideIdx, setSpgSlideIdx] = useState(0);
const [spgErrors, setSpgErrors] = useState<Set<number>>(new Set());
const [spgImages, setSpgImages] = useState<SpgImageInfo[]>([]);
// 모달이 다른 선박으로 변경될 때 리셋 + 이미지 목록 조회
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<VesselPhotoData | null | undefined>(() => {
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 (
<div className="mb-1.5">
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30">
<img src={localUrl} alt="Vessel"
className="w-full h-full object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
);
}
const allSpgFailed = spgUrls.length > 0 && spgUrls.every((_, i) => spgErrors.has(i));
const noPhoto = (!hasSPGlobal || allSpgFailed) && mtPhoto === null;
return (
<div className="mb-1.5">
<div className="flex mb-1">
{hasSPGlobal && (
<div
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
activeTab === 'spglobal'
? 'border-[#1565c0] text-white bg-white/5'
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
}`}
onClick={() => setActiveTab('spglobal')}
>
S&P Global
</div>
)}
<div
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
activeTab === 'marinetraffic'
? 'border-[#1565c0] text-white bg-white/5'
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
}`}
onClick={() => setActiveTab('marinetraffic')}
>
MarineTraffic
</div>
</div>
{/* 고정 높이 사진 영역 */}
<div className="vessel-photo-frame w-full rounded overflow-hidden bg-black/30 relative">
{currentUrl ? (
<img
key={currentUrl}
src={currentUrl}
alt="Vessel"
className="w-full h-full object-contain"
onError={() => {
if (activeTab === 'spglobal') {
setSpgErrors(prev => new Set(prev).add(spgSlideIdx));
}
}}
/>
) : noPhoto ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</div>
) : activeTab === 'marinetraffic' && mtPhoto === undefined ? (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
Loading...
</div>
) : (
<div className="flex items-center justify-center h-full text-kcg-dim text-[10px]">
No photo available
</div>
)}
{/* S&P Global 슬라이드 네비게이션 */}
{activeTab === 'spglobal' && validSpgCount > 1 && (
<>
<button
type="button"
className="absolute left-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i - 1 + validSpgCount) % validSpgCount); }}
>
&lt;
</button>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 bg-black/50 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-black/70 transition-colors"
onClick={(e) => { e.stopPropagation(); setSpgSlideIdx(i => (i + 1) % validSpgCount); }}
>
&gt;
</button>
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1">
{spgUrls.map((_, i) => (
<span
key={i}
className={`w-1.5 h-1.5 rounded-full ${i === spgSlideIdx ? 'bg-white' : 'bg-white/40'}`}
/>
))}
</div>
</>
)}
</div>
</div>
);
}
// 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 });
// 어구/어망 마름모 아이콘
if (!map.hasImage('gear-diamond')) {
const dc = document.createElement('canvas');
dc.width = s;
dc.height = s;
const dx = dc.getContext('2d')!;
dx.beginPath();
dx.moveTo(s / 2, 4); // top
dx.lineTo(s - 4, s / 2); // right
dx.lineTo(s / 2, s - 4); // bottom
dx.lineTo(4, s / 2); // left
dx.closePath();
dx.fillStyle = '#ffffff';
dx.fill();
const dd = dx.getImageData(0, 0, s, s);
map.addImage('gear-diamond', { width: s, height: s, data: new Uint8Array(dd.data.buffer) }, { sdf: true });
}
}
// ── Main layer (WebGL symbol rendering — triangles) ──
export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap, hiddenShipCategories, hiddenNationalities }: Props) {
const { current: map } = useMap();
const { fontScale } = useFontScale();
const sfs = fontScale.ship;
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [imageReady, setImageReady] = useState(false);
const highlightKorean = !!koreanOnly;
const prevHoveredRef = useRef<string | null>(null);
// focusMmsi로 외부에서 모달 열기
useEffect(() => {
if (focusMmsi) {
setSelectedMmsi(focusMmsi);
onFocusClear?.();
}
}, [focusMmsi, onFocusClear]);
// 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 from ALL ships (category/nationality filtering is GPU-side via MapLibre filter)
const shipGeoJson = useMemo(() => {
const features: GeoJSON.Feature[] = ships.map(ship => ({
type: 'Feature' as const,
properties: {
mmsi: ship.mmsi,
name: ship.name,
color: getShipHex(ship),
size: (/^.+?_\d+_\d+_?$/.test(ship.name || '') ? 0.8 : 1) * (SIZE_MAP[ship.category] ?? 0.12),
isMil: isMilitary(ship.category) ? 1 : 0,
isKorean: ship.flag === 'KR' ? 1 : 0,
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
isGear: /^.+?_\d+_\d+_?$/.test(ship.name || '') ? 1 : 0,
heading: ship.heading,
mtCategory: getMarineTrafficCategory(ship.typecode, ship.category),
natGroup: getNationalityGroup(ship.flag),
},
geometry: {
type: 'Point' as const,
coordinates: [ship.lng, ship.lat],
},
}));
return { type: 'FeatureCollection' as const, features };
}, [ships]);
// MapLibre filter expression — GPU-side category/nationality/military filtering (no GeoJSON rebuild on toggle)
type FilterExpr = (string | number | string[] | FilterExpr)[];
const shipVisibilityFilter = useMemo((): FilterExpr => {
const conditions: FilterExpr[] = [];
if (militaryOnly) {
conditions.push(['==', ['get', 'isMil'], 1]);
}
if (hiddenShipCategories && hiddenShipCategories.size > 0) {
conditions.push(['!', ['in', ['get', 'mtCategory'], ['literal', [...hiddenShipCategories]]]]);
}
if (hiddenNationalities && hiddenNationalities.size > 0) {
conditions.push(['!', ['in', ['get', 'natGroup'], ['literal', [...hiddenNationalities]]]]);
}
if (conditions.length === 0) return ['has', 'mmsi'];
if (conditions.length === 1) return conditions[0];
return ['all', ...conditions];
}, [militaryOnly, hiddenShipCategories, hiddenNationalities]);
// 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 ? ships.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(() => ships.filter(s => s.category === 'carrier'), [ships]);
// 선단 역할별 색상
const FLEET_ROLE_COLORS: Record<string, string> = {
pair: '#ef4444',
carrier: '#f97316',
lighting: '#eab308',
mothership: '#dc2626',
subsidiary: '#6b7280',
};
if (!imageReady) return null;
return (
<>
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
{/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */}
<Layer
id="ships-hover-ring"
type="circle"
filter={shipVisibilityFilter}
paint={{
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
'circle-color': 'rgba(255, 255, 255, 0.1)',
'circle-stroke-color': '#ffffff',
'circle-stroke-width': ['case', ['boolean', ['feature-state', 'hovered'], false], 2, 0],
'circle-stroke-opacity': 0.9,
}}
/>
{/* Korean ship outer ring — enlarged when highlighted */}
<Layer
id="ships-korean-ring"
type="circle"
filter={['==', ['get', 'isKorean'], 1]}
paint={{
'circle-radius': highlightKorean ? ['*', ['get', 'size'], 22] : ['*', ['get', 'size'], 14],
'circle-color': highlightKorean ? 'rgba(0, 229, 255, 0.08)' : 'transparent',
'circle-stroke-color': '#00e5ff',
'circle-stroke-width': highlightKorean ? 2.5 : 1.5,
'circle-stroke-opacity': highlightKorean ? 1 : 0.6,
}}
/>
{/* Korean ship label — always mounted, visibility으로 제어 */}
<Layer
key={`ships-korean-label-${sfs}`}
id={`ships-korean-label-${sfs}`}
type="symbol"
filter={['==', ['get', 'isKorean'], 1]}
layout={{
'visibility': highlightKorean ? 'visible' : 'none',
'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 6*sfs, 6, 7*sfs, 8, 9*sfs, 10, 11*sfs, 12, 13*sfs, 13, 15*sfs, 14, 17*sfs],
'text-offset': [0, 2.2],
'text-anchor': 'top',
'text-allow-overlap': false,
'text-font': ['Open Sans Regular'],
}}
paint={{
'text-color': '#00e5ff',
'text-halo-color': 'rgba(0,0,0,0.8)',
'text-halo-width': 1,
}}
/>
{/* Main ship triangles */}
<Layer
id="ships-triangles"
type="symbol"
filter={shipVisibilityFilter}
layout={{
'icon-image': ['case', ['==', ['get', 'isGear'], 1], 'gear-diamond', 'ship-triangle'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'size'], 0.8],
6, ['*', ['get', 'size'], 1.0],
8, ['*', ['get', 'size'], 1.5],
10, ['*', ['get', 'size'], 2.2],
12, ['*', ['get', 'size'], 2.8],
13, ['*', ['get', 'size'], 3.5],
14, ['*', ['get', 'size'], 4.2],
],
'icon-rotate': ['case', ['==', ['get', 'isGear'], 1], 0, ['get', 'heading']],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
}}
paint={{
'icon-color': ['get', 'color'],
'icon-opacity': 0.9,
'icon-halo-color': ['case',
['==', ['get', 'isMil'], 1], '#ffffff',
'rgba(255,255,255,0.3)',
],
'icon-halo-width': ['case',
['==', ['get', 'isMil'], 1], 1,
0.3,
],
}}
/>
</Source>
{/* Carrier labels as DOM markers (very few) */}
{carriers.map(ship => (
<Marker key={`label-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ pointerEvents: 'none' }}>
<div className="gl-marker-label" style={{ color: getShipColor(ship) }}>
{ship.name}
</div>
</div>
</Marker>
))}
{/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */}
{selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && (
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
<Layer
id="fleet-line-layer"
type="line"
paint={{
'line-color': '#ef4444',
'line-width': 2,
'line-dasharray': [3, 2],
'line-opacity': 0.8,
}}
/>
</Source>
)}
{/* Fleet member markers — Python cluster 기반 */}
{selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
<div style={{
width: 24, height: 24, borderRadius: '50%',
border: `2px solid ${FLEET_ROLE_COLORS[m.role] || '#ef4444'}`,
background: 'rgba(0,0,0,0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 8, color: '#fff', fontWeight: 700,
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
}}>
{m.role === 'LEADER' ? 'L' : '●'}
</div>
<div style={{
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
textShadow: '0 0 3px #000', fontWeight: 700, marginTop: -2,
}}>
{m.roleKo}
</div>
</Marker>
))}
{/* Popup for selected ship */}
{selectedShip && (
<ShipPopup ship={selectedShip} onClose={() => 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<HTMLDivElement>(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 (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div ref={popupRef} className="ship-popup-body" onMouseDown={onMouseDown}>
{/* Header — draggable handle */}
<div
className="ship-popup-header"
style={{ background: isMil ? '#1a1a2e' : '#1565c0', cursor: 'grab' }}
>
{flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
<strong className="ship-popup-name">{ship.name}</strong>
{navyLabel && (
<span className="ship-popup-navy-badge" style={{ background: navyAccent || color }}>
{navyLabel}
</span>
)}
</div>
{/* Photo */}
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} shipImageCount={ship.shipImageCount} />
{/* Type tags */}
<div className="ship-popup-tags">
<span className="ship-tag ship-tag-primary" style={{ background: color }}>
{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}
</span>
<span className="ship-tag ship-tag-secondary">
{t(`categoryLabel.${ship.category}`)}
</span>
{ship.typeDesc && (
<span className="ship-tag ship-tag-dim">{ship.typeDesc}</span>
)}
</div>
{/* Data grid — paired rows */}
<div className="ship-popup-grid">
{/* Identity */}
<div className="ship-popup-row">
<span className="ship-popup-label">MMSI</span>
<span className="ship-popup-value">{ship.mmsi}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">IMO</span>
<span className="ship-popup-value">{ship.imo || '-'}</span>
</div>
{ship.callSign && (
<>
<div className="ship-popup-row">
<span className="ship-popup-label">{t('popup.callSign')}</span>
<span className="ship-popup-value">{ship.callSign}</span>
</div>
<div className="ship-popup-row" />
</>
)}
{/* Position — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">Lat</span>
<span className="ship-popup-value">{ship.lat.toFixed(4)}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Lon</span>
<span className="ship-popup-value">{ship.lng.toFixed(4)}</span>
</div>
{/* Navigation — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">HDG</span>
<span className="ship-popup-value">{ship.heading.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">COG</span>
<span className="ship-popup-value">{ship.course.toFixed(1)}&deg;</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">SOG</span>
<span className="ship-popup-value">{ship.speed.toFixed(1)} kn</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Draught</span>
<span className="ship-popup-value">{ship.draught ? `${ship.draught.toFixed(2)}m` : '-'}</span>
</div>
{/* Dimensions — paired */}
<div className="ship-popup-row">
<span className="ship-popup-label">Length</span>
<span className="ship-popup-value">{ship.length ? `${ship.length}m` : '-'}</span>
</div>
<div className="ship-popup-row">
<span className="ship-popup-label">Width</span>
<span className="ship-popup-value">{ship.width ? `${ship.width}m` : '-'}</span>
</div>
</div>
{/* Long-value fields — full width below grid */}
{ship.status && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Status</span>
<span className="ship-popup-value">{ship.status}</span>
</div>
)}
{ship.destination && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">Dest</span>
<span className="ship-popup-value">{ship.destination}</span>
</div>
)}
{ship.eta && (
<div className="ship-popup-full-row">
<span className="ship-popup-label">ETA</span>
<span className="ship-popup-value">{new Date(ship.eta).toLocaleString()}</span>
</div>
)}
{/* Fleet info (선단 그룹 소속 시) */}
{fleetGroup && fleetGroup.members.length > 0 && (
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
🔗 {fleetGroup.fleetTypeKo} {fleetGroup.members.length}
</div>
{fleetGroup.members.slice(0, 5).map(m => (
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
<span style={{ color: '#ef4444', fontWeight: 700, minWidth: 55 }}>{m.roleKo}</span>
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
</div>
))}
{fleetGroup.members.length > 5 && (
<div style={{ fontSize: 8, color: '#666' }}>... {fleetGroup.members.length - 5}</div>
)}
</div>
)}
{/* Footer */}
<div className="ship-popup-footer">
<span className="ship-popup-timestamp">
{t('popup.lastUpdate')}: {new Date(ship.lastSeen).toLocaleString()}
</span>
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank" rel="noopener noreferrer" className="ship-popup-link">
MarineTraffic &rarr;
</a>
</div>
</div>
</Popup>
);
});