feat: 중국어선 분석 인프라 — 허가어선 API 연동 + vessel-analysis 백엔드 + 결과 포맷 확정
- Frontend: ChnPrmShipInfo 타입 + chnPrmShip.ts 서비스 (signal-batch 허가어선 API) - Frontend: FieldAnalysisModal fetchVesselPermit → lookupPermittedShip 교체 - Frontend: 더미 라벨 정리 (LightGBM → 규칙기반, BD-09/레이더 → STANDBY/미연동) - Frontend: VesselAnalysisResult 인터페이스 정의 (Python 분석 결과 수신용) - Backend: vessel-analysis REST API (Entity/Repository/Service/Controller) - Backend: DB 마이그레이션 005 (kcg.vessel_analysis_results 테이블) - Backend: AuthFilter 인증 예외 + CacheConfig 캐시 등록 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
5cf69a1d22
커밋
feabf16114
@ -23,6 +23,7 @@ public class AuthFilter extends OncePerRequestFilter {
|
|||||||
private static final String AUTH_PATH_PREFIX = "/api/auth/";
|
private static final String AUTH_PATH_PREFIX = "/api/auth/";
|
||||||
private static final String SENSOR_PATH_PREFIX = "/api/sensor/";
|
private static final String SENSOR_PATH_PREFIX = "/api/sensor/";
|
||||||
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
|
private static final String CCTV_PATH_PREFIX = "/api/cctv/";
|
||||||
|
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
|
||||||
|
|
||||||
private final JwtProvider jwtProvider;
|
private final JwtProvider jwtProvider;
|
||||||
|
|
||||||
@ -31,7 +32,8 @@ public class AuthFilter extends OncePerRequestFilter {
|
|||||||
String path = request.getRequestURI();
|
String path = request.getRequestURI();
|
||||||
return path.startsWith(AUTH_PATH_PREFIX)
|
return path.startsWith(AUTH_PATH_PREFIX)
|
||||||
|| path.startsWith(SENSOR_PATH_PREFIX)
|
|| path.startsWith(SENSOR_PATH_PREFIX)
|
||||||
|| path.startsWith(CCTV_PATH_PREFIX);
|
|| path.startsWith(CCTV_PATH_PREFIX)
|
||||||
|
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -20,6 +20,7 @@ public class CacheConfig {
|
|||||||
public static final String SATELLITES = "satellites";
|
public static final String SATELLITES = "satellites";
|
||||||
public static final String SEISMIC = "seismic";
|
public static final String SEISMIC = "seismic";
|
||||||
public static final String PRESSURE = "pressure";
|
public static final String PRESSURE = "pressure";
|
||||||
|
public static final String VESSEL_ANALYSIS = "vessel-analysis";
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
@ -27,7 +28,8 @@ public class CacheConfig {
|
|||||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||||
OSINT_IRAN, OSINT_KOREA,
|
OSINT_IRAN, OSINT_KOREA,
|
||||||
SATELLITES,
|
SATELLITES,
|
||||||
SEISMIC, PRESSURE
|
SEISMIC, PRESSURE,
|
||||||
|
VESSEL_ANALYSIS
|
||||||
);
|
);
|
||||||
manager.setCaffeine(Caffeine.newBuilder()
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||||
|
|||||||
@ -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);
|
||||||
@ -1,36 +1,8 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import type { Ship } from '../../types';
|
import type { Ship, ChnPrmShipInfo } from '../../types';
|
||||||
import { analyzeFishing } from '../../utils/fishingAnalysis';
|
import { analyzeFishing } from '../../utils/fishingAnalysis';
|
||||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { lookupPermittedShip } from '../../services/chnPrmShip';
|
||||||
// ── 선박 허가 정보 타입
|
|
||||||
interface PermitRecord {
|
|
||||||
permitNumber: string;
|
|
||||||
permitType: string;
|
|
||||||
issuedBy: string;
|
|
||||||
validFrom: string;
|
|
||||||
validTo: string;
|
|
||||||
authorizedZones: string[];
|
|
||||||
vesselName: string;
|
|
||||||
grossTonnage?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MMSI → 허가 정보 캐시 (null = 미등록, undefined = 미조회)
|
|
||||||
const permitCache = new Map<string, PermitRecord | null>();
|
|
||||||
|
|
||||||
async function fetchVesselPermit(mmsi: string): Promise<PermitRecord | null> {
|
|
||||||
if (permitCache.has(mmsi)) return permitCache.get(mmsi) ?? null;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/kcg/vessel-permit/${mmsi}`);
|
|
||||||
if (res.status === 404) { permitCache.set(mmsi, null); return null; }
|
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
|
||||||
const data: PermitRecord = await res.json();
|
|
||||||
permitCache.set(mmsi, data);
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
|
||||||
const mtPhotoCache = new Map<string, string | null>();
|
const mtPhotoCache = new Map<string, string | null>();
|
||||||
@ -173,7 +145,7 @@ const PIPE_STEPS = [
|
|||||||
{ num: '02', name: '행동 상태 탐지' },
|
{ num: '02', name: '행동 상태 탐지' },
|
||||||
{ num: '03', name: '궤적 리샘플링' },
|
{ num: '03', name: '궤적 리샘플링' },
|
||||||
{ num: '04', name: '특징 벡터 추출' },
|
{ num: '04', name: '특징 벡터 추출' },
|
||||||
{ num: '05', name: 'LightGBM 분류' },
|
{ num: '05', name: '규칙 기반 분류' },
|
||||||
{ num: '06', name: 'BIRCH 군집화' },
|
{ num: '06', name: 'BIRCH 군집화' },
|
||||||
{ num: '07', name: '계절 활동 분석' },
|
{ num: '07', name: '계절 활동 분석' },
|
||||||
];
|
];
|
||||||
@ -294,7 +266,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
|
|
||||||
// 허가 정보
|
// 허가 정보
|
||||||
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
|
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
|
||||||
const [permitData, setPermitData] = useState<PermitRecord | null>(null);
|
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
|
||||||
|
|
||||||
// 선박 사진
|
// 선박 사진
|
||||||
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음
|
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음
|
||||||
@ -306,7 +278,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
// 허가 조회
|
// 허가 조회
|
||||||
setPermitStatus('loading');
|
setPermitStatus('loading');
|
||||||
setPermitData(null);
|
setPermitData(null);
|
||||||
fetchVesselPermit(ship.mmsi).then(data => {
|
lookupPermittedShip(ship.mmsi).then(data => {
|
||||||
setPermitData(data);
|
setPermitData(data);
|
||||||
setPermitStatus(data ? 'found' : 'not-found');
|
setPermitStatus(data ? 'found' : 'not-found');
|
||||||
});
|
});
|
||||||
@ -378,7 +350,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}>▶ FIELD ANALYSIS</span>
|
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}>▶ FIELD ANALYSIS</span>
|
||||||
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}>중국 불법어업 현장분석 대시보드</span>
|
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}>중국 불법어업 현장분석 대시보드</span>
|
||||||
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · LightGBM · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
|
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · 규칙분류 · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||||
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
|
||||||
@ -412,8 +384,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
|
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
|
||||||
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' },
|
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 의심' },
|
||||||
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' },
|
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'BIRCH 군집' },
|
||||||
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'LightGBM 분류' },
|
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: '규칙 기반 분류' },
|
||||||
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: 'LightGBM 분류' },
|
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: '규칙 기반 분류' },
|
||||||
].map(({ label, val, color, sub }) => (
|
].map(({ label, val, color, sub }) => (
|
||||||
<div key={label} style={{
|
<div key={label} style={{
|
||||||
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
|
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
|
||||||
@ -489,8 +461,8 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ num: 'GPS', name: 'BD-09 변환', status: 'ACTIVE', color: C.amber },
|
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
|
||||||
{ num: 'NRD', name: '레이더 교차검증', status: 'LINKED', color: C.cyan },
|
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
|
||||||
].map(step => (
|
].map(step => (
|
||||||
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||||
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
|
||||||
@ -514,7 +486,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
|
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
|
||||||
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
|
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
|
||||||
{ label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 },
|
{ label: '클러스터', val: 'BIRCH 5NM', color: C.ink2 },
|
||||||
{ label: '선종 분류', val: 'LightGBM 95.7%', color: C.green },
|
{ label: '선종 분류', val: '규칙 기반 (Python 연동 예정)', color: C.ink2 },
|
||||||
].map(({ label, val, color }) => (
|
].map(({ label, val, color }) => (
|
||||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||||
@ -708,7 +680,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
|
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
|
||||||
AIS 4분 갱신 | Shepperson(2017) 기준 | LightGBM 95.68% | BIRCH 5NM
|
AIS 4분 갱신 | Shepperson(2017) 기준 | 규칙 기반 분류 | 근접 클러스터 5NM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -737,7 +709,7 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
{ label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink },
|
{ label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink },
|
||||||
{ label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
|
{ label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
|
||||||
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) },
|
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) },
|
||||||
{ label: 'LightGBM 선종', val: selectedVessel.vtype, color: C.ink },
|
{ label: '추정 선종', val: selectedVessel.vtype, color: C.ink },
|
||||||
{ label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
|
{ label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
|
||||||
{ label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
|
{ label: 'BIRCH 클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
|
||||||
{ label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
|
{ label: '경보 등급', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
|
||||||
@ -787,12 +759,14 @@ export function FieldAnalysisModal({ ships, onClose }: Props) {
|
|||||||
{permitStatus === 'found' && permitData && (
|
{permitStatus === 'found' && permitData && (
|
||||||
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
|
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
|
||||||
{[
|
{[
|
||||||
{ label: '허가번호', val: permitData.permitNumber },
|
{ label: '선명', val: permitData.name },
|
||||||
{ label: '허가종류', val: permitData.permitType },
|
{ label: '선종', val: permitData.vesselType },
|
||||||
{ label: '발급기관', val: permitData.issuedBy },
|
{ label: 'IMO', val: String(permitData.imo || '—') },
|
||||||
{ label: '유효기간', val: `${permitData.validFrom} ~ ${permitData.validTo}` },
|
{ label: '호출부호', val: permitData.callsign || '—' },
|
||||||
{ label: '허가수역', val: permitData.authorizedZones.join(', ') },
|
{ label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` },
|
||||||
...(permitData.grossTonnage ? [{ label: '총톤수', val: `${permitData.grossTonnage}GT` }] : []),
|
{ label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' },
|
||||||
|
{ label: '목적지', val: permitData.destination || '—' },
|
||||||
|
{ label: '상태', val: permitData.status || '—' },
|
||||||
].map(({ label, val }) => (
|
].map(({ label, val }) => (
|
||||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
|
||||||
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
|
||||||
|
|||||||
54
frontend/src/services/chnPrmShip.ts
Normal file
54
frontend/src/services/chnPrmShip.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import type { ChnPrmShipInfo } from '../types';
|
||||||
|
|
||||||
|
const SIGNAL_BATCH_BASE = '/signal-batch';
|
||||||
|
const CACHE_TTL_MS = 5 * 60_000; // 5분
|
||||||
|
|
||||||
|
let cachedList: ChnPrmShipInfo[] = [];
|
||||||
|
let cacheTime = 0;
|
||||||
|
let fetchPromise: Promise<ChnPrmShipInfo[]> | null = null;
|
||||||
|
|
||||||
|
async function fetchList(): Promise<ChnPrmShipInfo[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedList.length > 0 && now - cacheTime < CACHE_TTL_MS) {
|
||||||
|
return cachedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchPromise) return fetchPromise;
|
||||||
|
|
||||||
|
fetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions?minutes=60`,
|
||||||
|
{ headers: { accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
if (!res.ok) return cachedList;
|
||||||
|
const json: unknown = await res.json();
|
||||||
|
cachedList = Array.isArray(json) ? (json as ChnPrmShipInfo[]) : [];
|
||||||
|
cacheTime = Date.now();
|
||||||
|
return cachedList;
|
||||||
|
} catch {
|
||||||
|
return cachedList;
|
||||||
|
} finally {
|
||||||
|
fetchPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mmsi로 허가어선 정보 조회 — 목록을 캐시하고 lookup */
|
||||||
|
export async function lookupPermittedShip(mmsi: string): Promise<ChnPrmShipInfo | null> {
|
||||||
|
const list = await fetchList();
|
||||||
|
return list.find((s) => s.mmsi === mmsi) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 허가어선 mmsi Set (빠른 조회용) */
|
||||||
|
export async function getPermittedMmsiSet(): Promise<Set<string>> {
|
||||||
|
const list = await fetchList();
|
||||||
|
return new Set(list.map((s) => s.mmsi));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캐시 강제 갱신 */
|
||||||
|
export function invalidateCache(): void {
|
||||||
|
cacheTime = 0;
|
||||||
|
}
|
||||||
@ -147,3 +147,60 @@ export interface LayerVisibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AppMode = 'replay' | 'live';
|
export type AppMode = 'replay' | 'live';
|
||||||
|
|
||||||
|
// ── 중국어선 분석 결과 (Python 분류기 → REST API → Frontend) ──
|
||||||
|
|
||||||
|
export type VesselType = 'TRAWL' | 'PURSE' | 'LONGLINE' | 'TRAP';
|
||||||
|
export type RiskLevel = 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
|
||||||
|
export type ActivityState = 'FISHING' | 'SAILING' | 'STATIONARY' | 'AIS_LOSS';
|
||||||
|
export type ZoneType = 'TERRITORIAL' | 'CONTIGUOUS' | 'EEZ' | 'BEYOND';
|
||||||
|
export type FleetRole = 'MOTHER' | 'SUB' | 'TRANSPORT' | 'INDEPENDENT';
|
||||||
|
|
||||||
|
export interface VesselClassification {
|
||||||
|
vesselType: VesselType;
|
||||||
|
confidence: number; // 0~1
|
||||||
|
fishingPct: number; // 조업 비율 %
|
||||||
|
clusterId: number; // BIRCH 군집 ID (-1=노이즈)
|
||||||
|
season: string; // SPRING/SUMMER/FALL/WINTER
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselAlgorithms {
|
||||||
|
location: { zone: ZoneType; 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; centroid?: [number, number] };
|
||||||
|
fleetRole: { isLeader: boolean; role: FleetRole };
|
||||||
|
riskScore: { score: number; level: RiskLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VesselAnalysisResult {
|
||||||
|
mmsi: string;
|
||||||
|
timestamp: string; // ISO 분석 시점
|
||||||
|
classification: VesselClassification;
|
||||||
|
algorithms: VesselAlgorithms;
|
||||||
|
features: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 허가어선 정보 (signal-batch /api/v2/vessels/chnprmship)
|
||||||
|
export interface ChnPrmShipInfo {
|
||||||
|
mmsi: string;
|
||||||
|
imo: number;
|
||||||
|
name: string;
|
||||||
|
callsign: string;
|
||||||
|
vesselType: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
sog: number;
|
||||||
|
cog: number;
|
||||||
|
heading: number;
|
||||||
|
length: number;
|
||||||
|
width: number;
|
||||||
|
draught: number;
|
||||||
|
destination: string;
|
||||||
|
status: string;
|
||||||
|
signalKindCode: string;
|
||||||
|
messageTimestamp: string;
|
||||||
|
shipImagePath?: string | null;
|
||||||
|
shipImageCount?: number;
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user