- WGS84 사전 변환 GeoJSON 생성 (런타임 변환 제거) - FishingZoneLayer: 수역별 색상 fill/line + 이름 라벨 - AnalysisOverlay: 마커 크기 확대, 한글 라벨, 선박명 표시 - fishingAnalysis.ts: EPSG:3857 변환 로직 제거, WGS84 JSON 직접 사용 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|