diff --git a/frontend/src/components/korea/AnalysisOverlay.tsx b/frontend/src/components/korea/AnalysisOverlay.tsx index a24b655..bf9a8c1 100644 --- a/frontend/src/components/korea/AnalysisOverlay.tsx +++ b/frontend/src/components/korea/AnalysisOverlay.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Marker, Source, Layer } from 'react-map-gl/maplibre'; +import { Marker } from 'react-map-gl/maplibre'; import type { Ship, VesselAnalysisDto } from '../../types'; const RISK_COLORS: Record = { @@ -56,7 +56,7 @@ function riskPulseStyle(riskLevel: string): React.CSSProperties { }; } -export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: Props) { +export function AnalysisOverlay({ ships, analysisMap, clusters: _clusters, activeFilter }: Props) { // analysisMap에 있는 선박만 대상 const analyzedShips: AnalyzedShip[] = useMemo(() => { return ships @@ -90,72 +90,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5); }, [analyzedShips]); - // 선단 연결선 GeoJSON (cnFishing 필터 ON일 때) - const clusterLineGeoJson = useMemo(() => { - if (activeFilter !== 'cnFishing') { - return { type: 'FeatureCollection' as const, features: [] }; - } - - const features: GeoJSON.Feature[] = []; - - for (const [clusterId, mmsiList] of clusters) { - if (mmsiList.length < 2) continue; - - // cluster 내 선박 위치 조회 - const clusterShips = mmsiList - .map(mmsi => { - const ship = ships.find(s => s.mmsi === mmsi); - return ship ?? null; - }) - .filter((s): s is Ship => s !== null); - - if (clusterShips.length < 2) continue; - - // leader 찾기 - const leaderMmsi = mmsiList.find(mmsi => { - const dto = analysisMap.get(mmsi); - return dto?.algorithms.fleetRole.isLeader === true; - }); - - // leader → 각 member 연결선 - if (leaderMmsi) { - const leaderShip = ships.find(s => s.mmsi === leaderMmsi); - if (leaderShip) { - for (const memberShip of clusterShips) { - if (memberShip.mmsi === leaderMmsi) continue; - features.push({ - type: 'Feature' as const, - properties: { clusterId, leaderMmsi, memberMmsi: memberShip.mmsi }, - geometry: { - type: 'LineString' as const, - coordinates: [ - [leaderShip.lng, leaderShip.lat], - [memberShip.lng, memberShip.lat], - ], - }, - }); - } - } - } else { - // leader 없으면 순차 연결 - for (let i = 0; i < clusterShips.length - 1; i++) { - features.push({ - type: 'Feature' as const, - properties: { clusterId, leaderMmsi: null, memberMmsi: clusterShips[i + 1].mmsi }, - geometry: { - type: 'LineString' as const, - coordinates: [ - [clusterShips[i].lng, clusterShips[i].lat], - [clusterShips[i + 1].lng, clusterShips[i + 1].lat], - ], - }, - }); - } - } - } - - return { type: 'FeatureCollection' as const, features }; - }, [activeFilter, clusters, ships, analysisMap]); + // 선단 연결선은 ShipLayer에서 선박 클릭 시 Python cluster_id 기반으로 표시 // leader 선박 목록 (cnFishing 필터 ON) const leaderShips = useMemo(() => { @@ -165,22 +100,6 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: return ( <> - {/* 선단 연결선 */} - {clusterLineGeoJson.features.length > 0 && ( - - - - )} - {/* 위험도 마커 */} {riskMarkers.map(({ ship, dto }) => { const level = dto.algorithms.riskScore.level; diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 61e7c16..2e66ec4 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -232,7 +232,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF /> - {layers.ships && } + {layers.ships && } {/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */} {koreaFilters.illegalFishing && (allShips ?? ships).filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index d99ddb1..10b0164 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -1,10 +1,8 @@ import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; -import type { Ship, ShipCategory } from '../../types'; +import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types'; import maplibregl from 'maplibre-gl'; -import { buildFleetGroups } from '../../utils/fleetDetection'; -import type { FleetGroup } from '../../utils/fleetDetection'; interface Props { ships: Ship[]; @@ -13,6 +11,7 @@ interface Props { hoveredMmsi?: string | null; focusMmsi?: string | null; onFocusClear?: () => void; + analysisMap?: Map; } // ── MarineTraffic-style vessel type colors (CSS variable references) ── @@ -362,7 +361,7 @@ function ensureTriangleImage(map: maplibregl.Map) { } // ── Main layer (WebGL symbol rendering — triangles) ── -export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear }: Props) { +export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusMmsi, onFocusClear, analysisMap }: Props) { const { current: map } = useMap(); const [selectedMmsi, setSelectedMmsi] = useState(null); const [imageReady, setImageReady] = useState(false); @@ -465,38 +464,48 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null; - // 선단 사전 그룹핑 (전체 선박 대상 — ships 변경 시에만 재계산) - const fleetData = useMemo(() => { - return buildFleetGroups(ships); - }, [ships]); + // Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑) + const selectedFleetMembers = useMemo(() => { + if (!selectedMmsi || !analysisMap) return []; + const dto = analysisMap.get(selectedMmsi); + if (!dto) return []; + const clusterId = dto.algorithms.cluster.clusterId; + if (clusterId < 0) return []; - // 선택한 선박의 소속 그룹 - 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]); + // 같은 cluster_id를 가진 모든 선박 + const members: { ship: Ship; role: string; roleKo: string }[] = []; + for (const [mmsi, d] of analysisMap) { + if (d.algorithms.cluster.clusterId !== clusterId) continue; + const ship = ships.find(s => s.mmsi === mmsi); + if (!ship) continue; + const isLeader = d.algorithms.fleetRole.isLeader; + members.push({ + ship, + role: d.algorithms.fleetRole.role, + roleKo: isLeader ? '본선' : '선단원', + }); + } + return members; + }, [selectedMmsi, analysisMap, ships]); - // 선단 연결선 GeoJSON — 그룹 멤버 중심 star topology + // 선단 연결선 GeoJSON — 선택 선박과 같은 cluster 멤버 연결 const fleetLineGeoJson = useMemo(() => { - if (!selectedFleetGroup) return { type: 'FeatureCollection' as const, features: [] }; - const { center, members } = selectedFleetGroup; + if (selectedFleetMembers.length < 2) return { type: 'FeatureCollection' as const, features: [] }; + // 중심점 계산 + const cLat = selectedFleetMembers.reduce((s, m) => s + m.ship.lat, 0) / selectedFleetMembers.length; + const cLng = selectedFleetMembers.reduce((s, m) => s + m.ship.lng, 0) / selectedFleetMembers.length; return { type: 'FeatureCollection' as const, - features: members.map(m => ({ + features: selectedFleetMembers.map(m => ({ type: 'Feature' as const, properties: { role: m.role }, geometry: { type: 'LineString' as const, - coordinates: [ - [center.lng, center.lat], - [m.ship.lng, m.ship.lat], - ], + coordinates: [[cLng, cLat], [m.ship.lng, m.ship.lat]], }, })), }; - }, [selectedFleetGroup]); + }, [selectedFleetMembers]); // Carrier labels — only a few, so DOM markers are fine const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]); @@ -601,8 +610,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM ))} - {/* Fleet connection lines — 선단 그룹 소속 선박 클릭 시 */} - {selectedFleetGroup && fleetLineGeoJson.features.length > 0 && ( + {/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */} + {selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && ( )} - {/* Fleet member markers — 선단 그룹 소속 선박 클릭 시 */} - {selectedFleetGroup && selectedFleetGroup.members.map(m => ( + {/* Fleet member markers — Python cluster 기반 */} + {selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
- {m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : m.role === 'mothership' ? 'M' : '●'} + {m.role === 'LEADER' ? 'L' : '●'}
))} - {/* TODO: 줌아웃 시 선단 중심 마커 (fleetData.groups 전체 순회) — 이후 구현 */} {/* Popup for selected ship */} {selectedShip && ( - setSelectedMmsi(null)} fleetGroup={selectedFleetGroup} /> + setSelectedMmsi(null)} fleetGroup={null} /> )} ); } -const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: FleetGroup | null }) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ShipPopup = memo(function ShipPopup({ ship, onClose, fleetGroup }: { ship: Ship; onClose: () => void; fleetGroup?: any }) { const { t } = useTranslation('ships'); const mtType = getMTType(ship); const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;