// ═══ 중국어선 선단(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; } { // 중국 어선 후보만 추출 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(candidates.map((_, i) => i)); const groups: FleetGroup[] = []; const memberMap = new Map(); 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((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, }; }