Merge pull request 'fix: 선단을 Python cluster로 전환 — BFS 제거 + 보라선 제거' (#124) from fix/fleet-grouping-from-python into develop
This commit is contained in:
커밋
16805a1cf0
@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
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';
|
import type { Ship, VesselAnalysisDto } from '../../types';
|
||||||
|
|
||||||
const RISK_COLORS: Record<string, string> = {
|
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에 있는 선박만 대상
|
// analysisMap에 있는 선박만 대상
|
||||||
const analyzedShips: AnalyzedShip[] = useMemo(() => {
|
const analyzedShips: AnalyzedShip[] = useMemo(() => {
|
||||||
return ships
|
return ships
|
||||||
@ -90,72 +90,7 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
|||||||
return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
return analyzedShips.filter(({ dto }) => dto.algorithms.gpsSpoofing.spoofingScore > 0.5);
|
||||||
}, [analyzedShips]);
|
}, [analyzedShips]);
|
||||||
|
|
||||||
// 선단 연결선 GeoJSON (cnFishing 필터 ON일 때)
|
// 선단 연결선은 ShipLayer에서 선박 클릭 시 Python cluster_id 기반으로 표시
|
||||||
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]);
|
|
||||||
|
|
||||||
// leader 선박 목록 (cnFishing 필터 ON)
|
// leader 선박 목록 (cnFishing 필터 ON)
|
||||||
const leaderShips = useMemo(() => {
|
const leaderShips = useMemo(() => {
|
||||||
@ -165,22 +100,6 @@ export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }:
|
|||||||
|
|
||||||
return (
|
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 }) => {
|
{riskMarkers.map(({ ship, dto }) => {
|
||||||
const level = dto.algorithms.riskScore.level;
|
const level = dto.algorithms.riskScore.level;
|
||||||
|
|||||||
@ -232,7 +232,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</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(라이브 위치) 기반 */}
|
{/* Illegal fishing vessel markers — allShips(라이브 위치) 기반 */}
|
||||||
{koreaFilters.illegalFishing && (allShips ?? ships).filter(s => {
|
{koreaFilters.illegalFishing && (allShips ?? ships).filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
import { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { Ship, ShipCategory } from '../../types';
|
import type { Ship, ShipCategory, VesselAnalysisDto } from '../../types';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import { buildFleetGroups } from '../../utils/fleetDetection';
|
|
||||||
import type { FleetGroup } from '../../utils/fleetDetection';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
@ -13,6 +11,7 @@ interface Props {
|
|||||||
hoveredMmsi?: string | null;
|
hoveredMmsi?: string | null;
|
||||||
focusMmsi?: string | null;
|
focusMmsi?: string | null;
|
||||||
onFocusClear?: () => void;
|
onFocusClear?: () => void;
|
||||||
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
// ── MarineTraffic-style vessel type colors (CSS variable references) ──
|
||||||
@ -362,7 +361,7 @@ function ensureTriangleImage(map: maplibregl.Map) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main layer (WebGL symbol rendering — triangles) ──
|
// ── 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 { current: map } = useMap();
|
||||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||||
const [imageReady, setImageReady] = useState(false);
|
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;
|
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||||
|
|
||||||
// 선단 사전 그룹핑 (전체 선박 대상 — ships 변경 시에만 재계산)
|
// Python 분석 결과 기반 선단 그룹 (cluster_id로 그룹핑)
|
||||||
const fleetData = useMemo(() => {
|
const selectedFleetMembers = useMemo(() => {
|
||||||
return buildFleetGroups(ships);
|
if (!selectedMmsi || !analysisMap) return [];
|
||||||
}, [ships]);
|
const dto = analysisMap.get(selectedMmsi);
|
||||||
|
if (!dto) return [];
|
||||||
|
const clusterId = dto.algorithms.cluster.clusterId;
|
||||||
|
if (clusterId < 0) return [];
|
||||||
|
|
||||||
// 선택한 선박의 소속 그룹
|
// 같은 cluster_id를 가진 모든 선박
|
||||||
const selectedFleetGroup: FleetGroup | null = useMemo(() => {
|
const members: { ship: Ship; role: string; roleKo: string }[] = [];
|
||||||
if (!selectedMmsi) return null;
|
for (const [mmsi, d] of analysisMap) {
|
||||||
const groupId = fleetData.memberMap.get(selectedMmsi);
|
if (d.algorithms.cluster.clusterId !== clusterId) continue;
|
||||||
if (groupId === undefined) return null;
|
const ship = ships.find(s => s.mmsi === mmsi);
|
||||||
return fleetData.groups.find(g => g.groupId === groupId) ?? null;
|
if (!ship) continue;
|
||||||
}, [selectedMmsi, fleetData]);
|
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(() => {
|
const fleetLineGeoJson = useMemo(() => {
|
||||||
if (!selectedFleetGroup) return { type: 'FeatureCollection' as const, features: [] };
|
if (selectedFleetMembers.length < 2) return { type: 'FeatureCollection' as const, features: [] };
|
||||||
const { center, members } = selectedFleetGroup;
|
// 중심점 계산
|
||||||
|
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 {
|
return {
|
||||||
type: 'FeatureCollection' as const,
|
type: 'FeatureCollection' as const,
|
||||||
features: members.map(m => ({
|
features: selectedFleetMembers.map(m => ({
|
||||||
type: 'Feature' as const,
|
type: 'Feature' as const,
|
||||||
properties: { role: m.role },
|
properties: { role: m.role },
|
||||||
geometry: {
|
geometry: {
|
||||||
type: 'LineString' as const,
|
type: 'LineString' as const,
|
||||||
coordinates: [
|
coordinates: [[cLng, cLat], [m.ship.lng, m.ship.lat]],
|
||||||
[center.lng, center.lat],
|
|
||||||
[m.ship.lng, m.ship.lat],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}, [selectedFleetGroup]);
|
}, [selectedFleetMembers]);
|
||||||
|
|
||||||
// Carrier labels — only a few, so DOM markers are fine
|
// Carrier labels — only a few, so DOM markers are fine
|
||||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
||||||
@ -601,8 +610,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Fleet connection lines — 선단 그룹 소속 선박 클릭 시 */}
|
{/* Fleet connection lines — Python cluster 기반, 선박 클릭 시 */}
|
||||||
{selectedFleetGroup && fleetLineGeoJson.features.length > 0 && (
|
{selectedFleetMembers.length > 1 && fleetLineGeoJson.features.length > 0 && (
|
||||||
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
||||||
<Layer
|
<Layer
|
||||||
id="fleet-line-layer"
|
id="fleet-line-layer"
|
||||||
@ -617,8 +626,8 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
</Source>
|
</Source>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fleet member markers — 선단 그룹 소속 선박 클릭 시 */}
|
{/* Fleet member markers — Python cluster 기반 */}
|
||||||
{selectedFleetGroup && selectedFleetGroup.members.map(m => (
|
{selectedFleetMembers.length > 1 && selectedFleetMembers.map(m => (
|
||||||
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
|
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 24, height: 24, borderRadius: '50%',
|
width: 24, height: 24, borderRadius: '50%',
|
||||||
@ -628,7 +637,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
fontSize: 8, color: '#fff', fontWeight: 700,
|
fontSize: 8, color: '#fff', fontWeight: 700,
|
||||||
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
|
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>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
||||||
@ -638,17 +647,17 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
|||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
{/* TODO: 줌아웃 시 선단 중심 마커 (fleetData.groups 전체 순회) — 이후 구현 */}
|
|
||||||
|
|
||||||
{/* Popup for selected ship */}
|
{/* Popup for selected ship */}
|
||||||
{selectedShip && (
|
{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 { t } = useTranslation('ships');
|
||||||
const mtType = getMTType(ship);
|
const mtType = getMTType(ship);
|
||||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user