feat: Python 분석 결과 오버레이 + 메뉴 연동 — Backend API 복원 + DB 테이블 + 통계패널 + 위험도 마커

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-20 13:28:50 +09:00
부모 2a2b5fb111
커밋 e82b2d77e7
15개의 변경된 파일1056개의 추가작업 그리고 10개의 파일을 삭제

파일 보기

@ -0,0 +1,35 @@
package gc.mda.kcg.domain.analysis;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisController {
private final VesselAnalysisService vesselAnalysisService;
/**
* 최근 선박 분석 결과 조회
* @param region 지역 필터 (향후 확장용, 현재 미사용)
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getVesselAnalysis(
@RequestParam(required = false) String region) {
List<VesselAnalysisDto> results = vesselAnalysisService.getLatestResults();
return ResponseEntity.ok(Map.of(
"count", results.size(),
"items", results
));
}
}

파일 보기

@ -0,0 +1,148 @@
package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class VesselAnalysisDto {
private String mmsi;
private String timestamp;
private Classification classification;
private Algorithms algorithms;
private Map<String, Double> features;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Classification {
private String vesselType;
private Double confidence;
private Double fishingPct;
private Integer clusterId;
private String season;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Algorithms {
private LocationInfo location;
private ActivityInfo activity;
private DarkVesselInfo darkVessel;
private GpsSpoofingInfo gpsSpoofing;
private ClusterInfo cluster;
private FleetRoleInfo fleetRole;
private RiskScoreInfo riskScore;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class LocationInfo {
private String zone;
private Double distToBaselineNm;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ActivityInfo {
private String state;
private Double ucafScore;
private Double ucftScore;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class DarkVesselInfo {
private Boolean isDark;
private Integer gapDurationMin;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class GpsSpoofingInfo {
private Double spoofingScore;
private Double bd09OffsetM;
private Integer speedJumpCount;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ClusterInfo {
private Integer clusterId;
private Integer clusterSize;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class FleetRoleInfo {
private Boolean isLeader;
private String role;
}
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class RiskScoreInfo {
private Integer score;
private String level;
}
public static VesselAnalysisDto from(VesselAnalysisResult r) {
return VesselAnalysisDto.builder()
.mmsi(r.getMmsi())
.timestamp(r.getTimestamp().toString())
.classification(Classification.builder()
.vesselType(r.getVesselType())
.confidence(r.getConfidence())
.fishingPct(r.getFishingPct())
.clusterId(r.getClusterId())
.season(r.getSeason())
.build())
.algorithms(Algorithms.builder()
.location(LocationInfo.builder()
.zone(r.getZone())
.distToBaselineNm(r.getDistToBaselineNm())
.build())
.activity(ActivityInfo.builder()
.state(r.getActivityState())
.ucafScore(r.getUcafScore())
.ucftScore(r.getUcftScore())
.build())
.darkVessel(DarkVesselInfo.builder()
.isDark(r.getIsDark())
.gapDurationMin(r.getGapDurationMin())
.build())
.gpsSpoofing(GpsSpoofingInfo.builder()
.spoofingScore(r.getSpoofingScore())
.bd09OffsetM(r.getBd09OffsetM())
.speedJumpCount(r.getSpeedJumpCount())
.build())
.cluster(ClusterInfo.builder()
.clusterId(r.getClusterId())
.clusterSize(r.getClusterSize())
.build())
.fleetRole(FleetRoleInfo.builder()
.isLeader(r.getIsLeader())
.role(r.getFleetRole())
.build())
.riskScore(RiskScoreInfo.builder()
.score(r.getRiskScore())
.level(r.getRiskLevel())
.build())
.build())
.features(r.getFeatures())
.build();
}
}

파일 보기

@ -0,0 +1,97 @@
package gc.mda.kcg.domain.analysis;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
@Entity
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VesselAnalysisResult {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 15)
private String mmsi;
@Column(nullable = false)
private Instant timestamp;
@Column(length = 20)
private String vesselType;
private Double confidence;
private Double fishingPct;
private Integer clusterId;
@Column(length = 10)
private String season;
@Column(length = 20)
private String zone;
private Double distToBaselineNm;
@Column(length = 20)
private String activityState;
private Double ucafScore;
private Double ucftScore;
@Column(nullable = false)
private Boolean isDark;
private Integer gapDurationMin;
private Double spoofingScore;
private Double bd09OffsetM;
private Integer speedJumpCount;
private Integer clusterSize;
@Column(nullable = false)
private Boolean isLeader;
@Column(length = 20)
private String fleetRole;
private Integer riskScore;
@Column(length = 20)
private String riskLevel;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Double> features;
@Column(nullable = false)
private Instant analyzedAt;
@PrePersist
protected void onCreate() {
if (analyzedAt == null) {
analyzedAt = Instant.now();
}
if (isDark == null) {
isDark = false;
}
if (isLeader == null) {
isLeader = false;
}
}
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface VesselAnalysisResultRepository extends JpaRepository<VesselAnalysisResult, Long> {
List<VesselAnalysisResult> findByTimestampAfter(Instant since);
}

파일 보기

@ -0,0 +1,47 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.CacheConfig;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Service
@RequiredArgsConstructor
public class VesselAnalysisService {
private static final int RECENT_MINUTES = 10;
private final VesselAnalysisResultRepository repository;
private final CacheManager cacheManager;
/**
* 최근 10분 분석 결과를 반환한다. Caffeine 캐시(TTL 5분) 적용.
*/
@SuppressWarnings("unchecked")
public List<VesselAnalysisDto> getLatestResults() {
Cache cache = cacheManager.getCache(CacheConfig.VESSEL_ANALYSIS);
if (cache != null) {
Cache.ValueWrapper wrapper = cache.get("data");
if (wrapper != null) {
return (List<VesselAnalysisDto>) wrapper.get();
}
}
Instant since = Instant.now().minus(RECENT_MINUTES, ChronoUnit.MINUTES);
List<VesselAnalysisDto> results = repository.findByTimestampAfter(since)
.stream()
.map(VesselAnalysisDto::from)
.toList();
if (cache != null) {
cache.put("data", results);
}
return results;
}
}

파일 보기

@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS kcg.vessel_analysis_results (
id BIGSERIAL PRIMARY KEY,
mmsi VARCHAR(15) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
vessel_type VARCHAR(20),
confidence DOUBLE PRECISION,
fishing_pct DOUBLE PRECISION,
cluster_id INTEGER,
season VARCHAR(10),
zone VARCHAR(20),
dist_to_baseline_nm DOUBLE PRECISION,
activity_state VARCHAR(20),
ucaf_score DOUBLE PRECISION,
ucft_score DOUBLE PRECISION,
is_dark BOOLEAN DEFAULT FALSE,
gap_duration_min INTEGER,
spoofing_score DOUBLE PRECISION,
bd09_offset_m DOUBLE PRECISION,
speed_jump_count INTEGER,
cluster_size INTEGER,
is_leader BOOLEAN DEFAULT FALSE,
fleet_role VARCHAR(20),
risk_score INTEGER,
risk_level VARCHAR(20),
features JSONB,
analyzed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vessel_analysis_mmsi ON kcg.vessel_analysis_results(mmsi);
CREATE INDEX IF NOT EXISTS idx_vessel_analysis_timestamp ON kcg.vessel_analysis_results(timestamp DESC);

파일 보기

@ -15,6 +15,7 @@ import { useMonitor } from './hooks/useMonitor';
import { useIranData } from './hooks/useIranData';
import { useKoreaData } from './hooks/useKoreaData';
import { useKoreaFilters } from './hooks/useKoreaFilters';
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
import type { GeoEvent, LayerVisibility, AppMode } from './types';
import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
@ -186,11 +187,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
refreshKey,
});
// Vessel analysis (Python prediction 결과)
const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea');
// Korea filters hook
const koreaFiltersResult = useKoreaFilters(
koreaData.ships,
koreaData.visibleShips,
currentTime,
vesselAnalysis.analysisMap,
);
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
@ -560,6 +565,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
vesselAnalysis={vesselAnalysis}
/>
<div className="map-overlay-left">
<LayerPanel

파일 보기

@ -0,0 +1,279 @@
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_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'];
return {
width: 10,
height: 10,
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'];
return (
<Marker key={`risk-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="center">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pointerEvents: 'none' }}>
{/* 삼각형 아이콘 */}
<div style={{
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderBottom: `9px solid ${color}`,
filter: `drop-shadow(0 0 3px ${color}88)`,
}} />
{/* 위험도 텍스트 */}
<div style={{
fontSize: 5,
fontWeight: 700,
color,
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginTop: 1,
}}>
{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' }}>
{/* 보라 점선 원 */}
<div style={{
width: 16,
height: 16,
borderRadius: '50%',
border: '2px dashed #a855f7',
boxShadow: '0 0 4px #a855f788',
}} />
{/* gap 라벨 */}
<div style={{
fontSize: 5,
fontWeight: 700,
color: '#a855f7',
textShadow: '0 0 2px #000, 0 0 2px #000',
textAlign: 'center',
whiteSpace: 'nowrap',
marginTop: 1,
}}>
{gapMin > 0 ? `${Math.round(gapMin)}` : 'DARK'}
</div>
</div>
</Marker>
);
})}
{/* GPS 스푸핑 배지 */}
{spoofingMarkers.map(({ ship }) => (
<Marker key={`spoof-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
<div style={{
marginBottom: 14,
fontSize: 6,
fontWeight: 700,
color: '#fff',
backgroundColor: '#ef4444',
borderRadius: 2,
padding: '0 2px',
textShadow: 'none',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}>
GPS
</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>
))}
</>
);
}

파일 보기

@ -0,0 +1,175 @@
import { useState, useMemo } from 'react';
import type { AnalysisStats } from '../../hooks/useVesselAnalysis';
interface Props {
stats: AnalysisStats;
lastUpdated: number;
isLoading: boolean;
}
/** unix ms → HH:MM 형식 */
function formatTime(ms: number): string {
if (ms === 0) return '--:--';
const d = new Date(ms);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
export function AnalysisStatsPanel({ stats, lastUpdated, isLoading }: Props) {
const [expanded, setExpanded] = useState(true);
const isEmpty = useMemo(() => stats.total === 0, [stats.total]);
const panelStyle: React.CSSProperties = {
position: 'absolute',
top: 60,
right: 10,
zIndex: 10,
minWidth: 160,
backgroundColor: 'rgba(12, 24, 37, 0.92)',
border: '1px solid rgba(99, 179, 237, 0.25)',
borderRadius: 8,
color: '#e2e8f0',
fontFamily: 'monospace, sans-serif',
fontSize: 11,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderBottom: expanded ? '1px solid rgba(99, 179, 237, 0.15)' : 'none',
cursor: 'default',
userSelect: 'none',
};
const toggleButtonStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
fontSize: 10,
padding: '0 2px',
lineHeight: 1,
};
const bodyStyle: React.CSSProperties = {
padding: '8px 10px',
};
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 3,
};
const labelStyle: React.CSSProperties = {
color: '#94a3b8',
};
const valueStyle: React.CSSProperties = {
fontWeight: 700,
color: '#e2e8f0',
};
const dividerStyle: React.CSSProperties = {
borderTop: '1px solid rgba(99, 179, 237, 0.15)',
margin: '6px 0',
};
const riskRowStyle: React.CSSProperties = {
display: 'flex',
gap: 6,
justifyContent: 'space-between',
marginTop: 4,
};
const riskBadgeStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 2,
color: '#cbd5e1',
fontSize: 10,
};
return (
<div style={panelStyle}>
{/* 헤더 */}
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>AI </span>
{isLoading && (
<span style={{ fontSize: 9, color: '#fbbf24' }}>...</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 9, color: '#64748b' }}>{formatTime(lastUpdated)}</span>
<button
style={toggleButtonStyle}
onClick={() => setExpanded(prev => !prev)}
aria-label={expanded ? '패널 접기' : '패널 펼치기'}
>
{expanded ? '▲' : '▼'}
</button>
</div>
</div>
{/* 본문 */}
{expanded && (
<div style={bodyStyle}>
{isEmpty ? (
<div style={{ color: '#64748b', textAlign: 'center', padding: '6px 0' }}>
</div>
) : (
<>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={valueStyle}>{stats.total}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#a855f7' }}>{stats.dark}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}>GPS스푸핑</span>
<span style={{ ...valueStyle, color: '#ef4444' }}>{stats.spoofing}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}></span>
<span style={{ ...valueStyle, color: '#06b6d4' }}>{stats.clusterCount}</span>
</div>
<div style={dividerStyle} />
{/* 위험도 수치 행 */}
<div style={riskRowStyle}>
<div style={riskBadgeStyle}>
<span>🔴</span>
<span style={{ color: '#ef4444', fontWeight: 700 }}>{stats.critical}</span>
</div>
<div style={riskBadgeStyle}>
<span>🟠</span>
<span style={{ color: '#f97316', fontWeight: 700 }}>{stats.high}</span>
</div>
<div style={riskBadgeStyle}>
<span>🟡</span>
<span style={{ color: '#eab308', fontWeight: 700 }}>{stats.medium}</span>
</div>
<div style={riskBadgeStyle}>
<span>🟢</span>
<span style={{ color: '#22c55e', fontWeight: 700 }}>{stats.low}</span>
</div>
</div>
</>
)}
</div>
)}
</div>
);
}

파일 보기

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
import type { Ship } from '../../types';
import type { Ship, VesselAnalysisDto } from '../../types';
import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
@ -79,9 +79,10 @@ interface GearToParentLink {
interface Props {
ships: Ship[];
analysisMap?: Map<string, VesselAnalysisDto>;
}
export function ChineseFishingOverlay({ ships }: Props) {
export function ChineseFishingOverlay({ ships, analysisMap }: Props) {
// 중국 어선만 필터링
const chineseFishing = useMemo(() => {
return ships.filter(s => {
@ -91,17 +92,43 @@ export function ChineseFishingOverlay({ ships }: Props) {
});
}, [ships]);
// Python fleet_role → 표시용 role 매핑
const resolveRole = (s: Ship): { role: string; roleKo: string; color: string } => {
const dto = analysisMap?.get(s.mmsi);
if (dto) {
const fleetRole = dto.algorithms.fleetRole.role;
const riskLevel = dto.algorithms.riskScore.level;
if (fleetRole === 'LEADER') {
return { role: 'PT', roleKo: '본선', color: riskLevel === 'CRITICAL' ? '#ef4444' : '#f97316' };
}
if (fleetRole === 'MEMBER') {
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
}
}
return estimateRole(s);
};
// 조업 분석 결과
const analyzed = useMemo(() => {
return chineseFishing.map(s => ({
ship: s,
analysis: analyzeFishing(s),
role: estimateRole(s),
role: resolveRole(s),
}));
}, [chineseFishing]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chineseFishing, analysisMap]);
// 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척)
const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating).slice(0, 100), [analyzed]);
// Python activity_state === 'FISHING'인 선박도 조업 중으로 간주
const operating = useMemo(() => {
return analyzed
.filter(a => {
if (a.analysis.isOperating) return true;
const dto = analysisMap?.get(a.ship.mmsi);
return dto?.algorithms.activity.state === 'FISHING';
})
.slice(0, 100);
}, [analyzed, analysisMap]);
// 어구/어망 → 모선 연결 탐지 (거리 제한 + 정확 매칭 우선)
const gearLinks: GearToParentLink[] = useMemo(() => {

파일 보기

@ -21,10 +21,13 @@ import { GovBuildingLayer } from './GovBuildingLayer';
import { NKLaunchLayer } from './NKLaunchLayer';
import { NKMissileEventLayer } from './NKMissileEventLayer';
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
import { AnalysisOverlay } from './AnalysisOverlay';
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { fetchKoreaInfra } from '../../services/infra';
import type { PowerFacility } from '../../services/infra';
import type { Ship, Aircraft, SatellitePosition } from '../../types';
import type { OsintItem } from '../../services/osint';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
import { countryLabelsGeoJSON } from '../../data/countryLabels';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -49,6 +52,7 @@ interface Props {
cableWatchSuspects: Set<string>;
dokdoWatchSuspects: Set<string>;
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
vesselAnalysis?: UseVesselAnalysisResult;
}
// MarineTraffic-style: satellite + dark ocean + nautical overlay
@ -121,7 +125,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
ferryWatch: 'filters.ferryWatchMonitor',
};
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts }: Props) {
export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
const { t } = useTranslation();
const mapRef = useRef<MapRef>(null);
const [infra, setInfra] = useState<PowerFacility[]>([]);
@ -268,7 +272,20 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
{layers.govBuildings && <GovBuildingLayer />}
{layers.nkLaunch && <NKLaunchLayer />}
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
<AnalysisOverlay
ships={ships}
analysisMap={vesselAnalysis.analysisMap}
clusters={vesselAnalysis.clusters}
activeFilter={
koreaFilters.illegalFishing ? 'illegalFishing'
: koreaFilters.darkVessel ? 'darkVessel'
: layers.cnFishing ? 'cnFishing'
: null
}
/>
)}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}
@ -330,6 +347,15 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
))}
</div>
)}
{/* AI Analysis Stats Panel */}
{vesselAnalysis && vesselAnalysis.stats.total > 0 && (
<AnalysisStatsPanel
stats={vesselAnalysis.stats}
lastUpdated={vesselAnalysis.lastUpdated}
isLoading={vesselAnalysis.isLoading}
/>
)}
</Map>
);
}

파일 보기

@ -1,7 +1,7 @@
import { useState, useMemo, useRef } from 'react';
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
import { getMarineTrafficCategory } from '../utils/marineTraffic';
import type { Ship } from '../types';
import type { Ship, VesselAnalysisDto } from '../types';
interface KoreaFilters {
illegalFishing: boolean;
@ -41,6 +41,7 @@ export function useKoreaFilters(
koreaShips: Ship[],
visibleShips: Ship[],
currentTime: number,
analysisMap?: Map<string, VesselAnalysisDto>,
): UseKoreaFiltersResult {
const [filters, setFilters] = useState<KoreaFilters>({
illegalFishing: false,
@ -190,8 +191,17 @@ export function useKoreaFilters(
}
}
// Python 분류 결과 합집합: is_dark=true인 mmsi 추가
if (analysisMap) {
for (const [mmsi, dto] of analysisMap.entries()) {
if (dto.algorithms.darkVessel.isDark) {
result.add(mmsi);
}
}
}
return result;
}, [koreaShips, filters.darkVessel, currentTime]);
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]);
// 해저케이블 감시
const cableWatchSet = useMemo(() => {
@ -298,6 +308,7 @@ export function useKoreaFilters(
return visibleShips.filter(s => {
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
if (filters.illegalFishing && (analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'CRITICAL' || analysisMap?.get(s.mmsi)?.algorithms.riskScore.level === 'HIGH')) return true;
if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
if (filters.cableWatch && cableWatchSet.has(s.mmsi)) return true;
@ -305,7 +316,7 @@ export function useKoreaFilters(
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
return false;
});
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]);
return {
filters,

파일 보기

@ -0,0 +1,114 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import type { VesselAnalysisDto, RiskLevel } from '../types';
import { fetchVesselAnalysis } from '../services/vesselAnalysis';
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
const STALE_MS = 30 * 60_000; // 30분
export interface AnalysisStats {
total: number;
critical: number;
high: number;
medium: number;
low: number;
dark: number;
spoofing: number;
clusterCount: number;
}
export interface UseVesselAnalysisResult {
analysisMap: Map<string, VesselAnalysisDto>;
stats: AnalysisStats;
clusters: Map<number, string[]>;
isLoading: boolean;
lastUpdated: number;
}
const EMPTY_STATS: AnalysisStats = {
total: 0, critical: 0, high: 0, medium: 0, low: 0,
dark: 0, spoofing: 0, clusterCount: 0,
};
export function useVesselAnalysis(enabled: boolean): UseVesselAnalysisResult {
const mapRef = useRef<Map<string, VesselAnalysisDto>>(new Map());
const [version, setVersion] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState(0);
const doFetch = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
try {
const items = await fetchVesselAnalysis();
const now = Date.now();
const map = mapRef.current;
// stale 제거
for (const [mmsi, dto] of map) {
const ts = new Date(dto.timestamp).getTime();
if (now - ts > STALE_MS) map.delete(mmsi);
}
// 새 결과 merge
for (const item of items) {
map.set(item.mmsi, item);
}
setLastUpdated(now);
setVersion(v => v + 1);
} catch {
// 에러 시 기존 데이터 유지 (graceful degradation)
} finally {
setIsLoading(false);
}
}, [enabled]);
useEffect(() => {
doFetch();
const t = setInterval(doFetch, POLL_INTERVAL_MS);
return () => clearInterval(t);
}, [doFetch]);
const analysisMap = mapRef.current;
const stats = useMemo((): AnalysisStats => {
if (analysisMap.size === 0) return EMPTY_STATS;
let critical = 0, high = 0, medium = 0, low = 0, dark = 0, spoofing = 0;
const clusterIds = new Set<number>();
for (const dto of analysisMap.values()) {
const level: RiskLevel = dto.algorithms.riskScore.level;
if (level === 'CRITICAL') critical++;
else if (level === 'HIGH') high++;
else if (level === 'MEDIUM') medium++;
else low++;
if (dto.algorithms.darkVessel.isDark) dark++;
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) spoofing++;
if (dto.algorithms.cluster.clusterId >= 0) {
clusterIds.add(dto.algorithms.cluster.clusterId);
}
}
return {
total: analysisMap.size, critical, high, medium, low,
dark, spoofing, clusterCount: clusterIds.size,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version]);
const clusters = useMemo((): Map<number, string[]> => {
const result = new Map<number, string[]>();
for (const [mmsi, dto] of analysisMap) {
const cid = dto.algorithms.cluster.clusterId;
if (cid < 0) continue;
const arr = result.get(cid);
if (arr) arr.push(mmsi);
else result.set(cid, [mmsi]);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version]);
return { analysisMap, stats, clusters, isLoading, lastUpdated };
}

파일 보기

@ -0,0 +1,12 @@
import type { VesselAnalysisDto } from '../types';
const API_BASE = '/api/kcg';
export async function fetchVesselAnalysis(): Promise<VesselAnalysisDto[]> {
const res = await fetch(`${API_BASE}/vessel-analysis`, {
headers: { accept: 'application/json' },
});
if (!res.ok) return [];
const data: { count: number; items: VesselAnalysisDto[] } = await res.json();
return data.items ?? [];
}

파일 보기

@ -147,3 +147,31 @@ export interface LayerVisibility {
}
export type AppMode = 'replay' | 'live';
// Vessel analysis (Python prediction 결과)
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP' | 'UNKNOWN';
export type RiskLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
export type ActivityState = 'STATIONARY' | 'FISHING' | 'SAILING' | 'UNKNOWN';
export type FleetRole = 'LEADER' | 'MEMBER' | 'NOISE';
export interface VesselAnalysisDto {
mmsi: string;
timestamp: string;
classification: {
vesselType: VesselType;
confidence: number;
fishingPct: number;
clusterId: number;
season: string;
};
algorithms: {
location: { zone: string; distToBaselineNm: number };
activity: { state: ActivityState; ucafScore: number; ucftScore: number };
darkVessel: { isDark: boolean; gapDurationMin: number };
gpsSpoofing: { spoofingScore: number; bd09OffsetM: number; speedJumpCount: number };
cluster: { clusterId: number; clusterSize: number };
fleetRole: { isLeader: boolean; role: FleetRole };
riskScore: { score: number; level: RiskLevel };
};
features: Record<string, number>;
}