- ShipLayer: IMO 기반 /signal-batch/api/v1/shipimg/{imo} API로 실제 이미지 목록 조회
- 각 이미지 path + _2.jpg(원본) 사용 (기존 잘못된 _1→_2→_3 번호 패턴 제거)
- IMO별 이미지 목록 캐시(spgImageCache) 적용
- LoginPage: KCG 로고 우측 하단에 DEMO 문구 오버레이
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
675 lines
24 KiB
TypeScript
675 lines
24 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, 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<string, string> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
US: '#1e90ff', UK: '#e63946', FR: '#ffd60a', KR: '#00e5ff',
|
|
IR: '#2ecc40', JP: '#ff6b6b', AU: '#f4a261', DE: '#b5b5b5', IN: '#ff9f43',
|
|
};
|
|
|
|
const FLAG_EMOJI: Record<string, string> = {
|
|
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<ShipCategory, number> = {
|
|
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<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 gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
|
|
{hasSPGlobal && (
|
|
<div
|
|
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
|
activeTab === 'spglobal' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
|
}`}
|
|
onClick={() => setActiveTab('spglobal')}
|
|
>
|
|
S&P Global
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
|
activeTab === 'marinetraffic' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
|
}`}
|
|
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); }}
|
|
>
|
|
<
|
|
</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); }}
|
|
>
|
|
>
|
|
</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 });
|
|
}
|
|
|
|
// ── Main layer (WebGL symbol rendering — triangles) ──
|
|
export function ShipLayer({ ships, militaryOnly, koreanOnly }: Props) {
|
|
const { current: map } = useMap();
|
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(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 (
|
|
<>
|
|
<Source id="ships-source" type="geojson" data={shipGeoJson}>
|
|
{/* Korean ship outer ring (circle behind triangle) */}
|
|
<Layer
|
|
id="ships-korean-ring"
|
|
type="circle"
|
|
filter={['==', ['get', 'isKorean'], 1]}
|
|
paint={{
|
|
'circle-radius': ['*', ['get', 'size'], 14],
|
|
'circle-color': 'transparent',
|
|
'circle-stroke-color': '#00e5ff',
|
|
'circle-stroke-width': 1.5,
|
|
'circle-stroke-opacity': 0.6,
|
|
}}
|
|
/>
|
|
{/* Main ship triangles */}
|
|
<Layer
|
|
id="ships-triangles"
|
|
type="symbol"
|
|
layout={{
|
|
'icon-image': 'ship-triangle',
|
|
'icon-size': ['get', 'size'],
|
|
'icon-rotate': ['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>
|
|
))}
|
|
|
|
{/* Popup for selected ship */}
|
|
{selectedShip && (
|
|
<ShipPopup ship={selectedShip} onClose={() => 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] || '' : '';
|
|
|
|
// 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 ? 'var(--kcg-card)' : '#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)}°</span>
|
|
</div>
|
|
<div className="ship-popup-row">
|
|
<span className="ship-popup-label">COG</span>
|
|
<span className="ship-popup-value">{ship.course.toFixed(1)}°</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>
|
|
)}
|
|
|
|
{/* 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 →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
});
|