- buildFleetGroups(): BFS 3NM 클러스터링으로 전체 중국어선 사전 그룹핑 - mmsi → groupId 맵으로 어느 멤버를 눌러도 같은 그룹 표시 - 그룹별 유형 자동 판별 (trawl_pair/purse_seine/transship/cluster) - ShipLayer: detectFleet → buildFleetGroups 기반으로 전환 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
// ═══ 중국어선 선단(Fleet) 탐지 — GC-KCG-2026-001 기반 ═══
|
|
|
|
import type { Ship } from '../types';
|
|
import { getMarineTrafficCategory } from './marineTraffic';
|
|
|
|
export type FleetRole = 'mothership' | 'subsidiary' | 'carrier' | 'lighting' | 'pair';
|
|
|
|
export interface FleetMember {
|
|
ship: Ship;
|
|
role: FleetRole;
|
|
roleKo: string;
|
|
distanceNm: number;
|
|
reason: string;
|
|
}
|
|
|
|
export interface FleetConnection {
|
|
selectedShip: Ship;
|
|
members: FleetMember[];
|
|
fleetType: 'trawl_pair' | 'purse_seine_fleet' | 'transship' | 'unknown';
|
|
fleetTypeKo: string;
|
|
}
|
|
|
|
// ── 사전 그룹핑 인터페이스 ──
|
|
|
|
export interface FleetGroupMember {
|
|
ship: Ship;
|
|
role: FleetRole;
|
|
roleKo: string;
|
|
}
|
|
|
|
export interface FleetGroup {
|
|
groupId: number;
|
|
fleetType: 'trawl_pair' | 'purse_seine_fleet' | 'transship' | 'cluster';
|
|
fleetTypeKo: string;
|
|
members: FleetGroupMember[];
|
|
center: { lat: number; lng: number };
|
|
}
|
|
|
|
/** 두 지점 사이 거리(NM) */
|
|
function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
|
const R = 3440.065; // 지구 반경 (해리)
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
/** 선박이 중국 어선 후보인지 판별 */
|
|
function isCnFishingCandidate(ship: Ship): boolean {
|
|
if (ship.flag !== 'CN') return false;
|
|
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
|
return cat === 'fishing' || cat === 'unspecified';
|
|
}
|
|
|
|
/** 그룹 내 역할 결정 */
|
|
function assignRoles(members: Ship[]): FleetGroupMember[] {
|
|
if (members.length === 0) return [];
|
|
|
|
// 운반선 후보: 이름에 냉동운반선 관련 한자 포함 또는 cargo 카테고리
|
|
const isCarrierCandidate = (s: Ship): boolean => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
return cat === 'cargo' || s.name.includes('运') || s.name.includes('冷');
|
|
};
|
|
|
|
// 최대 속도 선박 → mothership
|
|
const maxSpeed = Math.max(...members.map(s => s.speed));
|
|
const motherIdx = members.findIndex(s => s.speed === maxSpeed);
|
|
|
|
return members.map((ship, idx) => {
|
|
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
|
|
|
if (isCarrierCandidate(ship) && ship.speed <= 2) {
|
|
return { ship, role: 'carrier' as FleetRole, roleKo: '운반선 (FC)' };
|
|
}
|
|
if (ship.speed < 1 && (cat === 'fishing' || cat === 'unspecified')) {
|
|
return { ship, role: 'lighting' as FleetRole, roleKo: '조명선' };
|
|
}
|
|
if (idx === motherIdx && members.length > 1) {
|
|
return { ship, role: 'mothership' as FleetRole, roleKo: '모선' };
|
|
}
|
|
return { ship, role: 'subsidiary' as FleetRole, roleKo: '선단 멤버' };
|
|
});
|
|
}
|
|
|
|
/** 그룹 유형 판별 */
|
|
function classifyGroup(members: Ship[]): { fleetType: FleetGroup['fleetType']; fleetTypeKo: string } {
|
|
if (members.length === 2) {
|
|
const [a, b] = members;
|
|
const speedDiff = Math.abs(a.speed - b.speed);
|
|
let headingDiff = Math.abs(a.heading - b.heading);
|
|
if (headingDiff > 180) headingDiff = 360 - headingDiff;
|
|
if (
|
|
speedDiff < 1 && headingDiff < 20 &&
|
|
a.speed >= 2 && a.speed <= 5 &&
|
|
b.speed >= 2 && b.speed <= 5
|
|
) {
|
|
return { fleetType: 'trawl_pair', fleetTypeKo: '2척식 저인망 (본선·부속선)' };
|
|
}
|
|
}
|
|
|
|
const hasCarrier = members.some(s => {
|
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
|
return (cat === 'cargo' || s.name.includes('运') || s.name.includes('冷')) && s.speed <= 2;
|
|
});
|
|
if (hasCarrier) {
|
|
return { fleetType: 'transship', fleetTypeKo: '환적 의심 (운반선 접근)' };
|
|
}
|
|
|
|
const hasLighting = members.some(s => s.speed < 1);
|
|
if (members.length >= 3 || hasLighting) {
|
|
return { fleetType: 'purse_seine_fleet', fleetTypeKo: '위망 선단 (모선·운반·조명)' };
|
|
}
|
|
|
|
return { fleetType: 'cluster', fleetTypeKo: '인근 어선 클러스터' };
|
|
}
|
|
|
|
/**
|
|
* 전체 중국어선을 사전 클러스터링하여 선단 그룹 생성.
|
|
* 어느 멤버를 눌러도 같은 그룹을 반환할 수 있도록 mmsi→groupId 맵도 함께 반환.
|
|
*
|
|
* 알고리즘: 3NM 이내 BFS flood fill (O(N²), N>1000 시 0.3도 박스 사전필터)
|
|
*/
|
|
export function buildFleetGroups(ships: Ship[]): {
|
|
groups: FleetGroup[];
|
|
memberMap: Map<string, number>;
|
|
} {
|
|
// 중국 어선 후보만 추출
|
|
const candidates = ships.filter(isCnFishingCandidate);
|
|
|
|
// N > 1000이면 0.3도 박스로 사전필터링하여 인접 목록 구성
|
|
const USE_BBOX_FILTER = candidates.length > 1000;
|
|
const CLUSTER_NM = 3;
|
|
const BBOX_DEG = 0.3; // ~18NM
|
|
|
|
// 미할당 인덱스 집합
|
|
const unassigned = new Set<number>(candidates.map((_, i) => i));
|
|
|
|
const groups: FleetGroup[] = [];
|
|
const memberMap = new Map<string, number>();
|
|
|
|
let groupId = 0;
|
|
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
if (!unassigned.has(i)) continue;
|
|
|
|
// BFS
|
|
const cluster: number[] = [];
|
|
const queue: number[] = [i];
|
|
unassigned.delete(i);
|
|
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift()!;
|
|
cluster.push(cur);
|
|
const curShip = candidates[cur];
|
|
|
|
// 탐색 후보: bbox 필터 또는 전체
|
|
const searchPool = USE_BBOX_FILTER
|
|
? candidates.reduce<number[]>((acc, s, idx) => {
|
|
if (
|
|
unassigned.has(idx) &&
|
|
Math.abs(s.lat - curShip.lat) < BBOX_DEG &&
|
|
Math.abs(s.lng - curShip.lng) < BBOX_DEG
|
|
) {
|
|
acc.push(idx);
|
|
}
|
|
return acc;
|
|
}, [])
|
|
: Array.from(unassigned);
|
|
|
|
for (const j of searchPool) {
|
|
if (!unassigned.has(j)) continue;
|
|
const neighbor = candidates[j];
|
|
if (distNm(curShip.lat, curShip.lng, neighbor.lat, neighbor.lng) <= CLUSTER_NM) {
|
|
unassigned.delete(j);
|
|
queue.push(j);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2척 미만은 그룹 해제
|
|
if (cluster.length < 2) continue;
|
|
|
|
const memberShips = cluster.map(idx => candidates[idx]);
|
|
const { fleetType, fleetTypeKo } = classifyGroup(memberShips);
|
|
const memberRoles = assignRoles(memberShips);
|
|
|
|
// 중심점 계산
|
|
const centerLat = memberShips.reduce((sum, s) => sum + s.lat, 0) / memberShips.length;
|
|
const centerLng = memberShips.reduce((sum, s) => sum + s.lng, 0) / memberShips.length;
|
|
|
|
const group: FleetGroup = {
|
|
groupId,
|
|
fleetType,
|
|
fleetTypeKo,
|
|
members: memberRoles,
|
|
center: { lat: centerLat, lng: centerLng },
|
|
};
|
|
|
|
groups.push(group);
|
|
memberShips.forEach(s => memberMap.set(s.mmsi, groupId));
|
|
groupId++;
|
|
}
|
|
|
|
return { groups, memberMap };
|
|
}
|
|
|
|
/**
|
|
* 선택한 중국어선 주변의 선단 구성을 탐지 (기존 1척 기준 탐지 — fallback용 유지)
|
|
*
|
|
* 보고서 기준:
|
|
* - PT 2척식 저인망: 본선+부속선 3NM 이내, 유사 속도(2~5kn), 유사 방향
|
|
* - PS 위망 선단: 3척+ 클러스터, 모선+운반선+조명선
|
|
* - FC 운반선 환적: 0.5NM 이내 접근, 양쪽 2kn 이하
|
|
*/
|
|
export function detectFleet(selectedShip: Ship, allShips: Ship[]): FleetConnection | null {
|
|
if (selectedShip.flag !== 'CN') return null;
|
|
|
|
const _mtCat = getMarineTrafficCategory(selectedShip.typecode, selectedShip.category);
|
|
const members: FleetMember[] = [];
|
|
|
|
// 주변 중국 선박 탐색 (10NM 반경)
|
|
const nearby = allShips.filter(s =>
|
|
s.mmsi !== selectedShip.mmsi &&
|
|
s.flag === 'CN' &&
|
|
distNm(selectedShip.lat, selectedShip.lng, s.lat, s.lng) < 10
|
|
);
|
|
|
|
for (const s of nearby) {
|
|
const d = distNm(selectedShip.lat, selectedShip.lng, s.lat, s.lng);
|
|
const sCat = getMarineTrafficCategory(s.typecode, s.category);
|
|
const speedDiff = Math.abs(selectedShip.speed - s.speed);
|
|
let headingDiff = Math.abs(selectedShip.heading - s.heading);
|
|
if (headingDiff > 180) headingDiff = 360 - headingDiff;
|
|
|
|
// === PT 본선-부속선 쌍 탐지 ===
|
|
// 3NM 이내 + 유사 속도(차이 1kn 미만) + 유사 방향(20° 미만) + 둘 다 2~5kn
|
|
if (d < 3 && speedDiff < 1 && headingDiff < 20 &&
|
|
selectedShip.speed >= 2 && selectedShip.speed <= 5 &&
|
|
s.speed >= 2 && s.speed <= 5) {
|
|
members.push({
|
|
ship: s,
|
|
role: 'pair',
|
|
roleKo: '부속선 (PT-S)',
|
|
distanceNm: d,
|
|
reason: `속도 ${s.speed.toFixed(1)}kn, 방향차 ${headingDiff.toFixed(0)}°, 거리 ${d.toFixed(1)}NM`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// === FC 운반선 환적 탐지 ===
|
|
// 0.5NM 이내 + 양쪽 2kn 이하
|
|
if (d < 0.5 && selectedShip.speed <= 2 && s.speed <= 2) {
|
|
const isCarrier = sCat === 'cargo' || sCat === 'unspecified' || s.name.includes('运') || s.name.includes('冷');
|
|
if (isCarrier) {
|
|
members.push({
|
|
ship: s,
|
|
role: 'carrier',
|
|
roleKo: '운반선 (FC)',
|
|
distanceNm: d,
|
|
reason: `환적 의심 — ${d.toFixed(2)}NM, 양쪽 저속`,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// === PS 선단 멤버 탐지 ===
|
|
// 2NM 이내 중국어선 클러스터
|
|
if (d < 2 && sCat === 'fishing') {
|
|
// 속도 차이로 역할 추정
|
|
if (s.speed < 1 && selectedShip.speed > 5) {
|
|
members.push({
|
|
ship: s,
|
|
role: 'lighting',
|
|
roleKo: '조명선',
|
|
distanceNm: d,
|
|
reason: `정지 중 — 집어등 추정`,
|
|
});
|
|
} else {
|
|
members.push({
|
|
ship: s,
|
|
role: 'subsidiary',
|
|
roleKo: '선단 멤버',
|
|
distanceNm: d,
|
|
reason: `${d.toFixed(1)}NM, ${s.speed.toFixed(1)}kn`,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// === 일반 근접 중국 선박 (5NM 이내) ===
|
|
if (d < 5 && (sCat === 'fishing' || sCat === 'unspecified')) {
|
|
members.push({
|
|
ship: s,
|
|
role: 'subsidiary',
|
|
roleKo: '인근 어선',
|
|
distanceNm: d,
|
|
reason: `${d.toFixed(1)}NM`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (members.length === 0) return null;
|
|
|
|
// 선단 유형 판별
|
|
const hasPair = members.some(m => m.role === 'pair');
|
|
const hasCarrier = members.some(m => m.role === 'carrier');
|
|
const hasLighting = members.some(m => m.role === 'lighting');
|
|
|
|
let fleetType: FleetConnection['fleetType'] = 'unknown';
|
|
let fleetTypeKo = '인근 선박 그룹';
|
|
|
|
if (hasPair) {
|
|
fleetType = 'trawl_pair';
|
|
fleetTypeKo = '2척식 저인망 (본선·부속선)';
|
|
} else if (hasCarrier) {
|
|
fleetType = 'transship';
|
|
fleetTypeKo = '환적 의심 (운반선 접근)';
|
|
} else if (hasLighting || members.length >= 3) {
|
|
fleetType = 'purse_seine_fleet';
|
|
fleetTypeKo = '위망 선단 (모선·운반·조명)';
|
|
}
|
|
|
|
// 거리순 정렬, 최대 10개
|
|
members.sort((a, b) => a.distanceNm - b.distanceNm);
|
|
|
|
return {
|
|
selectedShip,
|
|
members: members.slice(0, 10),
|
|
fleetType,
|
|
fleetTypeKo,
|
|
};
|
|
}
|