- deck.gl 9.2 설치 + DeckGLOverlay(MapboxOverlay interleaved) 통합 - 정적 마커 11종 → useStaticDeckLayers (IconLayer/TextLayer, SVG DataURI) - 분석 오버레이 → useAnalysisDeckLayers (ScatterplotLayer/TextLayer) - 불법어선/어구/수역 라벨 → deck.gl ScatterplotLayer/TextLayer - 줌 레벨별 스케일 (0~6: 0.6x, 7~9: 1.0x, 10~12: 1.4x, 13+: 1.8x) - NK 미사일 궤적 PathLayer 추가 + 정적 마커 클릭 Popup - 해저케이블 날짜변경선(180도) 좌표 보정 - 기존 DOM Marker 제거로 렌더링 성능 대폭 개선 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
5.7 KiB
TypeScript
127 lines
5.7 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Popup, Source, Layer } from 'react-map-gl/maplibre';
|
|
import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents';
|
|
import type { NKMissileEvent } from '../../data/nkMissileEvents';
|
|
import type { Ship } from '../../types';
|
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
|
|
|
function getMissileColor(type: string): string {
|
|
if (type.includes('ICBM')) return '#dc2626';
|
|
if (type.includes('IRBM')) return '#ef4444';
|
|
if (type.includes('SLBM')) return '#3b82f6';
|
|
return '#f97316';
|
|
}
|
|
|
|
function distKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 6371;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
interface Props {
|
|
ships: Ship[];
|
|
selected: NKMissileEvent | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function NKMissileEventLayer({ ships, selected, onClose }: Props) {
|
|
const lineGeoJSON = useMemo(() => ({
|
|
type: 'FeatureCollection' as const,
|
|
features: NK_MISSILE_EVENTS.map(ev => ({
|
|
type: 'Feature' as const,
|
|
properties: { id: ev.id },
|
|
geometry: {
|
|
type: 'LineString' as const,
|
|
coordinates: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]],
|
|
},
|
|
})),
|
|
}), []);
|
|
|
|
const nearbyShips = useMemo(() => {
|
|
if (!selected) return [];
|
|
return ships.filter(s => distKm(s.lat, s.lng, selected.impactLat, selected.impactLng) < 50);
|
|
}, [selected, ships]);
|
|
|
|
return (
|
|
<>
|
|
{/* 궤적 라인 — MapLibre Source/Layer 유지 */}
|
|
<Source id="nk-missile-lines" type="geojson" data={lineGeoJSON}>
|
|
<Layer
|
|
id="nk-missile-line-layer"
|
|
type="line"
|
|
paint={{
|
|
'line-color': '#ef4444',
|
|
'line-width': 2,
|
|
'line-dasharray': [4, 3],
|
|
'line-opacity': 0.7,
|
|
}}
|
|
/>
|
|
</Source>
|
|
|
|
{/* 낙하 지점 팝업 */}
|
|
{selected && (() => {
|
|
const color = getMissileColor(selected.type);
|
|
return (
|
|
<Popup longitude={selected.impactLng} latitude={selected.impactLat}
|
|
onClose={onClose} closeOnClick={false}
|
|
anchor="bottom" maxWidth="340px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 260 }}>
|
|
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
|
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
|
<strong style={{ fontSize: 13 }}>🚀 {selected.typeKo}</strong>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
|
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
|
{selected.type}
|
|
</span>
|
|
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
|
{selected.date} {selected.time} KST
|
|
</span>
|
|
</div>
|
|
<div className="popup-grid" style={{ gap: '3px 12px', marginBottom: 6 }}>
|
|
<div><span className="popup-label">발사지 : </span><strong>{selected.launchNameKo}</strong></div>
|
|
<div><span className="popup-label">발사시각 : </span><strong>{selected.time} KST</strong></div>
|
|
<div><span className="popup-label">비행거리 : </span><strong>{selected.distanceKm.toLocaleString()} km</strong></div>
|
|
<div><span className="popup-label">최고고도 : </span><strong>{selected.altitudeKm.toLocaleString()} km</strong></div>
|
|
<div><span className="popup-label">비행시간 : </span><strong>{selected.flightMin}분</strong></div>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
|
{selected.note}
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#999', marginBottom: 4 }}>
|
|
낙하지점: {selected.impactLat.toFixed(2)}°N, {selected.impactLng.toFixed(2)}°E
|
|
</div>
|
|
|
|
{/* 인근 선박 */}
|
|
<div style={{ borderTop: '1px solid #333', paddingTop: 6, marginTop: 4 }}>
|
|
<div style={{ fontSize: 10, fontWeight: 700, color: nearbyShips.length > 0 ? '#f87171' : '#22c55e', marginBottom: 4 }}>
|
|
{nearbyShips.length > 0
|
|
? `⚠️ 낙하지점 50km 내 선박 ${nearbyShips.length}척`
|
|
: '✅ 낙하지점 50km 내 선박 없음'}
|
|
</div>
|
|
{nearbyShips.slice(0, 5).map(s => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
const d = distKm(s.lat, s.lng, selected.impactLat, selected.impactLng);
|
|
return (
|
|
<div key={s.mmsi} style={{ fontSize: 9, color: '#aaa', display: 'flex', gap: 4, padding: '1px 0' }}>
|
|
<span style={{ color: '#f87171' }}>●</span>
|
|
<span>{s.name || s.mmsi}</span>
|
|
<span style={{ color: '#888' }}>{cat}</span>
|
|
<span style={{ marginLeft: 'auto', color: '#f97316' }}>{d.toFixed(1)}km</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{nearbyShips.length > 5 && (
|
|
<div style={{ fontSize: 9, color: '#666' }}>...외 {nearbyShips.length - 5}척</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|