Merge pull request 'feat: Python 분석 결과 오버레이 + 메뉴 연동' (#99) from feat/vessel-analysis-overlay into develop
This commit is contained in:
커밋
5154c67f1b
@ -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);
|
||||
@ -4,6 +4,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고
|
||||
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
|
||||
- 불법어선 필터에 Python risk_level(CRITICAL/HIGH) 선박 자동 포함
|
||||
- 다크베셀 필터에 Python is_dark 감지 결과 합집합
|
||||
- 중국어선감시에 Python fleet_role(LEADER/MEMBER) 역할 우선 표시
|
||||
- Backend vessel-analysis REST API 복원 (JPA + Caffeine 캐시)
|
||||
- DB vessel_analysis_results 테이블 생성 (005 마이그레이션)
|
||||
|
||||
## [2026-03-20]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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
|
||||
|
||||
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 { 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,
|
||||
|
||||
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';
|
||||
|
||||
// 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