fix: 선단 그룹핑을 Python cluster 결과로 전환 — 프론트 BFS 제거
- ShipLayer: buildFleetGroups() 제거 → Python analysisMap cluster_id 기반 - 선박 클릭 시 같은 cluster_id 멤버만 연결선 표시 - AnalysisOverlay: 보라색 100NM+ 클러스터 연결선 제거 - 프론트엔드 전체 순회 제거로 성능 복원 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
04d128b714
커밋
72f0dc4eba
@ -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<string, string> = {
|
||||
@ -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<GeoJSON.LineString>[] = [];
|
||||
|
||||
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 && (
|
||||
<Source id="analysis-cluster-lines" type="geojson" data={clusterLineGeoJson}>
|
||||
<Layer
|
||||
id="analysis-cluster-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#a855f7',
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [3, 2],
|
||||
'line-opacity': 0.7,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 위험도 마커 */}
|
||||
{riskMarkers.map(({ ship, dto }) => {
|
||||
const level = dto.algorithms.riskScore.level;
|
||||
|
||||
@ -232,7 +232,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} analysisMap={vesselAnalysis?.analysisMap} />}
|
||||
{/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */}
|
||||
{koreaFilters.illegalFishing && (allShips ?? ships).filter(s => {
|
||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
|
||||
@ -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<string, VesselAnalysisDto>;
|
||||
}
|
||||
|
||||
// ── 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<string | null>(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
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Fleet connection lines — 선단 그룹 소속 선박 클릭 시 */}
|
||||
{selectedFleetGroup && fleetLineGeoJson.features.length > 0 && (
|
||||
{/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */}
|
||||
{selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && (
|
||||
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
||||
<Layer
|
||||
id="fleet-line-layer"
|
||||
@ -617,8 +626,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Fleet member markers — 선단 그룹 소속 선박 클릭 시 */}
|
||||
{selectedFleetGroup && selectedFleetGroup.members.map(m => (
|
||||
{/* Fleet member markers — Python cluster 기반 */}
|
||||
{selectedFleetMembers.length > 1 && selectedFleetMembers.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%',
|
||||
@ -628,7 +637,7 @@ 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 === 'mothership' ? 'M' : '●'}
|
||||
{m.role === 'LEADER' ? 'L' : '●'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
||||
@ -638,17 +647,17 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* TODO: 줌아웃 시 선단 중심 마커 (fleetData.groups 전체 순회) — 이후 구현 */}
|
||||
|
||||
{/* Popup for selected ship */}
|
||||
{selectedShip && (
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleetGroup={selectedFleetGroup} />
|
||||
<ShipPopup ship={selectedShip} onClose={() => 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;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user