kcg-monitoring/frontend/src/components/korea/ChineseFishingOverlay.tsx

268 lines
8.7 KiB
TypeScript

import { useMemo } from 'react';
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { Ship } from '../../types';
import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { FishingNetIcon, TrawlNetIcon, GillnetIcon, StowNetIcon, PurseSeineIcon } from '../icons/FishingNetIcon';
/** 어구 아이콘 컴포넌트 매핑 */
function GearIcon({ gear, size = 14 }: { gear: FishingGearType; size?: number }) {
const meta = GEAR_LABELS[gear];
const color = meta?.color || '#888';
switch (gear) {
case 'trawl_pair':
case 'trawl_single':
return <TrawlNetIcon color={color} size={size} />;
case 'gillnet':
return <GillnetIcon color={color} size={size} />;
case 'stow_net':
return <StowNetIcon color={color} size={size} />;
case 'purse_seine':
return <PurseSeineIcon color={color} size={size} />;
default:
return <FishingNetIcon color={color} size={size} />;
}
}
/** 선박 역할 추정 — 속도/크기/카테고리 기반 */
function estimateRole(ship: Ship): { role: string; roleKo: string; color: string } {
const mtCat = getMarineTrafficCategory(ship.typecode, ship.category);
const speed = ship.speed;
const len = ship.length || 0;
// 운반선: 화물선/대형/미분류 + 저속
if (mtCat === 'cargo' || (mtCat === 'unspecified' && len > 50)) {
return { role: 'FC', roleKo: '운반', color: '#f97316' };
}
// 어선 분류
if (mtCat === 'fishing' || ship.category === 'fishing') {
// 대형(>200톤급, 길이 40m+) → 본선
if (len >= 40) {
return { role: 'PT', roleKo: '본선', color: '#ef4444' };
}
// 소형(<30m) + 트롤 속도 → 부속선
if (len > 0 && len < 30 && speed >= 2 && speed <= 5) {
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
}
// 기본 어선
return { role: 'FV', roleKo: '어선', color: '#22c55e' };
}
return { role: '', roleKo: '', color: '#6b7280' };
}
/**
* 어구/어망 이름에서 모선명 추출
* 패턴: "중국명칭숫자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[];
}
export function ChineseFishingOverlay({ ships }: Props) {
// 중국 어선만 필터링
const chineseFishing = useMemo(() => {
return ships.filter(s => {
if (s.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'fishing' || s.category === 'fishing';
});
}, [ships]);
// 조업 분석 결과
const analyzed = useMemo(() => {
return chineseFishing.map(s => ({
ship: s,
analysis: analyzeFishing(s),
role: estimateRole(s),
}));
}, [chineseFishing]);
// 조업 중인 선박만 (어구 아이콘 표시용)
const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating), [analyzed]);
// 어구/어망 → 모선 연결 탐지
const gearLinks: GearToParentLink[] = useMemo(() => {
// 어구/어망 선박 (이름_숫자_ 또는 이름% 패턴)
const gearPattern = /^.+_\d+_\d*$|%$/;
const gearShips = ships.filter(s => gearPattern.test(s.name));
if (gearShips.length === 0) return [];
// 모선 후보 (모든 선박의 이름 → Ship 매핑)
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 links: GearToParentLink[] = [];
for (const gear of gearShips) {
const parentName = extractParentName(gear.name);
if (!parentName) continue;
// 정확히 일치하는 모선 찾기
let parent = nameMap.get(parentName);
// 정확 매칭 없으면 부분 매칭 (앞부분이 같은 선박)
if (!parent) {
for (const [name, ship] of nameMap) {
if (name.startsWith(parentName) || parentName.startsWith(name)) {
parent = ship;
break;
}
}
}
if (parent) {
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]);
// 운반선 추정 (중국 화물선 중 어선 근처)
const carriers = useMemo(() => {
return ships.filter(s => {
if (s.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(s.typecode, s.category);
if (cat !== 'cargo' && cat !== 'unspecified') return false;
// 어선 5NM 이내에 있는 화물선
return chineseFishing.some(f => {
const dlat = Math.abs(s.lat - f.lat);
const dlng = Math.abs(s.lng - f.lng);
return dlat < 0.08 && dlng < 0.08; // ~5NM 근사
});
}).slice(0, 50); // 최대 50척
}, [ships, chineseFishing]);
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>
)}
{/* 어구/어망 위치 마커 (모선 연결된 것) */}
{gearLinks.map(link => (
<Marker key={`gearlink-${link.gear.mmsi}`} longitude={link.gear.lng} latitude={link.gear.lat} anchor="center">
<div style={{ filter: 'drop-shadow(0 0 3px #f9731688)', pointerEvents: 'none' }}>
<FishingNetIcon color="#f97316" size={10} />
</div>
<div style={{
fontSize: 5, color: '#f97316', textAlign: 'center',
textShadow: '0 0 2px #000', fontWeight: 700, marginTop: -1,
whiteSpace: 'nowrap', pointerEvents: 'none',
}}>
{link.parentName}
</div>
</Marker>
))}
{/* 조업 중 어선 — 어구 아이콘 */}
{operating.map(({ ship, analysis }) => {
const meta = GEAR_LABELS[analysis.gearType];
return (
<Marker key={`gear-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div style={{
marginBottom: 8,
filter: `drop-shadow(0 0 3px ${meta?.color || '#f97316'}88)`,
opacity: 0.85,
pointerEvents: 'none',
}}>
<GearIcon gear={analysis.gearType} size={12} />
</div>
</Marker>
);
})}
{/* 본선/부속선/어선 역할 라벨 */}
{analyzed.filter(a => a.role.role).map(({ ship, role }) => (
<Marker key={`role-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="top">
<div style={{
marginTop: 6,
fontSize: 5,
fontWeight: 700,
color: role.color,
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}>
{role.roleKo}
</div>
</Marker>
))}
{/* 운반선 라벨 */}
{carriers.map(s => (
<Marker key={`carrier-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="top">
<div style={{
marginTop: 6,
fontSize: 5,
fontWeight: 700,
color: '#f97316',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}>
</div>
</Marker>
))}
</>
);
}