Merge pull request 'release: 선단 Python 전환 + 성능 복원' (#125) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m59s

This commit is contained in:
htlee 2026-03-20 17:28:26 +09:00
커밋 93ddb7d1b6
3개의 변경된 파일45개의 추가작업 그리고 117개의 파일을 삭제

파일 보기

@ -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;