Merge pull request 'feat: 선단 사전 그룹핑 + 동일 그룹 보장' (#122) from fix/score-display-and-fixes into develop
This commit is contained in:
커밋
04d128b714
@ -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
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Fleet connection lines — 중국어선 클릭 시만 */}
|
||||
{fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && (
|
||||
{/* Fleet connection lines — 선단 그룹 소속 선박 클릭 시 */}
|
||||
{selectedFleetGroup && fleetLineGeoJson.features.length > 0 && (
|
||||
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
||||
<Layer
|
||||
id="fleet-line-layer"
|
||||
@ -614,8 +617,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Fleet member markers — 중국어선 클릭 시만 */}
|
||||
{fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => (
|
||||
{/* Fleet member markers — 선단 그룹 소속 선박 클릭 시 */}
|
||||
{selectedFleetGroup && selectedFleetGroup.members.map(m => (
|
||||
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
@ -625,26 +628,27 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
fontSize: 8, color: '#fff', fontWeight: 700,
|
||||
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
|
||||
}}>
|
||||
{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' : '●'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
||||
textShadow: '0 0 3px #000', fontWeight: 700, marginTop: -2,
|
||||
}}>
|
||||
{m.roleKo} {m.distanceNm.toFixed(1)}NM
|
||||
{m.roleKo}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* TODO: 줌아웃 시 선단 중심 마커 (fleetData.groups 전체 순회) — 이후 구현 */}
|
||||
|
||||
{/* Popup for selected ship */}
|
||||
{selectedShip && (
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleet={fleet} />
|
||||
<ShipPopup ship={selectedShip} onClose={() => 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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet info (중국어선만) */}
|
||||
{fleet && fleet.members.length > 0 && (
|
||||
{/* Fleet info (선단 그룹 소속 시) */}
|
||||
{fleetGroup && fleetGroup.members.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
|
||||
🔗 {fleet.fleetTypeKo} — {fleet.members.length}척 연결
|
||||
🔗 {fleetGroup.fleetTypeKo} — {fleetGroup.members.length}척 연결
|
||||
</div>
|
||||
{fleet.members.slice(0, 5).map(m => (
|
||||
{fleetGroup.members.slice(0, 5).map(m => (
|
||||
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
|
||||
<span style={{ color: '#ef4444', fontWeight: 700, minWidth: 55 }}>{m.roleKo}</span>
|
||||
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
|
||||
<span style={{ color: '#f97316' }}>{m.distanceNm.toFixed(1)}NM</span>
|
||||
</div>
|
||||
))}
|
||||
{fleet.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleet.members.length - 5}척</div>
|
||||
{fleetGroup.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleetGroup.members.length - 5}척</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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<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), 유사 방향
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user