import { useMemo } from 'react'; import { Marker, Source, Layer } from 'react-map-gl/maplibre'; import type { Ship, VesselAnalysisDto } from '../../types'; const RISK_COLORS: Record = { CRITICAL: '#ef4444', HIGH: '#f97316', MEDIUM: '#eab308', LOW: '#22c55e', }; const RISK_LABEL: Record = { CRITICAL: '긴급', HIGH: '경고', MEDIUM: '주의', LOW: '정상', }; const RISK_MARKER_SIZE: Record = { CRITICAL: 18, HIGH: 14, MEDIUM: 12, }; const RISK_PRIORITY: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, }; interface Props { ships: Ship[]; analysisMap: Map; clusters: Map; activeFilter: string | null; } interface AnalyzedShip { ship: Ship; dto: VesselAnalysisDto; } /** 위험도 펄스 애니메이션 인라인 스타일 */ function riskPulseStyle(riskLevel: string): React.CSSProperties { const color = RISK_COLORS[riskLevel] ?? RISK_COLORS['LOW']; const size = RISK_MARKER_SIZE[riskLevel] ?? 10; return { width: size, height: size, borderRadius: '50%', backgroundColor: color, boxShadow: `0 0 6px 2px ${color}88`, animation: riskLevel === 'CRITICAL' ? 'pulse 1s infinite' : undefined, pointerEvents: 'none', }; } export function AnalysisOverlay({ ships, analysisMap, clusters, activeFilter }: Props) { // analysisMap에 있는 선박만 대상 const analyzedShips: AnalyzedShip[] = useMemo(() => { return ships .filter(s => analysisMap.has(s.mmsi)) .map(s => ({ ship: s, dto: analysisMap.get(s.mmsi)! })); }, [ships, analysisMap]); // 위험도 마커 — CRITICAL/HIGH 우선 최대 100개 const riskMarkers = useMemo(() => { return analyzedShips .filter(({ dto }) => { const level = dto.algorithms.riskScore.level; return level === 'CRITICAL' || level === 'HIGH' || level === 'MEDIUM'; }) .sort((a, b) => { const pa = RISK_PRIORITY[a.dto.algorithms.riskScore.level] ?? 99; const pb = RISK_PRIORITY[b.dto.algorithms.riskScore.level] ?? 99; return pa - pb; }) .slice(0, 100); }, [analyzedShips]); // 다크베셀 마커 const darkVesselMarkers = useMemo(() => { if (activeFilter !== 'darkVessel') return []; return analyzedShips.filter(({ dto }) => dto.algorithms.darkVessel.isDark); }, [analyzedShips, activeFilter]); // GPS 스푸핑 마커 const spoofingMarkers = useMemo(() => { 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]); // leader 선박 목록 (cnFishing 필터 ON) const leaderShips = useMemo(() => { if (activeFilter !== 'cnFishing') return []; return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader); }, [analyzedShips, activeFilter]); return ( <> {/* 선단 연결선 */} {clusterLineGeoJson.features.length > 0 && ( )} {/* 위험도 마커 */} {riskMarkers.map(({ ship, dto }) => { const level = dto.algorithms.riskScore.level; const color = RISK_COLORS[level] ?? RISK_COLORS['LOW']; const size = RISK_MARKER_SIZE[level] ?? 12; const halfBase = Math.round(size * 0.5); const triHeight = Math.round(size * 0.9); return (
{/* 선박명 */} {ship.name && (
{ship.name}
)} {/* 삼각형 아이콘 */}
{/* 위험도 텍스트 (한글) */}
{RISK_LABEL[level] ?? level}
); })} {/* CRITICAL 펄스 오버레이 */} {riskMarkers .filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL') .map(({ ship }) => (
))} {/* 다크베셀 마커 */} {darkVesselMarkers.map(({ ship, dto }) => { const gapMin = dto.algorithms.darkVessel.gapDurationMin; return (
{/* 선박명 */} {ship.name && (
{ship.name}
)} {/* 보라 점선 원 */}
{/* gap 라벨: "AIS 소실 N분" */}
{gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}분` : 'DARK'}
); })} {/* GPS 스푸핑 배지 */} {spoofingMarkers.map(({ ship, dto }) => { const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100); return (
{/* 선박명 */} {ship.name && (
{ship.name}
)} {/* 스푸핑 배지 */}
{`GPS ${pct}%`}
); })} {/* 선단 leader 별 아이콘 */} {leaderShips.map(({ ship }) => (
))} ); }