- 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>
164 lines
6.2 KiB
TypeScript
164 lines
6.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre';
|
|
import { KOREA_SUBMARINE_CABLES, KOREA_LANDING_POINTS } from '../../services/submarineCable';
|
|
import type { SubmarineCable } from '../../services/submarineCable';
|
|
|
|
export function SubmarineCableLayer() {
|
|
const [selectedCable, setSelectedCable] = useState<SubmarineCable | null>(null);
|
|
const [selectedPoint, setSelectedPoint] = useState<{ name: string; lat: number; lng: number; cables: string[] } | null>(null);
|
|
|
|
// 날짜변경선(180도) 보정: 연속 좌표가 180도를 넘으면 경도를 연속으로 만듦
|
|
// 예: [170, lat] → [-170, lat] 를 [170, lat] → [190, lat] 로 변환
|
|
function fixDateline(route: number[][]): number[][] {
|
|
const fixed: number[][] = [];
|
|
for (let i = 0; i < route.length; i++) {
|
|
const [lng, lat] = route[i];
|
|
if (i === 0) {
|
|
fixed.push([lng, lat]);
|
|
continue;
|
|
}
|
|
const prevLng = fixed[i - 1][0];
|
|
let newLng = lng;
|
|
// 이전 경도와 180도 이상 차이나면 보정
|
|
while (newLng - prevLng > 180) newLng -= 360;
|
|
while (prevLng - newLng > 180) newLng += 360;
|
|
fixed.push([newLng, lat]);
|
|
}
|
|
return fixed;
|
|
}
|
|
|
|
// Build GeoJSON for all cables
|
|
const geojson: GeoJSON.FeatureCollection = {
|
|
type: 'FeatureCollection',
|
|
features: KOREA_SUBMARINE_CABLES.map(cable => ({
|
|
type: 'Feature' as const,
|
|
properties: {
|
|
id: cable.id,
|
|
name: cable.name,
|
|
color: cable.color,
|
|
},
|
|
geometry: {
|
|
type: 'LineString' as const,
|
|
coordinates: fixDateline(cable.route),
|
|
},
|
|
})),
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Cable lines */}
|
|
<Source id="submarine-cables" type="geojson" data={geojson}>
|
|
<Layer
|
|
id="submarine-cables-outline"
|
|
type="line"
|
|
paint={{
|
|
'line-color': ['get', 'color'],
|
|
'line-width': 1.5,
|
|
'line-opacity': 0.25,
|
|
}}
|
|
/>
|
|
<Layer
|
|
id="submarine-cables-line"
|
|
type="line"
|
|
paint={{
|
|
'line-color': ['get', 'color'],
|
|
'line-width': 1,
|
|
'line-opacity': 0.6,
|
|
'line-dasharray': [4, 3],
|
|
}}
|
|
/>
|
|
</Source>
|
|
|
|
{/* Landing points */}
|
|
{KOREA_LANDING_POINTS.map(pt => (
|
|
<Marker key={pt.name} longitude={pt.lng} latitude={pt.lat} anchor="center"
|
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedPoint(pt); setSelectedCable(null); }}>
|
|
<div style={{
|
|
width: 8, height: 8, borderRadius: '50%',
|
|
background: '#00e5ff', border: '1.5px solid #fff',
|
|
boxShadow: '0 0 6px #00e5ff88',
|
|
cursor: 'pointer',
|
|
}} />
|
|
</Marker>
|
|
))}
|
|
|
|
{/* Cable name labels along route (midpoint) */}
|
|
{KOREA_SUBMARINE_CABLES.map(cable => {
|
|
const mid = cable.route[Math.floor(cable.route.length / 3)];
|
|
if (!mid) return null;
|
|
return (
|
|
<Marker key={`label-${cable.id}`} longitude={mid[0]} latitude={mid[1]} anchor="center"
|
|
onClick={(e) => { e.originalEvent.stopPropagation(); setSelectedCable(cable); setSelectedPoint(null); }}>
|
|
<div style={{
|
|
fontSize: 7, fontFamily: 'monospace', fontWeight: 600,
|
|
color: cable.color, cursor: 'pointer',
|
|
textShadow: '0 0 3px #000, 0 0 3px #000, 0 0 6px #000',
|
|
whiteSpace: 'nowrap', opacity: 0.8,
|
|
}}>
|
|
{cable.name}
|
|
</div>
|
|
</Marker>
|
|
);
|
|
})}
|
|
|
|
{/* Landing point popup */}
|
|
{selectedPoint && (
|
|
<Popup longitude={selectedPoint.lng} latitude={selectedPoint.lat}
|
|
onClose={() => setSelectedPoint(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="260px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 180 }}>
|
|
<div className="popup-header" style={{ background: '#00e5ff', color: '#000', gap: 6, padding: '4px 8px' }}>
|
|
<span>📡</span> {selectedPoint.name} 해저케이블 기지
|
|
</div>
|
|
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 4 }}>
|
|
연결 케이블: {selectedPoint.cables.length}개
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{selectedPoint.cables.map(cid => {
|
|
const c = KOREA_SUBMARINE_CABLES.find(cc => cc.id === cid);
|
|
if (!c) return null;
|
|
return (
|
|
<div key={cid} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
|
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: c.color, flexShrink: 0 }} />
|
|
<span style={{ color: '#ddd' }}>{c.name}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
|
|
{/* Cable info popup */}
|
|
{selectedCable && (
|
|
<Popup
|
|
longitude={selectedCable.route[0][0]}
|
|
latitude={selectedCable.route[0][1]}
|
|
onClose={() => setSelectedCable(null)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="280px" className="gl-popup">
|
|
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
|
<div className="popup-header" style={{ background: selectedCable.color, color: '#000', padding: '4px 8px' }}>
|
|
🔌 {selectedCable.name}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<div>
|
|
<span className="popup-label">경유지: </span>
|
|
<span style={{ color: '#ddd' }}>{selectedCable.landingPoints.join(' → ')}</span>
|
|
</div>
|
|
{selectedCable.rfsYear && (
|
|
<div><span className="popup-label">개통: </span>{selectedCable.rfsYear}년</div>
|
|
)}
|
|
{selectedCable.length && (
|
|
<div><span className="popup-label">총 길이: </span>{selectedCable.length}</div>
|
|
)}
|
|
{selectedCable.owners && (
|
|
<div><span className="popup-label">운영: </span>{selectedCable.owners}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</>
|
|
);
|
|
}
|