kcg-monitoring/frontend/src/components/DamagedShipLayer.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

152 lines
6.1 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { damagedShips } from '../data/damagedShips';
import type { DamagedShip } from '../data/damagedShips';
interface Props {
currentTime: number;
}
const FLAG_EMOJI: Record<string, string> = {
GR: '\u{1F1EC}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', KR: '\u{1F1F0}\u{1F1F7}',
IR: '\u{1F1EE}\u{1F1F7}', US: '\u{1F1FA}\u{1F1F8}', UK: '\u{1F1EC}\u{1F1E7}',
PA: '\u{1F1F5}\u{1F1E6}', LR: '\u{1F1F1}\u{1F1F7}', CN: '\u{1F1E8}\u{1F1F3}',
};
const DAMAGE_COLORS: Record<DamagedShip['damage'], string> = {
sunk: '#ff0000',
severe: '#ef4444',
moderate: '#f97316',
minor: '#eab308',
};
const DAMAGE_LABELS: Record<DamagedShip['damage'], string> = {
sunk: '침몰',
severe: '중파',
moderate: '중손',
minor: '경미',
};
const KST_OFFSET = 9 * 3600_000;
function formatKST(ts: number): string {
const d = new Date(ts + KST_OFFSET);
return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} KST`;
}
export function DamagedShipLayer({ currentTime }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const visible = useMemo(
() => damagedShips.filter(s => currentTime >= s.damagedAt),
[currentTime],
);
const selected = selectedId ? visible.find(s => s.id === selectedId) ?? null : null;
return (
<>
{visible.map(ship => {
const color = DAMAGE_COLORS[ship.damage];
const isSunk = ship.damage === 'sunk';
const ageH = (currentTime - ship.damagedAt) / 3600_000;
const isRecent = ageH <= 24;
const size = isRecent ? 28 : 22;
const c = size / 2;
return (
<Marker key={ship.id} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div
style={{ cursor: 'pointer', position: 'relative' }}
onClick={(e) => { e.stopPropagation(); setSelectedId(ship.id); }}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* outer ring */}
<circle cx={c} cy={c} r={c - 2} fill="none" stroke={color}
strokeWidth={isRecent ? 2.5 : 1.5} opacity={isRecent ? 0.9 : 0.5}
strokeDasharray={isSunk ? 'none' : '3 2'}
/>
{/* ship icon (simplified) */}
<path
d={`M${c} ${c * 0.35} L${c * 1.45} ${c * 1.3} L${c * 1.25} ${c * 1.55} L${c * 0.75} ${c * 1.55} L${c * 0.55} ${c * 1.3} Z`}
fill={color} fillOpacity={isRecent ? 0.8 : 0.4}
/>
{/* X mark for damage */}
<line x1={c * 0.55} y1={c * 0.55} x2={c * 1.45} y2={c * 1.45}
stroke="#fff" strokeWidth={2} opacity={0.9} />
<line x1={c * 1.45} y1={c * 0.55} x2={c * 0.55} y2={c * 1.45}
stroke="#fff" strokeWidth={2} opacity={0.9} />
{/* inner X in color */}
<line x1={c * 0.6} y1={c * 0.6} x2={c * 1.4} y2={c * 1.4}
stroke={color} strokeWidth={1.2} />
<line x1={c * 1.4} y1={c * 0.6} x2={c * 0.6} y2={c * 1.4}
stroke={color} strokeWidth={1.2} />
</svg>
{/* label */}
<div style={{
position: 'absolute', top: size, left: '50%', transform: 'translateX(-50%)',
whiteSpace: 'nowrap', fontSize: 9, fontWeight: 700,
color, textShadow: '0 0 3px #000, 0 0 6px #000',
pointerEvents: 'none',
}}>
{isRecent && <span style={{
background: color, color: '#000', padding: '0 3px',
borderRadius: 2, marginRight: 3, fontSize: 8,
}}>NEW</span>}
{ship.name}
</div>
{/* pulse for recent */}
{isRecent && (
<div style={{
position: 'absolute', top: 0, left: 0, width: size, height: size,
borderRadius: '50%', border: `2px solid ${color}`,
animation: 'damaged-ship-pulse 2s ease-out infinite',
pointerEvents: 'none',
}} />
)}
</div>
</Marker>
);
})}
{selected && (
<Popup longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelectedId(null)} closeOnClick={false}
anchor="bottom" maxWidth="320px" className="gl-popup">
<div style={{ minWidth: 260, fontFamily: 'monospace', fontSize: 12 }}>
<div style={{
background: DAMAGE_COLORS[selected.damage], color: '#fff',
padding: '6px 10px', borderRadius: '4px 4px 0 0',
margin: '-10px -10px 8px -10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{FLAG_EMOJI[selected.flag] && <span style={{ fontSize: 16 }}>{FLAG_EMOJI[selected.flag]}</span>}
<strong style={{ flex: 1 }}>{selected.name}</strong>
<span style={{
background: 'rgba(0,0,0,0.3)', padding: '1px 6px',
borderRadius: 3, fontSize: 10,
}}>{DAMAGE_LABELS[selected.damage]}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '3px 12px', fontSize: 11 }}>
<div><span style={{ color: '#888' }}> : </span>{selected.type}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.flag}</div>
<div><span style={{ color: '#888' }}> : </span>{selected.cause}</div>
<div><span style={{ color: '#888' }}> : </span>{formatKST(selected.damagedAt)}</div>
</div>
<div style={{ marginTop: 6, fontSize: 11, color: '#ccc', lineHeight: 1.4 }}>
{selected.description}
</div>
</div>
</Popup>
)}
<style>{`
@keyframes damaged-ship-pulse {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(2.5); opacity: 0; }
}
`}</style>
</>
);
}