kcg-monitoring/frontend/src/components/korea/AnalysisOverlay.tsx
htlee af02ad12ff feat: 불법어선 필터 시 수역 폴리곤 오버레이 + 선박 마커 가시성 개선
- WGS84 사전 변환 GeoJSON 생성 (런타임 변환 제거)
- FishingZoneLayer: 수역별 색상 fill/line + 이름 라벨
- AnalysisOverlay: 마커 크기 확대, 한글 라벨, 선박명 표시
- fishingAnalysis.ts: EPSG:3857 변환 로직 제거, WGS84 JSON 직접 사용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:05:35 +09:00

344 lines
11 KiB
TypeScript

import { useMemo } from 'react';
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { Ship, VesselAnalysisDto } from '../../types';
const RISK_COLORS: Record<string, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
MEDIUM: '#eab308',
LOW: '#22c55e',
};
const RISK_LABEL: Record<string, string> = {
CRITICAL: '긴급',
HIGH: '경고',
MEDIUM: '주의',
LOW: '정상',
};
const RISK_MARKER_SIZE: Record<string, number> = {
CRITICAL: 18,
HIGH: 14,
MEDIUM: 12,
};
const RISK_PRIORITY: Record<string, number> = {
CRITICAL: 0,
HIGH: 1,
MEDIUM: 2,
LOW: 3,
};
interface Props {
ships: Ship[];
analysisMap: Map<string, VesselAnalysisDto>;
clusters: Map<number, string[]>;
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<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)
const leaderShips = useMemo(() => {
if (activeFilter !== 'cnFishing') return [];
return analyzedShips.filter(({ dto }) => dto.algorithms.fleetRole.isLeader);
}, [analyzedShips, 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;
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 (
<Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
{/* 선박명 */}
{ship.name && (
<div style={{
fontSize: 9,
fontWeight: 700,
color: '#fff',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginBottom: 2,
}}>
{ship.name}
</div>
)}
{/* 삼각형 아이콘 */}
<div style={{
width: 0,
height: 0,
borderLeft: `${halfBase}px solid transparent`,
borderRight: `${halfBase}px solid transparent`,
borderBottom: `${triHeight}px solid ${color}`,
filter: `drop-shadow(0 0 3px ${color}88)`,
}} />
{/* 위험도 텍스트 (한글) */}
<div style={{
fontSize: 8,
fontWeight: 700,
color,
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginTop: 1,
}}>
{RISK_LABEL[level] ?? level}
</div>
</div>
</Marker>
);
})}
{/* CRITICAL 펄스 오버레이 */}
{riskMarkers
.filter(({ dto }) => dto.algorithms.riskScore.level === 'CRITICAL')
.map(({ ship }) => (
<Marker key={`pulse-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={riskPulseStyle('CRITICAL')} />
</Marker>
))}
{/* 다크베셀 마커 */}
{darkVesselMarkers.map(({ ship, dto }) => {
const gapMin = dto.algorithms.darkVessel.gapDurationMin;
return (
<Marker key={`dark-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
{/* 선박명 */}
{ship.name && (
<div style={{
fontSize: 9,
fontWeight: 700,
color: '#fff',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginBottom: 2,
}}>
{ship.name}
</div>
)}
{/* 보라 점선 원 */}
<div style={{
width: 16,
height: 16,
borderRadius: '50%',
border: '2px dashed #a855f7',
boxShadow: '0 0 4px #a855f788',
}} />
{/* gap 라벨: "AIS 소실 N분" */}
<div style={{
fontSize: 8,
fontWeight: 700,
color: '#a855f7',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginTop: 1,
}}>
{gapMin > 0 ? `AIS 소실 ${Math.round(gapMin)}` : 'DARK'}
</div>
</div>
</Marker>
);
})}
{/* GPS 스푸핑 배지 */}
{spoofingMarkers.map(({ ship, dto }) => {
const pct = Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100);
return (
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
{/* 선박명 */}
{ship.name && (
<div style={{
fontSize: 9,
fontWeight: 700,
color: '#fff',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginBottom: 2,
}}>
{ship.name}
</div>
)}
{/* 스푸핑 배지 */}
<div style={{
marginBottom: 14,
fontSize: 8,
fontWeight: 700,
color: '#fff',
backgroundColor: '#ef4444',
borderRadius: 2,
padding: '0 3px',
textShadow: 'none',
whiteSpace: 'nowrap',
}}>
{`GPS ${pct}%`}
</div>
</div>
</Marker>
);
})}
{/* 선단 leader 별 아이콘 */}
{leaderShips.map(({ ship }) => (
<Marker key={`leader-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div style={{
marginBottom: 6,
fontSize: 10,
color: '#f59e0b',
textShadow: '0 0 3px #000',
pointerEvents: 'none',
}}>
</div>
</Marker>
))}
</>
);
}