kcg-monitoring/frontend/src/components/korea/ChineseFishingOverlay.tsx
htlee f0c991c9ec refactor: deck.gl 전면 전환 — DOM Marker → GPU 렌더링
- 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>
2026-03-20 21:11:56 +09:00

110 lines
3.3 KiB
TypeScript

import { useMemo } from 'react';
import { Source, Layer } from 'react-map-gl/maplibre';
import type { Ship, VesselAnalysisDto } from '../../types';
/**
* 어구/어망 이름에서 모선명 추출
* 패턴: "중국명칭숫자5자리_숫자_숫자" → 앞부분이 모선명
* 예: "鲁荣渔12345_1_2" → "鲁荣渔12345"
* "浙象渔05678_3_1" → "浙象渔05678"
* 또는 "이름%" → "이름" 부분이 모선명
*/
function extractParentName(gearName: string): string | null {
// 패턴1: 이름_숫자_숫자 또는 이름_숫자_
const m1 = gearName.match(/^(.+?)_\d+_\d*$/);
if (m1) return m1[1].trim();
const m2 = gearName.match(/^(.+?)_\d+_$/);
if (m2) return m2[1].trim();
// 패턴2: 이름%
if (gearName.endsWith('%')) return gearName.slice(0, -1).trim();
return null;
}
interface GearToParentLink {
gear: Ship;
parent: Ship;
parentName: string;
}
interface Props {
ships: Ship[];
analysisMap?: Map<string, VesselAnalysisDto>;
}
export function ChineseFishingOverlay({ ships, analysisMap: _analysisMap }: Props) {
// 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선)
const gearLinks: GearToParentLink[] = useMemo(() => {
const gearPattern = /^.+_\d+_\d*$|%$/;
const gearShips = ships.filter(s => gearPattern.test(s.name));
if (gearShips.length === 0) return [];
// 모선 후보
const nameMap = new Map<string, Ship>();
for (const s of ships) {
if (!gearPattern.test(s.name) && s.name) {
nameMap.set(s.name.trim(), s);
}
}
const MAX_DIST_DEG = 0.15; // ~10NM — 이 이상 떨어지면 연결 안 함
const MAX_LINKS = 200; // 브라우저 성능 보호
const links: GearToParentLink[] = [];
for (const gear of gearShips) {
if (links.length >= MAX_LINKS) break;
const parentName = extractParentName(gear.name);
if (!parentName || parentName.length < 3) continue; // 너무 짧은 이름 제외
// 정확 매칭만 (부분 매칭 제거 — 오탐 원인)
const parent = nameMap.get(parentName);
if (!parent) continue;
// 거리 제한: ~10NM 이내만 연결
const dlat = Math.abs(gear.lat - parent.lat);
const dlng = Math.abs(gear.lng - parent.lng);
if (dlat > MAX_DIST_DEG || dlng > MAX_DIST_DEG) continue;
links.push({ gear, parent, parentName });
}
return links;
}, [ships]);
// 어구-모선 연결선 GeoJSON
const gearLineGeoJson = useMemo(() => ({
type: 'FeatureCollection' as const,
features: gearLinks.map(link => ({
type: 'Feature' as const,
properties: { gearMmsi: link.gear.mmsi, parentMmsi: link.parent.mmsi },
geometry: {
type: 'LineString' as const,
coordinates: [
[link.gear.lng, link.gear.lat],
[link.parent.lng, link.parent.lat],
],
},
})),
}), [gearLinks]);
return (
<>
{/* 어구/어망 → 모선 연결선 */}
{gearLineGeoJson.features.length > 0 && (
<Source id="gear-parent-lines" type="geojson" data={gearLineGeoJson}>
<Layer
id="gear-parent-line-layer"
type="line"
paint={{
'line-color': '#f97316',
'line-width': 1.5,
'line-dasharray': [2, 2],
'line-opacity': 0.6,
}}
/>
</Source>
)}
</>
);
}