kcg-monitoring/frontend/src/components/ShipLayer.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

468 lines
18 KiB
TypeScript

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<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 = '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<PhotoSource>(defaultTab);
// MarineTraffic image state (lazy loaded)
const [mtPhoto, setMtPhoto] = useState<VesselPhotoData | null | undefined>(() => {
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 (
<div className="mb-1.5">
<img src={localUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
);
}
return (
<div className="mb-1.5">
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
{hasSignalBatch && (
<div
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
activeTab === 'signal-batch' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
}`}
onClick={() => setActiveTab('signal-batch')}
>
signal-batch
</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>
{currentUrl ? (
<img src={currentUrl} alt="Vessel"
className="w-full rounded block"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
activeTab === 'marinetraffic' && mtPhoto === undefined
? <div className="text-center p-2 text-kcg-dim text-[10px]">{t('popup.loading')}</div>
: null
)}
</div>
);
}
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<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] || '' : '';
return (
<Popup longitude={ship.lng} latitude={ship.lat}
onClose={onClose} closeOnClick={false}
anchor="bottom" maxWidth="340px" className="gl-popup">
<div className="min-w-[280px] max-w-[340px] font-mono text-xs">
<div
className="flex items-center gap-2 px-2.5 py-1.5 rounded-t text-white -mx-2.5 -mt-2.5 mb-2"
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0' }}
>
{flagEmoji && <span className="text-base">{flagEmoji}</span>}
<strong className="text-[13px] flex-1">{ship.name}</strong>
{navyLabel && (
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-black"
style={{ background: navyAccent || color }}
>{navyLabel}</span>
)}
</div>
<VesselPhoto mmsi={ship.mmsi} imo={ship.imo} shipImagePath={ship.shipImagePath} />
<div className="flex gap-1 mb-1.5 border-b border-kcg-border-light pb-1">
<span
className="px-1.5 py-px rounded text-[10px] font-bold text-white"
style={{ background: color }}
>{t(`mtTypeLabel.${mtType}`, { defaultValue: 'Unknown' })}</span>
<span className="px-1.5 py-px rounded text-[10px] bg-kcg-border text-kcg-text-secondary">
{t(`categoryLabel.${ship.category}`)}
</span>
{ship.typeDesc && (
<span className="text-kcg-dim text-[10px] leading-[18px]">{ship.typeDesc}</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<div>
<div><span className="text-kcg-muted">{t('popup.mmsi')} : </span>{ship.mmsi}</div>
{ship.callSign && <div><span className="text-kcg-muted">{t('popup.callSign')} : </span>{ship.callSign}</div>}
{ship.imo && <div><span className="text-kcg-muted">{t('popup.imo')} : </span>{ship.imo}</div>}
{ship.status && <div><span className="text-kcg-muted">{t('popup.status')} : </span>{ship.status}</div>}
{ship.length && <div><span className="text-kcg-muted">{t('popup.length')} : </span>{ship.length}m</div>}
{ship.width && <div><span className="text-kcg-muted">{t('popup.width')} : </span>{ship.width}m</div>}
{ship.draught && <div><span className="text-kcg-muted">{t('popup.draught')} : </span>{ship.draught}m</div>}
</div>
<div>
<div><span className="text-kcg-muted">{t('popup.heading')} : </span>{ship.heading.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.course')} : </span>{ship.course.toFixed(1)}&deg;</div>
<div><span className="text-kcg-muted">{t('popup.speed')} : </span>{ship.speed.toFixed(1)} kn</div>
<div><span className="text-kcg-muted">{t('popup.lat')} : </span>{formatCoord(ship.lat, 0).split(',')[0]}</div>
<div><span className="text-kcg-muted">{t('popup.lon')} : </span>{formatCoord(0, ship.lng).split(', ')[1] || ship.lng.toFixed(3)}</div>
{ship.destination && <div><span className="text-kcg-muted">{t('popup.destination')} : </span>{ship.destination}</div>}
{ship.eta && <div><span className="text-kcg-muted">{t('popup.eta')} : </span>{new Date(ship.eta).toLocaleString()}</div>}
</div>
</div>
<div className="mt-1.5 text-[9px] text-[#999] text-right">
{t('popup.lastUpdate')} : {new Date(ship.lastSeen).toLocaleString()}
</div>
<div className="mt-1 text-[10px] text-right">
<a href={`https://www.marinetraffic.com/en/ais/details/ships/mmsi:${ship.mmsi}`}
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
MarineTraffic &rarr;
</a>
</div>
</div>
</Popup>
);
});