diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 4d27400..d99ddb1 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -3,8 +3,8 @@ import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Ship, ShipCategory } from '../../types'; import maplibregl from 'maplibre-gl'; -import { detectFleet } from '../../utils/fleetDetection'; -import type { FleetConnection } from '../../utils/fleetDetection'; +import { buildFleetGroups } from '../../utils/fleetDetection'; +import type { FleetGroup } from '../../utils/fleetDetection'; interface Props { ships: Ship[]; @@ -465,35 +465,38 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null; - // 선단 탐지 (중국어선 선택 시 — 성능 최적화: 근처 선박만 전달) - const fleet: FleetConnection | null = useMemo(() => { - if (!selectedShip || selectedShip.flag !== 'CN') return null; - // 0.2도(~12NM) 이내 선박만 필터링하여 전달 - const nearby = ships.filter(s => - Math.abs(s.lat - selectedShip.lat) < 0.2 && - Math.abs(s.lng - selectedShip.lng) < 0.2 - ); - return detectFleet(selectedShip, nearby); - }, [selectedShip, ships]); + // 선단 사전 그룹핑 (전체 선박 대상 — ships 변경 시에만 재계산) + const fleetData = useMemo(() => { + return buildFleetGroups(ships); + }, [ships]); - // 선단 연결선 GeoJSON + // 선택한 선박의 소속 그룹 + const selectedFleetGroup: FleetGroup | null = useMemo(() => { + if (!selectedMmsi) return null; + const groupId = fleetData.memberMap.get(selectedMmsi); + if (groupId === undefined) return null; + return fleetData.groups.find(g => g.groupId === groupId) ?? null; + }, [selectedMmsi, fleetData]); + + // 선단 연결선 GeoJSON — 그룹 멤버 중심 star topology const fleetLineGeoJson = useMemo(() => { - if (!fleet) return { type: 'FeatureCollection' as const, features: [] }; + if (!selectedFleetGroup) return { type: 'FeatureCollection' as const, features: [] }; + const { center, members } = selectedFleetGroup; return { type: 'FeatureCollection' as const, - features: fleet.members.map(m => ({ + features: members.map(m => ({ type: 'Feature' as const, properties: { role: m.role }, geometry: { type: 'LineString' as const, coordinates: [ - [fleet.selectedShip.lng, fleet.selectedShip.lat], + [center.lng, center.lat], [m.ship.lng, m.ship.lat], ], }, })), }; - }, [fleet]); + }, [selectedFleetGroup]); // Carrier labels — only a few, so DOM markers are fine const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]); @@ -598,8 +601,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM ))} - {/* Fleet connection lines — 중국어선 클릭 시만 */} - {fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && ( + {/* Fleet connection lines — 선단 그룹 소속 선박 클릭 시 */} + {selectedFleetGroup && fleetLineGeoJson.features.length > 0 && ( )} - {/* Fleet member markers — 중국어선 클릭 시만 */} - {fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => ( + {/* Fleet member markers — 선단 그룹 소속 선박 클릭 시 */} + {selectedFleetGroup && selectedFleetGroup.members.map(m => (
- {m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : '●'} + {m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : m.role === 'mothership' ? 'M' : '●'}
- {m.roleKo} {m.distanceNm.toFixed(1)}NM + {m.roleKo}
))} + {/* TODO: 줌아웃 시 선단 중심 마커 (fleetData.groups 전체 순회) — 이후 구현 */} {/* Popup for selected ship */} {selectedShip && ( - setSelectedMmsi(null)} fleet={fleet} /> + setSelectedMmsi(null)} fleetGroup={selectedFleetGroup} /> )} ); } -const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship; onClose: () => void; fleet?: FleetConnection | null }) { +const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: FleetGroup | null }) { const { t } = useTranslation('ships'); const mtType = getMTType(ship); const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown; @@ -808,21 +812,20 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship )} - {/* Fleet info (중국어선만) */} - {fleet && fleet.members.length > 0 && ( + {/* Fleet info (선단 그룹 소속 시) */} + {fleetGroup && fleetGroup.members.length > 0 && (
- 🔗 {fleet.fleetTypeKo} — {fleet.members.length}척 연결 + 🔗 {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결
- {fleet.members.slice(0, 5).map(m => ( + {fleetGroup.members.slice(0, 5).map(m => (
{m.roleKo} {m.ship.name || m.ship.mmsi} - {m.distanceNm.toFixed(1)}NM
))} - {fleet.members.length > 5 && ( -
...외 {fleet.members.length - 5}척
+ {fleetGroup.members.length > 5 && ( +
...외 {fleetGroup.members.length - 5}척
)}
)} diff --git a/frontend/src/utils/fleetDetection.ts b/frontend/src/utils/fleetDetection.ts index fc2dc5a..ffb87dd 100644 --- a/frontend/src/utils/fleetDetection.ts +++ b/frontend/src/utils/fleetDetection.ts @@ -20,6 +20,22 @@ export interface FleetConnection { 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; // 지구 반경 (해리) @@ -29,8 +45,167 @@ function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number 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), 유사 방향