- CollectorMonitor: 29건 인라인 → CSS 클래스 (~3건 동적만 잔존) - 팝업 공통 CSS: .popup-header, .popup-body, .popup-grid, .popup-label 추출 - AirportLayer, DamagedShipLayer, InfraLayer, SubmarineCableLayer 적용 - LoginPage: var(--kcg-*) 인라인 → Tailwind 유틸리티 전환 (hover 포함) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
154 lines
6.2 KiB
TypeScript
154 lines
6.2 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`;
|
|
}
|
|
|
|
// 리플레이 시나리오 시간 범위 (T0 ~ T0+13일)
|
|
// 이 범위 밖의 currentTime이면 더미 데이터를 표시하지 않음 (LIVE 모드 대응)
|
|
const SCENARIO_START = new Date('2026-03-01T00:00:00Z').getTime();
|
|
const SCENARIO_END = new Date('2026-03-14T00:00:00Z').getTime();
|
|
|
|
export function DamagedShipLayer({ currentTime }: Props) {
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const isScenarioTime = currentTime >= SCENARIO_START && currentTime <= SCENARIO_END;
|
|
|
|
const visible = useMemo(
|
|
() => isScenarioTime ? damagedShips.filter(s => currentTime >= s.damagedAt) : [],
|
|
[currentTime, isScenarioTime],
|
|
);
|
|
|
|
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 className="popup-body" style={{ minWidth: 260 }}>
|
|
<div className="popup-header" style={{ background: DAMAGE_COLORS[selected.damage] }}>
|
|
{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 className="popup-grid">
|
|
<div><span className="popup-label">선종 : </span>{selected.type}</div>
|
|
<div><span className="popup-label">국적 : </span>{selected.flag}</div>
|
|
<div><span className="popup-label">원인 : </span>{selected.cause}</div>
|
|
<div><span className="popup-label">피격 : </span>{formatKST(selected.damagedAt)}</div>
|
|
</div>
|
|
<div className="popup-desc">
|
|
{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>
|
|
</>
|
|
);
|
|
}
|