feat: Python 분석 결과 오버레이 + 메뉴 연동 — Backend API 복원 + DB 테이블 + 통계패널 + 위험도 마커
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
2a2b5fb111
커밋
e82b2d77e7
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
database/migration/005_vessel_analysis.sql
Normal file
30
database/migration/005_vessel_analysis.sql
Normal file
@ -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 { useIranData } from './hooks/useIranData';
|
||||||
import { useKoreaData } from './hooks/useKoreaData';
|
import { useKoreaData } from './hooks/useKoreaData';
|
||||||
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
import { useKoreaFilters } from './hooks/useKoreaFilters';
|
||||||
|
import { useVesselAnalysis } from './hooks/useVesselAnalysis';
|
||||||
import type { GeoEvent, LayerVisibility, AppMode } from './types';
|
import type { GeoEvent, LayerVisibility, AppMode } from './types';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
@ -186,11 +187,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
refreshKey,
|
refreshKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vessel analysis (Python prediction 결과)
|
||||||
|
const vesselAnalysis = useVesselAnalysis(dashboardTab === 'korea');
|
||||||
|
|
||||||
// Korea filters hook
|
// Korea filters hook
|
||||||
const koreaFiltersResult = useKoreaFilters(
|
const koreaFiltersResult = useKoreaFilters(
|
||||||
koreaData.ships,
|
koreaData.ships,
|
||||||
koreaData.visibleShips,
|
koreaData.visibleShips,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
vesselAnalysis.analysisMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
const toggleLayer = useCallback((key: keyof LayerVisibility) => {
|
||||||
@ -560,6 +565,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
cableWatchSuspects={koreaFiltersResult.cableWatchSuspects}
|
||||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||||
|
vesselAnalysis={vesselAnalysis}
|
||||||
/>
|
/>
|
||||||
<div className="map-overlay-left">
|
<div className="map-overlay-left">
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
|
|||||||
279
frontend/src/components/korea/AnalysisOverlay.tsx
Normal file
279
frontend/src/components/korea/AnalysisOverlay.tsx
Normal file
@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
frontend/src/components/korea/AnalysisStatsPanel.tsx
Normal file
175
frontend/src/components/korea/AnalysisStatsPanel.tsx
Normal file
@ -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 { useMemo } from 'react';
|
||||||
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
|
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 { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
@ -79,9 +79,10 @@ interface GearToParentLink {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ships: Ship[];
|
ships: Ship[];
|
||||||
|
analysisMap?: Map<string, VesselAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChineseFishingOverlay({ ships }: Props) {
|
export function ChineseFishingOverlay({ ships, analysisMap }: Props) {
|
||||||
// 중국 어선만 필터링
|
// 중국 어선만 필터링
|
||||||
const chineseFishing = useMemo(() => {
|
const chineseFishing = useMemo(() => {
|
||||||
return ships.filter(s => {
|
return ships.filter(s => {
|
||||||
@ -91,17 +92,43 @@ export function ChineseFishingOverlay({ ships }: Props) {
|
|||||||
});
|
});
|
||||||
}, [ships]);
|
}, [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(() => {
|
const analyzed = useMemo(() => {
|
||||||
return chineseFishing.map(s => ({
|
return chineseFishing.map(s => ({
|
||||||
ship: s,
|
ship: s,
|
||||||
analysis: analyzeFishing(s),
|
analysis: analyzeFishing(s),
|
||||||
role: estimateRole(s),
|
role: resolveRole(s),
|
||||||
}));
|
}));
|
||||||
}, [chineseFishing]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chineseFishing, analysisMap]);
|
||||||
|
|
||||||
// 조업 중인 선박만 (어구 아이콘 표시용, 최대 100척)
|
// 조업 중인 선박만 (어구 아이콘 표시용, 최대 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(() => {
|
const gearLinks: GearToParentLink[] = useMemo(() => {
|
||||||
|
|||||||
@ -21,10 +21,13 @@ import { GovBuildingLayer } from './GovBuildingLayer';
|
|||||||
import { NKLaunchLayer } from './NKLaunchLayer';
|
import { NKLaunchLayer } from './NKLaunchLayer';
|
||||||
import { NKMissileEventLayer } from './NKMissileEventLayer';
|
import { NKMissileEventLayer } from './NKMissileEventLayer';
|
||||||
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||||
|
import { AnalysisOverlay } from './AnalysisOverlay';
|
||||||
|
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
|
||||||
import { fetchKoreaInfra } from '../../services/infra';
|
import { fetchKoreaInfra } from '../../services/infra';
|
||||||
import type { PowerFacility } from '../../services/infra';
|
import type { PowerFacility } from '../../services/infra';
|
||||||
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
||||||
import type { OsintItem } from '../../services/osint';
|
import type { OsintItem } from '../../services/osint';
|
||||||
|
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
|
||||||
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
import { countryLabelsGeoJSON } from '../../data/countryLabels';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
@ -49,6 +52,7 @@ interface Props {
|
|||||||
cableWatchSuspects: Set<string>;
|
cableWatchSuspects: Set<string>;
|
||||||
dokdoWatchSuspects: Set<string>;
|
dokdoWatchSuspects: Set<string>;
|
||||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||||
|
vesselAnalysis?: UseVesselAnalysisResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||||
@ -121,7 +125,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
|||||||
ferryWatch: 'filters.ferryWatchMonitor',
|
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 { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
@ -268,7 +272,20 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
|||||||
{layers.govBuildings && <GovBuildingLayer />}
|
{layers.govBuildings && <GovBuildingLayer />}
|
||||||
{layers.nkLaunch && <NKLaunchLayer />}
|
{layers.nkLaunch && <NKLaunchLayer />}
|
||||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
{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.airports && <KoreaAirportLayer />}
|
||||||
{layers.coastGuard && <CoastGuardLayer />}
|
{layers.coastGuard && <CoastGuardLayer />}
|
||||||
{layers.navWarning && <NavWarningLayer />}
|
{layers.navWarning && <NavWarningLayer />}
|
||||||
@ -330,6 +347,15 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Analysis Stats Panel */}
|
||||||
|
{vesselAnalysis && vesselAnalysis.stats.total > 0 && (
|
||||||
|
<AnalysisStatsPanel
|
||||||
|
stats={vesselAnalysis.stats}
|
||||||
|
lastUpdated={vesselAnalysis.lastUpdated}
|
||||||
|
isLoading={vesselAnalysis.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useRef } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
import { KOREA_SUBMARINE_CABLES } from '../services/submarineCable';
|
||||||
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../utils/marineTraffic';
|
||||||
import type { Ship } from '../types';
|
import type { Ship, VesselAnalysisDto } from '../types';
|
||||||
|
|
||||||
interface KoreaFilters {
|
interface KoreaFilters {
|
||||||
illegalFishing: boolean;
|
illegalFishing: boolean;
|
||||||
@ -41,6 +41,7 @@ export function useKoreaFilters(
|
|||||||
koreaShips: Ship[],
|
koreaShips: Ship[],
|
||||||
visibleShips: Ship[],
|
visibleShips: Ship[],
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
|
analysisMap?: Map<string, VesselAnalysisDto>,
|
||||||
): UseKoreaFiltersResult {
|
): UseKoreaFiltersResult {
|
||||||
const [filters, setFilters] = useState<KoreaFilters>({
|
const [filters, setFilters] = useState<KoreaFilters>({
|
||||||
illegalFishing: false,
|
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;
|
return result;
|
||||||
}, [koreaShips, filters.darkVessel, currentTime]);
|
}, [koreaShips, filters.darkVessel, currentTime, analysisMap]);
|
||||||
|
|
||||||
// 해저케이블 감시
|
// 해저케이블 감시
|
||||||
const cableWatchSet = useMemo(() => {
|
const cableWatchSet = useMemo(() => {
|
||||||
@ -298,6 +308,7 @@ export function useKoreaFilters(
|
|||||||
return visibleShips.filter(s => {
|
return visibleShips.filter(s => {
|
||||||
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
const mtCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
if (filters.illegalFishing && mtCat === 'fishing' && s.flag !== 'KR') return true;
|
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.illegalTransship && transshipSuspects.has(s.mmsi)) return true;
|
||||||
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true;
|
||||||
if (filters.cableWatch && cableWatchSet.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;
|
if (filters.ferryWatch && getMarineTrafficCategory(s.typecode, s.category) === 'passenger') return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet]);
|
}, [visibleShips, filters, anyFilterOn, transshipSuspects, darkVesselSet, cableWatchSet, dokdoWatchSet, analysisMap]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
|
|||||||
114
frontend/src/hooks/useVesselAnalysis.ts
Normal file
114
frontend/src/hooks/useVesselAnalysis.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
12
frontend/src/services/vesselAnalysis.ts
Normal file
12
frontend/src/services/vesselAnalysis.ts
Normal file
@ -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';
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user