- 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>
110 lines
3.3 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|