268 lines
8.7 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|