Merge pull request 'feat: 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널' (#62) from feature/china-fishing-backend-api into develop
This commit is contained in:
커밋
9607f798dd
@ -0,0 +1,27 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 집계 응답.
|
||||
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
|
||||
*/
|
||||
public record AnalysisStatsResponse(
|
||||
long total,
|
||||
long darkCount,
|
||||
long spoofingCount,
|
||||
long transshipCount,
|
||||
long criticalCount,
|
||||
long highCount,
|
||||
long mediumCount,
|
||||
long lowCount,
|
||||
long territorialCount,
|
||||
long contiguousCount,
|
||||
long eezCount,
|
||||
long fishingCount,
|
||||
BigDecimal avgRiskScore,
|
||||
OffsetDateTime windowStart,
|
||||
OffsetDateTime windowEnd
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* prediction 자동 어구 탐지 결과 응답 DTO.
|
||||
* gear_code / gear_judgment 가 NOT NULL 인 row의 핵심 필드만 노출.
|
||||
*/
|
||||
public record GearDetectionResponse(
|
||||
Long id,
|
||||
String mmsi,
|
||||
OffsetDateTime analyzedAt,
|
||||
String vesselType,
|
||||
String gearCode,
|
||||
String gearJudgment,
|
||||
String permitStatus,
|
||||
String riskLevel,
|
||||
Integer riskScore,
|
||||
String zoneCode,
|
||||
List<String> violationCategories
|
||||
) {
|
||||
public static GearDetectionResponse from(VesselAnalysisResult e) {
|
||||
return new GearDetectionResponse(
|
||||
e.getId(),
|
||||
e.getMmsi(),
|
||||
e.getAnalyzedAt(),
|
||||
e.getVesselType(),
|
||||
e.getGearCode(),
|
||||
e.getGearJudgment(),
|
||||
e.getPermitStatus(),
|
||||
e.getRiskLevel(),
|
||||
e.getRiskScore(),
|
||||
e.getZoneCode(),
|
||||
e.getViolationCategories()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@ -33,17 +34,52 @@ public class VesselAnalysisController {
|
||||
@RequestParam(required = false) String zoneCode,
|
||||
@RequestParam(required = false) String riskLevel,
|
||||
@RequestParam(required = false) Boolean isDark,
|
||||
@RequestParam(required = false) String mmsiPrefix,
|
||||
@RequestParam(required = false) Integer minRiskScore,
|
||||
@RequestParam(required = false) BigDecimal minFishingPct,
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
return service.getAnalysisResults(
|
||||
mmsi, zoneCode, riskLevel, isDark, after,
|
||||
mmsi, zoneCode, riskLevel, isDark,
|
||||
mmsiPrefix, minRiskScore, minFishingPct, after,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||
).map(VesselAnalysisResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
|
||||
* - hours: 윈도우 (기본 1시간)
|
||||
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public AnalysisStatsResponse getStats(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(required = false) String mmsiPrefix
|
||||
) {
|
||||
return service.getStats(hours, mmsiPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* prediction 자동 어구 탐지 결과 목록.
|
||||
* gear_code/gear_judgment NOT NULL 인 row만 MMSI 중복 제거 후 반환.
|
||||
*/
|
||||
@GetMapping("/gear-detections")
|
||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
||||
public Page<GearDetectionResponse> listGearDetections(
|
||||
@RequestParam(defaultValue = "1") int hours,
|
||||
@RequestParam(required = false) String mmsiPrefix,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size
|
||||
) {
|
||||
return service.getGearDetections(hours, mmsiPrefix,
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
|
||||
).map(GearDetectionResponse::from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과 (features 포함).
|
||||
*/
|
||||
|
||||
@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@ -57,4 +58,61 @@ public interface VesselAnalysisRepository
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestTransshipSuspects(
|
||||
@Param("after") OffsetDateTime after, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
|
||||
* mmsiPrefix 가 null 이면 전체, 아니면 LIKE ':prefix%'.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT v FROM VesselAnalysisResult v
|
||||
WHERE v.gearCode IS NOT NULL
|
||||
AND v.gearJudgment IS NOT NULL
|
||||
AND v.analyzedAt > :after
|
||||
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
|
||||
AND v.analyzedAt = (
|
||||
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
|
||||
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
|
||||
)
|
||||
ORDER BY v.analyzedAt DESC
|
||||
""")
|
||||
Page<VesselAnalysisResult> findLatestGearDetections(
|
||||
@Param("after") OffsetDateTime after,
|
||||
@Param("mmsiPrefix") String mmsiPrefix,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
|
||||
* mmsiPrefix 가 null 이면 전체.
|
||||
* 반환 Map 키: total, dark_count, spoofing_count, transship_count,
|
||||
* critical_count, high_count, medium_count, low_count,
|
||||
* territorial_count, contiguous_count, eez_count,
|
||||
* fishing_count, avg_risk_score
|
||||
*/
|
||||
@Query(value = """
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (mmsi) *
|
||||
FROM kcg.vessel_analysis_results
|
||||
WHERE analyzed_at > :after
|
||||
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
|
||||
ORDER BY mmsi, analyzed_at DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
|
||||
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
|
||||
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
|
||||
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
|
||||
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
|
||||
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
|
||||
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
|
||||
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
|
||||
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
|
||||
FROM latest
|
||||
""", nativeQuery = true)
|
||||
Map<String, Object> aggregateStats(
|
||||
@Param("after") OffsetDateTime after,
|
||||
@Param("mmsiPrefix") String mmsiPrefix);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -16,6 +17,7 @@ public record VesselAnalysisResponse(
|
||||
String vesselType,
|
||||
BigDecimal confidence,
|
||||
BigDecimal fishingPct,
|
||||
Integer clusterId,
|
||||
String season,
|
||||
// 위치
|
||||
Double lat,
|
||||
@ -24,11 +26,14 @@ public record VesselAnalysisResponse(
|
||||
BigDecimal distToBaselineNm,
|
||||
// 행동
|
||||
String activityState,
|
||||
BigDecimal ucafScore,
|
||||
BigDecimal ucftScore,
|
||||
// 위협
|
||||
Boolean isDark,
|
||||
Integer gapDurationMin,
|
||||
String darkPattern,
|
||||
BigDecimal spoofingScore,
|
||||
BigDecimal bd09OffsetM,
|
||||
Integer speedJumpCount,
|
||||
// 환적
|
||||
Boolean transshipSuspect,
|
||||
@ -45,6 +50,7 @@ public record VesselAnalysisResponse(
|
||||
String gearCode,
|
||||
String gearJudgment,
|
||||
String permitStatus,
|
||||
List<String> violationCategories,
|
||||
// features
|
||||
Map<String, Object> features
|
||||
) {
|
||||
@ -56,16 +62,20 @@ public record VesselAnalysisResponse(
|
||||
e.getVesselType(),
|
||||
e.getConfidence(),
|
||||
e.getFishingPct(),
|
||||
e.getClusterId(),
|
||||
e.getSeason(),
|
||||
e.getLat(),
|
||||
e.getLon(),
|
||||
e.getZoneCode(),
|
||||
e.getDistToBaselineNm(),
|
||||
e.getActivityState(),
|
||||
e.getUcafScore(),
|
||||
e.getUcftScore(),
|
||||
e.getIsDark(),
|
||||
e.getGapDurationMin(),
|
||||
e.getDarkPattern(),
|
||||
e.getSpoofingScore(),
|
||||
e.getBd09OffsetM(),
|
||||
e.getSpeedJumpCount(),
|
||||
e.getTransshipSuspect(),
|
||||
e.getTransshipPairMmsi(),
|
||||
@ -78,6 +88,7 @@ public record VesselAnalysisResponse(
|
||||
e.getGearCode(),
|
||||
e.getGearJudgment(),
|
||||
e.getPermitStatus(),
|
||||
e.getViolationCategories(),
|
||||
e.getFeatures()
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@ -125,6 +126,10 @@ public class VesselAnalysisResult {
|
||||
@Column(name = "permit_status", length = 20)
|
||||
private String permitStatus;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "violation_categories", columnDefinition = "text[]")
|
||||
private List<String> violationCategories;
|
||||
|
||||
// features JSONB
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", columnDefinition = "jsonb")
|
||||
|
||||
@ -7,8 +7,10 @@ import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 서비스.
|
||||
@ -26,9 +28,10 @@ public class VesselAnalysisService {
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getAnalysisResults(
|
||||
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
|
||||
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
|
||||
OffsetDateTime after, Pageable pageable
|
||||
) {
|
||||
Specification<VesselAnalysisResult> spec = Specification.where(null);
|
||||
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
|
||||
|
||||
if (after != null) {
|
||||
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
|
||||
@ -36,6 +39,10 @@ public class VesselAnalysisService {
|
||||
if (mmsi != null && !mmsi.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
|
||||
}
|
||||
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
|
||||
final String prefix = mmsiPrefix;
|
||||
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
|
||||
}
|
||||
if (zoneCode != null && !zoneCode.isBlank()) {
|
||||
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
|
||||
}
|
||||
@ -45,10 +52,66 @@ public class VesselAnalysisService {
|
||||
if (isDark != null && isDark) {
|
||||
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
|
||||
}
|
||||
if (minRiskScore != null) {
|
||||
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
|
||||
}
|
||||
if (minFishingPct != null) {
|
||||
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
|
||||
}
|
||||
|
||||
return repository.findAll(spec, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* MMSI별 최신 row 기준 집계 (단일 쿼리).
|
||||
*/
|
||||
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
|
||||
OffsetDateTime windowEnd = OffsetDateTime.now();
|
||||
OffsetDateTime windowStart = windowEnd.minusHours(hours);
|
||||
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
|
||||
|
||||
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
|
||||
return new AnalysisStatsResponse(
|
||||
longOf(row, "total"),
|
||||
longOf(row, "dark_count"),
|
||||
longOf(row, "spoofing_count"),
|
||||
longOf(row, "transship_count"),
|
||||
longOf(row, "critical_count"),
|
||||
longOf(row, "high_count"),
|
||||
longOf(row, "medium_count"),
|
||||
longOf(row, "low_count"),
|
||||
longOf(row, "territorial_count"),
|
||||
longOf(row, "contiguous_count"),
|
||||
longOf(row, "eez_count"),
|
||||
longOf(row, "fishing_count"),
|
||||
bigDecimalOf(row, "avg_risk_score"),
|
||||
windowStart,
|
||||
windowEnd
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* prediction 자동 어구 탐지 결과 목록.
|
||||
*/
|
||||
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
|
||||
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
|
||||
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
|
||||
return repository.findLatestGearDetections(after, prefix, pageable);
|
||||
}
|
||||
|
||||
private static long longOf(Map<String, Object> row, String key) {
|
||||
Object v = row.get(key);
|
||||
if (v == null) return 0L;
|
||||
return ((Number) v).longValue();
|
||||
}
|
||||
|
||||
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
|
||||
Object v = row.get(key);
|
||||
if (v == null) return BigDecimal.ZERO;
|
||||
if (v instanceof BigDecimal bd) return bd;
|
||||
return new BigDecimal(v.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과.
|
||||
*/
|
||||
|
||||
@ -4,6 +4,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
|
||||
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
|
||||
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
|
||||
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
|
||||
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
|
||||
- **VesselAnalysisResponse 필드 확장** — `violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
|
||||
- **prediction 분석 시점 좌표 저장** — `AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
|
||||
|
||||
## [2026-04-16.5]
|
||||
|
||||
### 변경
|
||||
|
||||
@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PageContainer } from '@shared/components/layout';
|
||||
import {
|
||||
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
Search, Clock, ChevronRight, ChevronLeft, Cloud,
|
||||
Eye, AlertTriangle, Radio, RotateCcw,
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
@ -14,22 +14,23 @@ import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GearIdentification } from './GearIdentification';
|
||||
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
||||
import { VesselMiniMap } from './components/VesselMiniMap';
|
||||
import { VesselAnomalyPanel } from './components/VesselAnomalyPanel';
|
||||
import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly';
|
||||
import { PieChart as EcPieChart } from '@lib/charts';
|
||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
filterTransshipSuspects,
|
||||
type VesselAnalysisItem,
|
||||
type VesselAnalysisStats,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
getAnalysisStats,
|
||||
getAnalysisVessels,
|
||||
getAnalysisHistory,
|
||||
type AnalysisStats,
|
||||
type VesselAnalysis,
|
||||
} from '@/services/analysisApi';
|
||||
import { toVesselItem } from '@/services/analysisAdapter';
|
||||
|
||||
// ─── 중국 MMSI prefix ─────────────
|
||||
const CHINA_MMSI_PREFIX = '412';
|
||||
|
||||
function isChinaVessel(mmsi: string): boolean {
|
||||
return mmsi.startsWith(CHINA_MMSI_PREFIX);
|
||||
}
|
||||
|
||||
// ─── 특이운항 선박 리스트 타입 ────────────────
|
||||
type VesselStatus = '의심' | '양호' | '경고';
|
||||
interface VesselItem {
|
||||
@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus {
|
||||
|
||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
||||
const score = item.algorithms.riskScore.score;
|
||||
const vt = item.classification.vesselType;
|
||||
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
||||
return {
|
||||
id: String(idx + 1),
|
||||
mmsi: item.mmsi,
|
||||
callSign: '-',
|
||||
channel: '',
|
||||
source: 'AIS',
|
||||
name: item.classification.vesselType || item.mmsi,
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
|
||||
name: hasType ? vt : '중국어선',
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
|
||||
country: 'China',
|
||||
status: deriveVesselStatus(score),
|
||||
riskPct: score,
|
||||
@ -202,10 +205,14 @@ export function ChinaFishing() {
|
||||
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState('');
|
||||
|
||||
// API state
|
||||
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
|
||||
const [topVessels, setTopVessels] = useState<VesselAnalysisItem[]>([]);
|
||||
const [apiStats, setApiStats] = useState<AnalysisStats | null>(null);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [apiLoading, setApiLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState('');
|
||||
@ -214,10 +221,18 @@ export function ChinaFishing() {
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setServiceAvailable(res.serviceAvailable);
|
||||
setAllItems(res.items);
|
||||
setApiStats(res.stats);
|
||||
const [stats, topPage] = await Promise.all([
|
||||
getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }),
|
||||
getAnalysisVessels({
|
||||
hours: 1,
|
||||
mmsiPrefix: CHINA_MMSI_PREFIX,
|
||||
minRiskScore: 40,
|
||||
size: 20,
|
||||
}),
|
||||
]);
|
||||
setApiStats(stats);
|
||||
setTopVessels(topPage.content.map(toVesselItem));
|
||||
setServiceAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||
setServiceAvailable(false);
|
||||
@ -228,55 +243,77 @@ export function ChinaFishing() {
|
||||
|
||||
useEffect(() => { loadApi(); }, [loadApi]);
|
||||
|
||||
// 중국어선 필터
|
||||
const chinaVessels = useMemo(
|
||||
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
|
||||
[allItems],
|
||||
// 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터)
|
||||
useEffect(() => {
|
||||
if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; }
|
||||
let cancelled = false;
|
||||
setHistoryLoading(true); setHistoryError('');
|
||||
getAnalysisHistory(selectedMmsi, 24)
|
||||
.then((rows) => { if (!cancelled) setHistory(rows); })
|
||||
.catch((e: unknown) => {
|
||||
if (cancelled) return;
|
||||
setHistory([]);
|
||||
setHistoryError(e instanceof Error ? e.message : '이력 조회 실패');
|
||||
})
|
||||
.finally(() => { if (!cancelled) setHistoryLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedMmsi]);
|
||||
|
||||
const anomalySegments = useMemo(
|
||||
() => groupAnomaliesToSegments(extractAnomalies(history)),
|
||||
[history],
|
||||
);
|
||||
|
||||
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
|
||||
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
|
||||
// ─ 파생 계산 (서버 집계 우선) ─
|
||||
// Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT.
|
||||
// topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용).
|
||||
const countersRow1 = useMemo(() => {
|
||||
if (!apiStats) return [];
|
||||
return [
|
||||
{ label: '통합', count: apiStats.total, color: '#6b7280' },
|
||||
{ label: 'AIS', count: apiStats.total, color: '#3b82f6' },
|
||||
{ label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' },
|
||||
{ label: '어업선', count: apiStats.fishingCount, color: '#10b981' },
|
||||
];
|
||||
}, [apiStats]);
|
||||
|
||||
// 센서 카운터 (API 기반)
|
||||
const countersRow1 = useMemo(() => [
|
||||
{ label: '통합', count: allItems.length, color: '#6b7280' },
|
||||
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
|
||||
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
|
||||
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
|
||||
], [allItems]);
|
||||
const countersRow2 = useMemo(() => {
|
||||
if (!apiStats) return [];
|
||||
return [
|
||||
{ label: '중국어선', count: apiStats.total, color: '#f97316' },
|
||||
{ label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' },
|
||||
{ label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' },
|
||||
{ label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' },
|
||||
];
|
||||
}, [apiStats]);
|
||||
|
||||
const countersRow2 = useMemo(() => [
|
||||
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
|
||||
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
|
||||
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
|
||||
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
|
||||
], [chinaVessels, chinaDark, chinaTransship]);
|
||||
|
||||
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
|
||||
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||
const vesselList: VesselItem[] = useMemo(
|
||||
() => chinaVessels
|
||||
.filter((i) => i.algorithms.riskScore.score >= 40)
|
||||
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
|
||||
.slice(0, 20)
|
||||
.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[chinaVessels],
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[topVessels],
|
||||
);
|
||||
|
||||
// 위험도별 분포 (도넛 차트용)
|
||||
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||
const riskDistribution = useMemo(() => {
|
||||
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
|
||||
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
|
||||
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
|
||||
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
|
||||
return { critical, high, medium, low, total: chinaVessels.length };
|
||||
}, [chinaVessels]);
|
||||
if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
return {
|
||||
critical: apiStats.criticalCount,
|
||||
high: apiStats.highCount,
|
||||
medium: apiStats.mediumCount,
|
||||
low: apiStats.lowCount,
|
||||
total: apiStats.total,
|
||||
};
|
||||
}, [apiStats]);
|
||||
|
||||
// 안전도 지수 계산
|
||||
// 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일)
|
||||
const safetyIndex = useMemo(() => {
|
||||
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
|
||||
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
|
||||
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
|
||||
}, [chinaVessels]);
|
||||
const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0;
|
||||
if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 };
|
||||
return {
|
||||
risk: Number((avgRisk / 10).toFixed(2)),
|
||||
safety: Number(((100 - avgRisk) / 10).toFixed(2)),
|
||||
};
|
||||
}, [apiStats]);
|
||||
|
||||
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
|
||||
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
|
||||
@ -319,7 +356,7 @@ export function ChinaFishing() {
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다</span>
|
||||
<span>분석 API 호출 실패 - 잠시 후 다시 시도해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -371,7 +408,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
|
||||
<span>해역 전체 통항량</span>
|
||||
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
|
||||
<span className="text-lg font-extrabold text-heading">{(apiStats?.total ?? 0).toLocaleString()}</span>
|
||||
<span className="text-hint">(척)</span>
|
||||
</div>
|
||||
|
||||
@ -422,12 +459,15 @@ export function ChinaFishing() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 관심영역 안전도 */}
|
||||
{/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
|
||||
<div className="col-span-4">
|
||||
<Card className="bg-surface-raised border-slate-700/30 h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<option>영역 A</option>
|
||||
<option>영역 B</option>
|
||||
@ -453,7 +493,14 @@ export function ChinaFishing() {
|
||||
<span className="text-green-400 font-bold ml-auto">정상</span>
|
||||
</div>
|
||||
</div>
|
||||
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
|
||||
<CircleGauge
|
||||
value={
|
||||
apiStats && apiStats.total > 0
|
||||
? Number(((1 - apiStats.criticalCount / Math.max(apiStats.total, 1)) * 100).toFixed(1))
|
||||
: 100
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -467,21 +514,30 @@ export function ChinaFishing() {
|
||||
<div className="col-span-5">
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 헤더 */}
|
||||
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{vesselTabs.map((tab) => (
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => setVesselTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
vesselTab === tab
|
||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{vesselTabs.map((tab) => {
|
||||
const disabled = tab !== '특이운항';
|
||||
return (
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => !disabled && setVesselTab(tab)}
|
||||
disabled={disabled}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
vesselTab === tab
|
||||
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
|
||||
: disabled
|
||||
? 'text-hint opacity-50 cursor-not-allowed'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{disabled && (
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선박 목록 */}
|
||||
@ -491,28 +547,34 @@ export function ChinaFishing() {
|
||||
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
|
||||
</div>
|
||||
)}
|
||||
{vesselList.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
|
||||
>
|
||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
||||
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>{v.country}</span>
|
||||
{vesselList.map((v) => {
|
||||
const selected = v.mmsi === selectedMmsi;
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
onClick={() => setSelectedMmsi(selected ? null : v.mmsi)}
|
||||
className={`flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 transition-colors cursor-pointer group ${
|
||||
selected ? 'bg-blue-500/10 hover:bg-blue-500/15' : 'hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
<StatusRing status={v.status} riskPct={v.riskPct} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
|
||||
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
|
||||
<span>출처 | <span className="text-label">{v.source}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
||||
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>{v.country}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -524,19 +586,20 @@ export function ChinaFishing() {
|
||||
{/* 통계 차트 */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-0">
|
||||
{/* 탭 */}
|
||||
{/* 탭 — 월별 집계 API 미연동 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{statsTabs.map((tab) => (
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => setStatsTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
|
||||
statsTab === tab
|
||||
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
|
||||
: 'text-hint hover:text-label'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
<Badge intent="warning" size="xs" className="font-normal">준비중</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -594,11 +657,14 @@ export function ChinaFishing() {
|
||||
{/* 하단 카드 3개 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
{/* 최근 위성영상 분석 */}
|
||||
{/* 최근 위성영상 분석 (VIIRS 수집 파이프라인 미구축 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
@ -618,11 +684,14 @@ export function ChinaFishing() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기상 예보 */}
|
||||
{/* 기상 예보 (기상청 API 미연동 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -641,11 +710,14 @@ export function ChinaFishing() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* VTS연계 현황 */}
|
||||
{/* VTS연계 현황 (VTS 시스템 연계 미구축 → 데모) */}
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
||||
<Badge intent="warning" size="xs" className="font-normal">데모 데이터</Badge>
|
||||
</div>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
@ -677,6 +749,28 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 선택 시: 궤적 미니맵 + 특이운항 판별 구간 상세 (최근 24h 분석 이력 기반) ── */}
|
||||
{selectedMmsi && (
|
||||
<div className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-5">
|
||||
<VesselMiniMap
|
||||
mmsi={selectedMmsi}
|
||||
vesselName={vesselList.find((v) => v.mmsi === selectedMmsi)?.name}
|
||||
segments={anomalySegments}
|
||||
onClose={() => setSelectedMmsi(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-7">
|
||||
<VesselAnomalyPanel
|
||||
segments={anomalySegments}
|
||||
loading={historyLoading}
|
||||
error={historyError}
|
||||
totalHistoryCount={history.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search, Anchor, Ship, Eye, AlertTriangle, CheckCircle, XCircle,
|
||||
ChevronRight, ChevronDown, Info, Shield, Radar, Target, Waves,
|
||||
ArrowRight, Flag, Zap, HelpCircle
|
||||
Search, Anchor, Ship, AlertTriangle, CheckCircle, XCircle,
|
||||
ChevronRight, Info, Shield, Radar, Target, Waves,
|
||||
ArrowRight, Zap, HelpCircle, Loader2, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
|
||||
|
||||
// ─── 판별 기준 데이터 ─────────────────
|
||||
|
||||
@ -619,11 +623,25 @@ function GearComparisonTable() {
|
||||
|
||||
// ─── 메인 페이지 ──────────────────────
|
||||
|
||||
// gearCode → gearCategory 매핑 (자동탐지 → 입력 폼 프리필용)
|
||||
const GEAR_CODE_CATEGORY: Record<string, GearType> = {
|
||||
C21: 'trawl', C22: 'trawl', PT: 'trawl', OT: 'trawl', TRAWL: 'trawl',
|
||||
C23: 'purseSeine', PS: 'purseSeine', PURSE: 'purseSeine',
|
||||
C25: 'gillnet', GN: 'gillnet', GNS: 'gillnet', GND: 'gillnet', GILLNET: 'gillnet',
|
||||
C40: 'unknown', FC: 'unknown',
|
||||
};
|
||||
|
||||
const ZONE_CODE_SEA_AREA: Record<string, string> = {
|
||||
ZONE_I: 'I', ZONE_II: 'II', ZONE_III: 'III', ZONE_IV: 'IV',
|
||||
TERRITORIAL_SEA: '영해', CONTIGUOUS_ZONE: '접속수역', EEZ_OR_BEYOND: 'EEZ 외',
|
||||
};
|
||||
|
||||
export function GearIdentification() {
|
||||
const { t } = useTranslation('detection');
|
||||
const [input, setInput] = useState<GearInput>(DEFAULT_INPUT);
|
||||
const [result, setResult] = useState<IdentificationResult | null>(null);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [autoSelected, setAutoSelected] = useState<GearDetection | null>(null);
|
||||
|
||||
const update = <K extends keyof GearInput>(key: K, value: GearInput[K]) => {
|
||||
setInput((prev) => ({ ...prev, [key]: value }));
|
||||
@ -636,6 +654,60 @@ export function GearIdentification() {
|
||||
const resetForm = () => {
|
||||
setInput(DEFAULT_INPUT);
|
||||
setResult(null);
|
||||
setAutoSelected(null);
|
||||
};
|
||||
|
||||
// 자동탐지 row 선택 → 입력 폼 프리필 + 결과 패널에 근거 프리셋
|
||||
const applyAutoDetection = (v: GearDetection) => {
|
||||
const code = (v.gearCode || '').toUpperCase();
|
||||
const category = GEAR_CODE_CATEGORY[code] ?? 'unknown';
|
||||
const seaArea = v.zoneCode ? ZONE_CODE_SEA_AREA[v.zoneCode] ?? '' : '';
|
||||
|
||||
setInput({
|
||||
...DEFAULT_INPUT,
|
||||
gearCategory: category,
|
||||
permitCode: code,
|
||||
mmsiPrefix: v.mmsi.slice(0, 3),
|
||||
seaArea,
|
||||
discoveryDate: v.analyzedAt.slice(0, 10),
|
||||
});
|
||||
setAutoSelected(v);
|
||||
|
||||
// 자동탐지 근거를 결과 패널에 프리셋
|
||||
const reasons: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
reasons.push(`MMSI ${v.mmsi} · ${v.vesselType ?? 'UNKNOWN'} · prediction 자동탐지`);
|
||||
reasons.push(`어구 코드: ${code} · 판정: ${GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}`);
|
||||
if (v.permitStatus) {
|
||||
reasons.push(`허가 상태: ${PERMIT_STATUS_LABEL[v.permitStatus] ?? v.permitStatus}`);
|
||||
}
|
||||
(v.violationCategories ?? []).forEach((cat) => warnings.push(`위반 카테고리: ${cat}`));
|
||||
if (v.gearJudgment === 'CLOSED_SEASON_FISHING') warnings.push('금어기 조업 의심 — 허가기간 외 조업');
|
||||
if (v.gearJudgment === 'UNREGISTERED_GEAR') warnings.push('미등록 어구 — fleet_vessels 미매칭');
|
||||
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
|
||||
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
|
||||
|
||||
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
|
||||
? v.riskLevel
|
||||
: 'LOW';
|
||||
|
||||
setResult({
|
||||
origin: 'china',
|
||||
confidence: v.riskScore && v.riskScore >= 70 ? 'high' : v.riskScore && v.riskScore >= 40 ? 'medium' : 'low',
|
||||
gearType: category,
|
||||
gearSubType: code,
|
||||
gbCode: '',
|
||||
koreaName: '',
|
||||
reasons,
|
||||
warnings,
|
||||
actionRequired: alertLevel === 'CRITICAL' || alertLevel === 'HIGH'
|
||||
? '현장 확인 및 보강 정보 입력 후 최종 판별 실행'
|
||||
: '추가 정보 입력 후 판별 실행',
|
||||
alertLevel,
|
||||
});
|
||||
|
||||
// 입력 폼 영역으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -665,6 +737,31 @@ export function GearIdentification() {
|
||||
{/* 레퍼런스 테이블 (토글) */}
|
||||
{showReference && <GearComparisonTable />}
|
||||
|
||||
{/* 자동탐지 선택 힌트 */}
|
||||
{autoSelected && (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg border border-cyan-500/30 bg-cyan-500/5 text-[11px]">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge intent="info" size="sm">자동탐지 연계</Badge>
|
||||
<span className="text-hint">MMSI</span>
|
||||
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
|
||||
<span className="text-hint">·</span>
|
||||
<span className="text-hint">어구</span>
|
||||
<span className="font-mono text-label">{autoSelected.gearCode}</span>
|
||||
<span className="text-hint">·</span>
|
||||
<Badge intent={GEAR_JUDGMENT_INTENT[autoSelected.gearJudgment] ?? 'muted'} size="sm">
|
||||
{GEAR_JUDGMENT_LABEL[autoSelected.gearJudgment] ?? autoSelected.gearJudgment}
|
||||
</Badge>
|
||||
<span className="text-hint ml-2">하단 자동탐지 목록에서 클릭한 선박 정보가 아래 폼에 프리필되었습니다. 추가 정보 입력 후 판별 실행하세요.</span>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
|
||||
className="text-[10px] text-hint hover:text-heading shrink-0"
|
||||
>
|
||||
해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* ── 좌측: 입력 폼 ── */}
|
||||
<div className="col-span-5 space-y-3">
|
||||
@ -1002,6 +1099,155 @@ and vessel_spacing < 1000 # m
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 자동탐지 결과 (prediction 기반) */}
|
||||
<AutoGearDetectionSection
|
||||
onSelect={applyAutoDetection}
|
||||
selectedId={autoSelected?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 자동탐지 결과 섹션 ─────────────────
|
||||
|
||||
const GEAR_JUDGMENT_LABEL: Record<string, string> = {
|
||||
CLOSED_SEASON_FISHING: '금어기 조업',
|
||||
UNREGISTERED_GEAR: '미등록 어구',
|
||||
GEAR_MISMATCH: '어구 불일치',
|
||||
MULTIPLE_VIOLATION: '복합 위반',
|
||||
NORMAL: '정상',
|
||||
};
|
||||
|
||||
const GEAR_JUDGMENT_INTENT: Record<string, 'critical' | 'warning' | 'muted' | 'success'> = {
|
||||
CLOSED_SEASON_FISHING: 'critical',
|
||||
UNREGISTERED_GEAR: 'warning',
|
||||
GEAR_MISMATCH: 'warning',
|
||||
MULTIPLE_VIOLATION: 'critical',
|
||||
NORMAL: 'success',
|
||||
};
|
||||
|
||||
const PERMIT_STATUS_LABEL: Record<string, string> = {
|
||||
PERMITTED: '허가',
|
||||
UNPERMITTED: '미허가',
|
||||
UNKNOWN: '확인불가',
|
||||
};
|
||||
|
||||
function AutoGearDetectionSection({
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: {
|
||||
onSelect: (v: GearDetection) => void;
|
||||
selectedId: number | null;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
||||
const [items, setItems] = useState<GearDetection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const page = await getGearDetections({ hours: 1, mmsiPrefix: '412', size: 50 });
|
||||
setItems(page.content);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : '조회 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-cyan-500" />
|
||||
최근 자동탐지 결과 (prediction, 최근 1시간 중국 선박)
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL · 행 클릭 시 상단 입력 폼에 프리필
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={load}
|
||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
{!loading && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface-overlay text-hint">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left">MMSI</th>
|
||||
<th className="px-2 py-1.5 text-left">선박유형</th>
|
||||
<th className="px-2 py-1.5 text-center">어구코드</th>
|
||||
<th className="px-2 py-1.5 text-center">판정</th>
|
||||
<th className="px-2 py-1.5 text-center">허가</th>
|
||||
<th className="px-2 py-1.5 text-left">해역</th>
|
||||
<th className="px-2 py-1.5 text-center">위험도</th>
|
||||
<th className="px-2 py-1.5 text-right">점수</th>
|
||||
<th className="px-2 py-1.5 text-left">위반</th>
|
||||
<th className="px-2 py-1.5 text-left">갱신</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint">자동탐지된 어구 위반 결과가 없습니다.</td></tr>
|
||||
)}
|
||||
{items.map((v) => {
|
||||
const selected = v.id === selectedId;
|
||||
return (
|
||||
<tr
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v)}
|
||||
className={`border-t border-border cursor-pointer transition-colors ${
|
||||
selected ? 'bg-cyan-500/10 hover:bg-cyan-500/15' : 'hover:bg-surface-overlay/50'
|
||||
}`}
|
||||
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
|
||||
>
|
||||
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
|
||||
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge intent={GEAR_JUDGMENT_INTENT[v.gearJudgment] ?? 'muted'} size="sm">
|
||||
{GEAR_JUDGMENT_LABEL[v.gearJudgment] ?? v.gearJudgment}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center text-[10px] text-muted-foreground">
|
||||
{PERMIT_STATUS_LABEL[v.permitStatus ?? ''] ?? v.permitStatus ?? '-'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||
{v.zoneCode ? getZoneCodeLabel(v.zoneCode, t, lang) : '-'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.riskLevel ? (
|
||||
<Badge intent={getAlertLevelIntent(v.riskLevel)} size="sm">{v.riskLevel}</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.riskScore ?? '-'}</td>
|
||||
<td className="px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||
{(v.violationCategories ?? []).join(', ') || '-'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||
{v.analyzedAt ? formatDateTime(v.analyzedAt) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,16 +3,18 @@ import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
type VesselAnalysisStats,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
getAnalysisVessels,
|
||||
getDarkVessels,
|
||||
getTransshipSuspects,
|
||||
} from '@/services/analysisApi';
|
||||
import { toVesselItem } from '@/services/analysisAdapter';
|
||||
|
||||
/**
|
||||
* iran 백엔드의 실시간 vessel analysis 결과를 표시.
|
||||
* vessel_analysis_results 기반 실시간 선박 분석 테이블.
|
||||
* - mode: 'dark' (Dark Vessel만) / 'spoofing' (스푸핑 의심) / 'transship' (전재) / 'all'
|
||||
* - 위험도 통계 + 필터링된 선박 테이블
|
||||
* - 위험도 통계 카드 + 상위 위험도순 선박 테이블
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
@ -21,8 +23,6 @@ interface Props {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
TERRITORIAL_SEA: '영해',
|
||||
CONTIGUOUS_ZONE: '접속수역',
|
||||
@ -33,9 +33,15 @@ const ZONE_LABELS: Record<string, string> = {
|
||||
ZONE_IV: '특정해역 IV',
|
||||
};
|
||||
|
||||
const ENDPOINT_LABEL: Record<Props['mode'], string> = {
|
||||
all: 'GET /api/analysis/vessels',
|
||||
dark: 'GET /api/analysis/dark',
|
||||
transship: 'GET /api/analysis/transship',
|
||||
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
|
||||
};
|
||||
|
||||
export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@ -44,24 +50,27 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setItems(res.items);
|
||||
setStats(res.stats);
|
||||
setAvailable(res.serviceAvailable);
|
||||
const page = mode === 'dark'
|
||||
? await getDarkVessels({ hours: 1, size: 200 })
|
||||
: mode === 'transship'
|
||||
? await getTransshipSuspects({ hours: 1, size: 200 })
|
||||
: await getAnalysisVessels({ hours: 1, size: 200 });
|
||||
setItems(page.content.map(toVesselItem));
|
||||
setAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
setAvailable(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = items;
|
||||
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
|
||||
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
||||
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
|
||||
// spoofing 은 /analysis/vessels 결과를 클라에서 임계값 필터
|
||||
if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
||||
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
|
||||
return result;
|
||||
}, [items, mode, zoneFilter]);
|
||||
@ -71,6 +80,20 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
[filtered],
|
||||
);
|
||||
|
||||
// 통계 카드: mode 로 필터된 items 기반으로 집계해야 상단 숫자와 하단 리스트가 정합
|
||||
const stats = useMemo(() => {
|
||||
const modeFilteredItems = mode === 'spoofing'
|
||||
? items.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3)
|
||||
: items;
|
||||
return {
|
||||
total: modeFilteredItems.length,
|
||||
criticalCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length,
|
||||
highCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'HIGH').length,
|
||||
mediumCount: modeFilteredItems.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length,
|
||||
darkCount: modeFilteredItems.filter((i) => i.algorithms.darkVessel.isDark).length,
|
||||
};
|
||||
}, [items, mode]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
@ -81,7 +104,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
||||
{ENDPOINT_LABEL[mode]} · prediction 5분 주기 분석 결과
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -99,17 +122,15 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
|
||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
||||
</div>
|
||||
)}
|
||||
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<StatBox label="전체" value={stats.total} color="text-heading" />
|
||||
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
|
||||
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
|
||||
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
|
||||
<StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
|
||||
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
@ -187,11 +208,11 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
function StatBox({ label, value, color }: { label: string; value: number | undefined; color: string }) {
|
||||
return (
|
||||
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
||||
<div className="text-[9px] text-hint">{label}</div>
|
||||
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
|
||||
<div className={`text-lg font-bold ${color}`}>{(value ?? 0).toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 선박 24h 특이운항 판별 구간 상세 패널.
|
||||
* 연속된 동일 카테고리 신호는 1개 구간으로 병합하여 시작~종료 시각과 함께 표시한다.
|
||||
*/
|
||||
import { Loader2, AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import {
|
||||
type AnomalySegment,
|
||||
getAnomalyCategoryIntent,
|
||||
getAnomalyCategoryLabel,
|
||||
} from './vesselAnomaly';
|
||||
|
||||
interface Props {
|
||||
segments: AnomalySegment[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
totalHistoryCount: number;
|
||||
}
|
||||
|
||||
function formatDuration(min: number): string {
|
||||
if (min <= 0) return '단일 샘플';
|
||||
if (min < 60) return `${min}분`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m === 0 ? `${h}시간` : `${h}시간 ${m}분`;
|
||||
}
|
||||
|
||||
export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount }: Props) {
|
||||
const criticalCount = segments.filter((s) => s.severity === 'critical').length;
|
||||
const warningCount = segments.filter((s) => s.severity === 'warning').length;
|
||||
const infoCount = segments.filter((s) => s.severity === 'info').length;
|
||||
const totalSamples = segments.reduce((sum, s) => sum + s.pointCount, 0);
|
||||
|
||||
return (
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">특이운항 판별 구간</span>
|
||||
{segments.length > 0 && (
|
||||
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
|
||||
· {segments.length}구간
|
||||
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
|
||||
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
|
||||
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-hint">
|
||||
최근 24h 분석 {totalHistoryCount}건 중 {totalSamples}건 포함
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-400">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && segments.length === 0 && totalHistoryCount > 0 && (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-hint">
|
||||
최근 24h 동안 Dark / Spoofing / 환적 / 어구위반 / 고위험 신호가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalHistoryCount === 0 && (
|
||||
<div className="px-3 py-6 text-center text-[11px] text-hint">
|
||||
분석 이력이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && segments.length > 0 && (
|
||||
<div className="max-h-[360px] overflow-y-auto space-y-1.5 pr-1">
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`rounded border px-2.5 py-2 text-[10px] ${
|
||||
s.severity === 'critical'
|
||||
? 'border-red-500/30 bg-red-500/5'
|
||||
: s.severity === 'warning'
|
||||
? 'border-orange-500/30 bg-orange-500/5'
|
||||
: 'border-blue-500/30 bg-blue-500/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-label">
|
||||
{formatDateTime(s.startTime)}
|
||||
{s.startTime !== s.endTime && (
|
||||
<>
|
||||
<span className="text-hint mx-1">→</span>
|
||||
{formatDateTime(s.endTime)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-hint text-[9px]">
|
||||
<span className="text-muted-foreground">{formatDuration(s.durationMin)}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>{s.pointCount}회 연속 감지</span>
|
||||
{s.representativeLat != null && s.representativeLon != null && (
|
||||
<>
|
||||
<span className="mx-1">·</span>
|
||||
<span
|
||||
className="font-mono"
|
||||
title="판별 근거가 된 이벤트 발생 지점 (예: AIS 신호 단절 시작 좌표). 실제 분석 시각 위치는 지도의 색칠 궤적 구간을 확인하세요."
|
||||
>
|
||||
{s.representativeLat.toFixed(3)}°N, {s.representativeLon.toFixed(3)}°E
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-0.5">(이벤트 기준)</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap mb-1">
|
||||
{s.categories.map((c) => (
|
||||
<Badge key={c} intent={getAnomalyCategoryIntent(c)} size="xs" className="font-normal">
|
||||
{getAnomalyCategoryLabel(c)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] leading-snug">{s.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
259
frontend/src/features/detection/components/VesselMiniMap.tsx
Normal file
259
frontend/src/features/detection/components/VesselMiniMap.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 선박 궤적 미니맵 — 단일 MMSI 24h 항적 정적 표시.
|
||||
* fetchVesselTracks (signal-batch 프록시) 호출 → PathLayer 로 그림.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { Loader2, Ship, Clock, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { PathLayer, ScatterplotLayer } from 'deck.gl';
|
||||
import type { Layer } from 'deck.gl';
|
||||
import { BaseMap, type MapHandle } from '@lib/map';
|
||||
import { useMapLayers } from '@lib/map/hooks/useMapLayers';
|
||||
import { fetchVesselTracks, type VesselTrack } from '@/services/vesselAnalysisApi';
|
||||
import type { AnomalySegment } from './vesselAnomaly';
|
||||
|
||||
interface Props {
|
||||
mmsi: string;
|
||||
vesselName?: string;
|
||||
hoursBack?: number;
|
||||
segments?: AnomalySegment[];
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function fmt(ts: string | number): string {
|
||||
const n = typeof ts === 'string' ? parseInt(ts, 10) : ts;
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
const d = new Date(n * 1000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
|
||||
const mapRef = useRef<MapHandle | null>(null);
|
||||
const [track, setTrack] = useState<VesselTrack | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(''); setTrack(null);
|
||||
try {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - hoursBack * 3600 * 1000);
|
||||
const res = await fetchVesselTracks(
|
||||
[mmsi],
|
||||
start.toISOString(),
|
||||
end.toISOString(),
|
||||
);
|
||||
setTrack(res[0] ?? null);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : '궤적 조회 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mmsi, hoursBack]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 궤적 로드 후 bounds 로 지도 이동
|
||||
useEffect(() => {
|
||||
if (!track || track.geometry.length === 0) return;
|
||||
const map = mapRef.current?.map;
|
||||
if (!map) return;
|
||||
const lons = track.geometry.map((p) => p[0]);
|
||||
const lats = track.geometry.map((p) => p[1]);
|
||||
const w = Math.min(...lons), e = Math.max(...lons);
|
||||
const s = Math.min(...lats), n = Math.max(...lats);
|
||||
const span = Math.max(e - w, n - s);
|
||||
if (span < 0.001) {
|
||||
map.setCenter([(w + e) / 2, (s + n) / 2]);
|
||||
map.setZoom(11);
|
||||
} else {
|
||||
map.fitBounds([[w, s], [e, n]], { padding: 24, maxZoom: 11, duration: 0 });
|
||||
}
|
||||
}, [track]);
|
||||
|
||||
// segment 의 [startTime, endTime] 범위에 들어오는 AIS 궤적 포인트를 뽑아 severity 색 path로 덧그린다.
|
||||
// 이게 사용자가 '어떤 시간대 궤적이 특이운항으로 판별됐는지' 를 바로 알게 해주는 핵심 표시.
|
||||
const segmentPaths = useMemo((): Array<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }> => {
|
||||
if (!track || track.timestamps.length === 0 || segments.length === 0) return [];
|
||||
if (track.timestamps.length !== track.geometry.length) return [];
|
||||
const epochs = track.timestamps.map((t) => Number(t) * 1000);
|
||||
return segments
|
||||
.map((seg) => {
|
||||
const start = new Date(seg.startTime).getTime();
|
||||
const end = new Date(seg.endTime).getTime();
|
||||
const path: [number, number][] = [];
|
||||
for (let i = 0; i < epochs.length; i++) {
|
||||
if (epochs[i] >= start && epochs[i] <= end) path.push(track.geometry[i]);
|
||||
}
|
||||
return { id: seg.id, path, severity: seg.severity };
|
||||
})
|
||||
.filter((s) => s.path.length >= 2);
|
||||
}, [track, segments]);
|
||||
|
||||
useMapLayers(mapRef, (): Layer[] => {
|
||||
const layers: Layer[] = [];
|
||||
if (track && track.geometry.length >= 2) {
|
||||
layers.push(
|
||||
new PathLayer({
|
||||
id: `mini-track-${mmsi}`,
|
||||
data: [{ path: track.geometry }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [59, 130, 246, 140],
|
||||
getWidth: 2,
|
||||
widthUnits: 'pixels',
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (segmentPaths.length > 0) {
|
||||
layers.push(
|
||||
new PathLayer<{ id: string; path: [number, number][]; severity: AnomalySegment['severity'] }>({
|
||||
id: `mini-segment-paths-${mmsi}`,
|
||||
data: segmentPaths,
|
||||
getPath: (d) => d.path,
|
||||
getColor: (d) =>
|
||||
d.severity === 'critical' ? [239, 68, 68, 240]
|
||||
: d.severity === 'warning' ? [249, 115, 22, 230]
|
||||
: [59, 130, 246, 210],
|
||||
getWidth: 4,
|
||||
widthUnits: 'pixels',
|
||||
widthMinPixels: 3,
|
||||
jointRounded: true,
|
||||
capRounded: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (track && track.geometry.length >= 2) {
|
||||
layers.push(
|
||||
new PathLayer({
|
||||
id: `mini-track-head-${mmsi}`,
|
||||
data: [{ path: track.geometry.slice(-2) }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [239, 68, 68, 255],
|
||||
getWidth: 4,
|
||||
widthUnits: 'pixels',
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 이벤트 기준 좌표 (gap 시작점 등) — 분석 시각이 아니라 판별 근거가 된 과거 시점.
|
||||
// 반복 분석이 같은 좌표를 참조하는 경우가 많아 작게/반투명하게 표시한다.
|
||||
const geoSegments = segments.filter(
|
||||
(s): s is AnomalySegment & { representativeLat: number; representativeLon: number } =>
|
||||
s.representativeLat != null && s.representativeLon != null,
|
||||
);
|
||||
if (geoSegments.length > 0) {
|
||||
layers.push(
|
||||
new ScatterplotLayer<AnomalySegment & { representativeLat: number; representativeLon: number }>({
|
||||
id: `mini-segments-${mmsi}`,
|
||||
data: geoSegments,
|
||||
getPosition: (d) => [d.representativeLon, d.representativeLat],
|
||||
getRadius: 4,
|
||||
radiusUnits: 'pixels',
|
||||
radiusMinPixels: 4,
|
||||
radiusMaxPixels: 6,
|
||||
getFillColor: (d) =>
|
||||
d.severity === 'critical' ? [239, 68, 68, 180]
|
||||
: d.severity === 'warning' ? [249, 115, 22, 170]
|
||||
: [59, 130, 246, 160],
|
||||
getLineColor: [255, 255, 255, 220],
|
||||
lineWidthMinPixels: 1,
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return layers;
|
||||
}, [track, mmsi, segments, segmentPaths]);
|
||||
|
||||
const tsList = track?.timestamps ?? [];
|
||||
const startTs = tsList[0];
|
||||
const endTs = tsList[tsList.length - 1];
|
||||
|
||||
return (
|
||||
<Card className="bg-surface-raised border-slate-700/30">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
|
||||
{vesselName ?? mmsi}
|
||||
<span className="text-[9px] text-hint font-mono">{mmsi}</span>
|
||||
{segments.length > 0 && (
|
||||
<Badge intent="critical" size="xs" className="font-normal">
|
||||
특이 구간 {segments.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-hint">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
<span>{startTs ? fmt(startTs) : '-'}</span>
|
||||
<span>→</span>
|
||||
<span>{endTs ? fmt(endTs) : '-'}</span>
|
||||
<span className="ml-1 text-muted-foreground">· {track?.pointCount ?? 0} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
|
||||
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative h-80 rounded overflow-hidden border border-slate-700/30">
|
||||
<BaseMap ref={mapRef} height={320} interactive={true} zoom={7} />
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && track && track.geometry.length < 2 && (
|
||||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center text-[11px] text-hint">
|
||||
24시간 내 궤적 없음 (AIS 미수신)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{segments.length > 0 && (
|
||||
<div className="flex items-start gap-x-3 gap-y-0.5 flex-wrap text-[9px] text-hint">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-[2px] bg-red-500" />
|
||||
CRITICAL
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-[2px] bg-orange-500" />
|
||||
WARNING
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-4 h-[2px] bg-blue-500" />
|
||||
INFO
|
||||
</span>
|
||||
<span className="text-muted-foreground">굵은 색 구간 = 판별 시간대 AIS 궤적</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white/80 inline-block border border-slate-600" />
|
||||
이벤트 기준점
|
||||
</span>
|
||||
{segmentPaths.length < segments.length && (
|
||||
<span className="text-muted-foreground">
|
||||
· 궤적 매칭 실패 {segments.length - segmentPaths.length}구간
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
236
frontend/src/features/detection/components/vesselAnomaly.ts
Normal file
236
frontend/src/features/detection/components/vesselAnomaly.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* vessel_analysis_results 이력 중 '특이운항' 판별 포인트 추출 유틸.
|
||||
*
|
||||
* 단일 선박의 24h history 를 받아 '이상 신호'가 있는 시점만 AnomalyPoint 로 변환한다.
|
||||
* - DARK: is_dark=true
|
||||
* - SPOOFING: spoofing_score >= 0.3
|
||||
* - TRANSSHIP: transship_suspect=true
|
||||
* - GEAR_VIOLATION: gear_judgment 값이 존재하며 NORMAL 아님
|
||||
* - HIGH_RISK: risk_level CRITICAL/HIGH/MEDIUM (score>=40)
|
||||
*
|
||||
* 좌표는 top-level lat/lon 이 null 인 경우가 많아 features.gap_start_lat/lon 로 fallback.
|
||||
* 좌표가 없어도 이상 신호가 있으면 패널에는 표시하고, 미니맵 포인트만 생략한다.
|
||||
*/
|
||||
import type { VesselAnalysis } from '@/services/analysisApi';
|
||||
|
||||
export type AnomalyCategory =
|
||||
| 'DARK'
|
||||
| 'SPOOFING'
|
||||
| 'TRANSSHIP'
|
||||
| 'GEAR_VIOLATION'
|
||||
| 'HIGH_RISK';
|
||||
|
||||
export interface AnomalyPoint {
|
||||
id: number;
|
||||
timestamp: string; // ISO
|
||||
lat: number | null; // 좌표 없을 수 있음 (미니맵 포인트 생략)
|
||||
lon: number | null;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
categories: AnomalyCategory[];
|
||||
description: string; // 사람이 읽는 요약
|
||||
raw: VesselAnalysis;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<AnomalyCategory, string> = {
|
||||
DARK: '다크베셀',
|
||||
SPOOFING: 'GPS 스푸핑',
|
||||
TRANSSHIP: '환적 의심',
|
||||
GEAR_VIOLATION: '어구 위반',
|
||||
HIGH_RISK: '고위험',
|
||||
};
|
||||
|
||||
export function getAnomalyCategoryLabel(c: AnomalyCategory): string {
|
||||
return CATEGORY_LABEL[c];
|
||||
}
|
||||
|
||||
export function getAnomalyCategoryIntent(c: AnomalyCategory): 'critical' | 'warning' | 'info' | 'muted' {
|
||||
switch (c) {
|
||||
case 'TRANSSHIP':
|
||||
return 'critical';
|
||||
case 'DARK':
|
||||
case 'SPOOFING':
|
||||
case 'GEAR_VIOLATION':
|
||||
case 'HIGH_RISK':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
/** severity 우선순위 비교 (critical > warning > info). */
|
||||
function bumpSeverity(cur: AnomalyPoint['severity'], next: AnomalyPoint['severity']): AnomalyPoint['severity'] {
|
||||
if (cur === 'critical' || next === 'critical') return 'critical';
|
||||
if (cur === 'warning' || next === 'warning') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/** top-level lat/lon → features.gap_start_lat/lon fallback. */
|
||||
function extractCoord(v: VesselAnalysis): { lat: number | null; lon: number | null } {
|
||||
if (typeof v.lat === 'number' && typeof v.lon === 'number') return { lat: v.lat, lon: v.lon };
|
||||
const f = v.features;
|
||||
if (f) {
|
||||
const fLat = f['gap_start_lat'];
|
||||
const fLon = f['gap_start_lon'];
|
||||
if (typeof fLat === 'number' && typeof fLon === 'number') return { lat: fLat, lon: fLon };
|
||||
}
|
||||
return { lat: null, lon: null };
|
||||
}
|
||||
|
||||
/** features.dark_patterns → 한글/영문 그대로 나열. */
|
||||
function extractDarkPatterns(v: VesselAnalysis): string[] {
|
||||
const dp = v.features?.['dark_patterns'];
|
||||
if (!Array.isArray(dp)) return [];
|
||||
return dp.filter((x): x is string => typeof x === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 analysis 행 → AnomalyPoint (이상 없으면 null).
|
||||
*/
|
||||
export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
|
||||
const cats: AnomalyCategory[] = [];
|
||||
const descs: string[] = [];
|
||||
let severity: AnomalyPoint['severity'] = 'info';
|
||||
|
||||
if (v.isDark) {
|
||||
cats.push('DARK');
|
||||
const patterns = extractDarkPatterns(v);
|
||||
const patternTxt = patterns.length ? `, 패턴: ${patterns.join(', ')}` : '';
|
||||
descs.push(`다크베셀 (gap ${v.gapDurationMin ?? 0}분${v.darkPattern ? `, ${v.darkPattern}` : ''}${patternTxt})`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
}
|
||||
if ((v.spoofingScore ?? 0) >= 0.3) {
|
||||
cats.push('SPOOFING');
|
||||
descs.push(`GPS 스푸핑 (score ${Number(v.spoofingScore).toFixed(2)}${v.speedJumpCount ? `, speed jump ${v.speedJumpCount}` : ''})`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
}
|
||||
if (v.transshipSuspect) {
|
||||
cats.push('TRANSSHIP');
|
||||
descs.push(`환적 의심 (페어 ${v.transshipPairMmsi || '-'}, ${v.transshipDurationMin ?? 0}분)`);
|
||||
severity = bumpSeverity(severity, 'critical');
|
||||
}
|
||||
if (v.gearJudgment && v.gearJudgment !== 'NORMAL') {
|
||||
cats.push('GEAR_VIOLATION');
|
||||
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
}
|
||||
if (v.riskLevel === 'CRITICAL') {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'critical');
|
||||
} else if (v.riskLevel === 'HIGH') {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'warning');
|
||||
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
|
||||
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
|
||||
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
|
||||
severity = bumpSeverity(severity, 'info');
|
||||
}
|
||||
|
||||
if (cats.length === 0) return null;
|
||||
|
||||
const { lat, lon } = extractCoord(v);
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
timestamp: v.analyzedAt,
|
||||
lat,
|
||||
lon,
|
||||
severity,
|
||||
categories: Array.from(new Set(cats)),
|
||||
description: descs.join(' · '),
|
||||
raw: v,
|
||||
};
|
||||
}
|
||||
|
||||
/** history 배열 → 최신순 anomaly 포인트. */
|
||||
export function extractAnomalies(history: VesselAnalysis[]): AnomalyPoint[] {
|
||||
return history
|
||||
.map(classifyAnomaly)
|
||||
.filter((x): x is AnomalyPoint => x !== null)
|
||||
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
}
|
||||
|
||||
// ─── 구간(Segment) 병합 ────────────────────────────
|
||||
// prediction 은 5분 주기로 분석 결과를 write 하므로, 동일 신호(카테고리 집합 일치)가
|
||||
// 연속해서 쌓인 구간은 사용자 관점에서 '같은 사건'이다. 이를 시작~종료 구간으로 병합한다.
|
||||
|
||||
export interface AnomalySegment {
|
||||
id: string;
|
||||
categories: AnomalyCategory[];
|
||||
severity: AnomalyPoint['severity'];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
durationMin: number;
|
||||
pointCount: number; // 병합된 원본 분석 행 수
|
||||
description: string; // 대표 description (첫 포인트)
|
||||
representativeLat: number | null;
|
||||
representativeLon: number | null;
|
||||
geoPoints: Array<{ lat: number; lon: number; timestamp: string }>;
|
||||
points: AnomalyPoint[];
|
||||
}
|
||||
|
||||
function categoryKey(cats: AnomalyCategory[]): string {
|
||||
return [...cats].sort().join('|');
|
||||
}
|
||||
|
||||
function buildSegment(points: AnomalyPoint[]): AnomalySegment {
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const startTime = first.timestamp;
|
||||
const endTime = last.timestamp;
|
||||
const durationMin = Math.max(
|
||||
0,
|
||||
Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000),
|
||||
);
|
||||
const geoPoints = points
|
||||
.filter((p): p is AnomalyPoint & { lat: number; lon: number } => p.lat != null && p.lon != null)
|
||||
.map((p) => ({ lat: p.lat, lon: p.lon, timestamp: p.timestamp }));
|
||||
// 대표 좌표: 구간 중간 좌표 (없으면 첫 좌표)
|
||||
const mid = geoPoints[Math.floor(geoPoints.length / 2)] ?? geoPoints[0] ?? null;
|
||||
return {
|
||||
id: `${first.timestamp}_${categoryKey(first.categories)}`,
|
||||
categories: first.categories,
|
||||
severity: first.severity,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMin,
|
||||
pointCount: points.length,
|
||||
description: first.description,
|
||||
representativeLat: mid?.lat ?? null,
|
||||
representativeLon: mid?.lon ?? null,
|
||||
geoPoints,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* anomaly 포인트를 시간순 연속 + 동일 카테고리 집합으로 병합.
|
||||
* 5분 주기 반복된 동일 신호는 하나의 구간으로 합쳐 반환.
|
||||
*/
|
||||
export function groupAnomaliesToSegments(points: AnomalyPoint[]): AnomalySegment[] {
|
||||
if (points.length === 0) return [];
|
||||
// 시간 오름차순 정렬 후 인접 병합
|
||||
const asc = [...points].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
const segments: AnomalySegment[] = [];
|
||||
let bucket: AnomalyPoint[] = [];
|
||||
let key = '';
|
||||
|
||||
for (const p of asc) {
|
||||
const k = categoryKey(p.categories);
|
||||
if (bucket.length === 0) {
|
||||
bucket.push(p);
|
||||
key = k;
|
||||
} else if (k === key) {
|
||||
bucket.push(p);
|
||||
} else {
|
||||
segments.push(buildSegment(bucket));
|
||||
bucket = [p];
|
||||
key = k;
|
||||
}
|
||||
}
|
||||
if (bucket.length > 0) segments.push(buildSegment(bucket));
|
||||
|
||||
// 최신 구간이 위로 오도록 역순 반환
|
||||
return segments.sort((a, b) => b.startTime.localeCompare(a.startTime));
|
||||
}
|
||||
57
frontend/src/services/analysisAdapter.ts
Normal file
57
frontend/src/services/analysisAdapter.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* analysisApi (flat shape) → vesselAnalysisApi (nested VesselAnalysisItem) 변환.
|
||||
* 기존 컴포넌트가 nested shape에 의존하므로 서비스 교체 후에도 컴포넌트 최소 수정으로 재사용할 수 있게 어댑터를 둔다.
|
||||
*/
|
||||
import type { VesselAnalysis } from './analysisApi';
|
||||
import type { VesselAnalysisItem } from './vesselAnalysisApi';
|
||||
|
||||
export function toVesselItem(v: VesselAnalysis): VesselAnalysisItem {
|
||||
return {
|
||||
mmsi: v.mmsi,
|
||||
timestamp: v.analyzedAt,
|
||||
classification: {
|
||||
vesselType: v.vesselType ?? 'UNKNOWN',
|
||||
confidence: Number(v.confidence ?? 0),
|
||||
fishingPct: Number(v.fishingPct ?? 0),
|
||||
clusterId: v.clusterId ?? 0,
|
||||
season: v.season ?? 'UNKNOWN',
|
||||
},
|
||||
algorithms: {
|
||||
location: {
|
||||
zone: v.zoneCode ?? 'EEZ_OR_BEYOND',
|
||||
distToBaselineNm: Number(v.distToBaselineNm ?? 0),
|
||||
},
|
||||
activity: {
|
||||
state: v.activityState ?? 'UNKNOWN',
|
||||
ucafScore: Number(v.ucafScore ?? 0),
|
||||
ucftScore: Number(v.ucftScore ?? 0),
|
||||
},
|
||||
darkVessel: {
|
||||
isDark: v.isDark ?? false,
|
||||
gapDurationMin: v.gapDurationMin ?? 0,
|
||||
},
|
||||
gpsSpoofing: {
|
||||
spoofingScore: Number(v.spoofingScore ?? 0),
|
||||
bd09OffsetM: Number(v.bd09OffsetM ?? 0),
|
||||
speedJumpCount: v.speedJumpCount ?? 0,
|
||||
},
|
||||
cluster: {
|
||||
clusterId: v.fleetClusterId ?? 0,
|
||||
clusterSize: 0,
|
||||
},
|
||||
fleetRole: {
|
||||
isLeader: v.fleetIsLeader ?? false,
|
||||
role: v.fleetRole ?? 'NONE',
|
||||
},
|
||||
riskScore: {
|
||||
score: v.riskScore ?? 0,
|
||||
level: v.riskLevel ?? 'LOW',
|
||||
},
|
||||
transship: {
|
||||
isSuspect: v.transshipSuspect ?? false,
|
||||
pairMmsi: v.transshipPairMmsi ?? '',
|
||||
durationMin: v.transshipDurationMin ?? 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,113 +1,176 @@
|
||||
/**
|
||||
* vessel_analysis_results 직접 조회 API 서비스.
|
||||
* 백엔드 /api/analysis/* 엔드포인트 연동.
|
||||
* 백엔드 /api/analysis/* 엔드포인트 연동 (flat shape).
|
||||
* prediction이 5분 주기로 write한 분석 결과를 그대로 노출.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||
|
||||
// ─── DTO (백엔드 VesselAnalysisResponse 1:1 매핑) ─────────────
|
||||
|
||||
export interface VesselAnalysis {
|
||||
id: number;
|
||||
mmsi: string;
|
||||
analyzedAt: string;
|
||||
// 분류
|
||||
vesselType: string | null;
|
||||
confidence: number | null;
|
||||
fishingPct: number | null;
|
||||
clusterId: number | null;
|
||||
season: string | null;
|
||||
// 위치
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
zoneCode: string | null;
|
||||
distToBaselineNm: number | null;
|
||||
// 행동
|
||||
activityState: string | null;
|
||||
ucafScore: number | null;
|
||||
ucftScore: number | null;
|
||||
// 위협
|
||||
isDark: boolean | null;
|
||||
gapDurationMin: number | null;
|
||||
darkPattern: string | null;
|
||||
spoofingScore: number | null;
|
||||
bd09OffsetM: number | null;
|
||||
speedJumpCount: number | null;
|
||||
// 환적
|
||||
transshipSuspect: boolean | null;
|
||||
transshipPairMmsi: string | null;
|
||||
transshipDurationMin: number | null;
|
||||
// 선단
|
||||
fleetClusterId: number | null;
|
||||
fleetRole: string | null;
|
||||
fleetIsLeader: boolean | null;
|
||||
// 위험도
|
||||
riskScore: number | null;
|
||||
riskLevel: string | null;
|
||||
// 확장
|
||||
gearCode: string | null;
|
||||
gearJudgment: string | null;
|
||||
permitStatus: string | null;
|
||||
violationCategories: string[] | null;
|
||||
features: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AnalysisPageResponse {
|
||||
content: VesselAnalysis[];
|
||||
export interface AnalysisStats {
|
||||
total: number;
|
||||
darkCount: number;
|
||||
spoofingCount: number;
|
||||
transshipCount: number;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
mediumCount: number;
|
||||
lowCount: number;
|
||||
territorialCount: number;
|
||||
contiguousCount: number;
|
||||
eezCount: number;
|
||||
fishingCount: number;
|
||||
avgRiskScore: number;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
}
|
||||
|
||||
export interface GearDetection {
|
||||
id: number;
|
||||
mmsi: string;
|
||||
analyzedAt: string;
|
||||
vesselType: string | null;
|
||||
gearCode: string;
|
||||
gearJudgment: string;
|
||||
permitStatus: string | null;
|
||||
riskLevel: string | null;
|
||||
riskScore: number | null;
|
||||
zoneCode: string | null;
|
||||
violationCategories: string[] | null;
|
||||
}
|
||||
|
||||
export interface AnalysisPageResponse<T = VesselAnalysis> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** 분석 결과 목록 조회 */
|
||||
export async function getAnalysisVessels(params?: {
|
||||
// ─── 내부 헬퍼 ─────────────
|
||||
|
||||
function buildQuery(params: Record<string, unknown>): string {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
qs.set(k, String(v));
|
||||
}
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── 공개 함수 ─────────────
|
||||
|
||||
/** 분석 결과 목록 조회 (필터 + 페이징). */
|
||||
export function getAnalysisVessels(params?: {
|
||||
mmsi?: string;
|
||||
mmsiPrefix?: string;
|
||||
zoneCode?: string;
|
||||
riskLevel?: string;
|
||||
isDark?: boolean;
|
||||
minRiskScore?: number;
|
||||
minFishingPct?: number;
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.mmsi) query.set('mmsi', params.mmsi);
|
||||
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
|
||||
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
|
||||
if (params?.isDark != null) query.set('isDark', String(params.isDark));
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
return apiGet('/analysis/vessels', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER). */
|
||||
export function getAnalysisStats(params?: {
|
||||
hours?: number;
|
||||
mmsiPrefix?: string;
|
||||
}): Promise<AnalysisStats> {
|
||||
return apiGet('/analysis/stats', { hours: 1, ...params });
|
||||
}
|
||||
|
||||
/** 특정 선박 최신 분석 결과 */
|
||||
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
export function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||
return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}`);
|
||||
}
|
||||
|
||||
/** 특정 선박 분석 이력 */
|
||||
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
export function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||
return apiGet(`/analysis/vessels/${encodeURIComponent(mmsi)}/history`, { hours });
|
||||
}
|
||||
|
||||
/** 다크 베셀 목록 */
|
||||
export async function getDarkVessels(params?: {
|
||||
/** 다크 베셀 목록 (MMSI 중복 제거) */
|
||||
export function getDarkVessels(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
return apiGet('/analysis/dark', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** 환적 의심 목록 */
|
||||
export async function getTransshipSuspects(params?: {
|
||||
/** 환적 의심 목록 (MMSI 중복 제거) */
|
||||
export function getTransshipSuspects(params?: {
|
||||
hours?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('hours', String(params?.hours ?? 1));
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 50));
|
||||
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
return apiGet('/analysis/transship', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
/** prediction 자동 어구 탐지 결과 */
|
||||
export function getGearDetections(params?: {
|
||||
hours?: number;
|
||||
mmsiPrefix?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<AnalysisPageResponse<GearDetection>> {
|
||||
return apiGet('/analysis/gear-detections', { hours: 1, page: 0, size: 50, ...params });
|
||||
}
|
||||
|
||||
@ -90,6 +90,12 @@ async function apiGet<T>(path: string): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 백엔드 /api/vessel-analysis 는 빈 스텁 응답만 반환한다.
|
||||
* vessel_analysis_results 조회는 {@link import('./analysisApi').getAnalysisVessels} /
|
||||
* {@link import('./analysisApi').getAnalysisStats} 를 사용한다.
|
||||
* 본 함수는 호출처 제거 후 제거 예정.
|
||||
*/
|
||||
export function fetchVesselAnalysis() {
|
||||
return apiGet<VesselAnalysisResponse>('/vessel-analysis');
|
||||
}
|
||||
|
||||
@ -72,7 +72,7 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
||||
insert_sql = """
|
||||
INSERT INTO vessel_analysis_results (
|
||||
mmsi, analyzed_at, vessel_type, confidence, fishing_pct,
|
||||
cluster_id, season, zone_code, dist_to_baseline_nm, activity_state,
|
||||
cluster_id, season, lat, lon, zone_code, dist_to_baseline_nm, activity_state,
|
||||
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
||||
spoofing_score, bd09_offset_m, speed_jump_count,
|
||||
fleet_cluster_id, fleet_is_leader, fleet_role,
|
||||
@ -88,6 +88,8 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
||||
fishing_pct = EXCLUDED.fishing_pct,
|
||||
cluster_id = EXCLUDED.cluster_id,
|
||||
season = EXCLUDED.season,
|
||||
lat = EXCLUDED.lat,
|
||||
lon = EXCLUDED.lon,
|
||||
zone_code = EXCLUDED.zone_code,
|
||||
dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm,
|
||||
activity_state = EXCLUDED.activity_state,
|
||||
|
||||
@ -20,6 +20,8 @@ class AnalysisResult:
|
||||
# ALGO 01: 위치
|
||||
zone: str = 'EEZ_OR_BEYOND'
|
||||
dist_to_baseline_nm: float = 999.0
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
|
||||
# ALGO 02: 활동 상태
|
||||
activity_state: str = 'UNKNOWN'
|
||||
@ -95,6 +97,10 @@ class AnalysisResult:
|
||||
|
||||
safe_features = _sanitize(self.features) if self.features else {}
|
||||
|
||||
def _opt_f(v: object) -> Optional[float]:
|
||||
"""Optional float (None 유지)."""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
return (
|
||||
str(self.mmsi),
|
||||
self.analyzed_at, # analyzed_at (PK 파티션키)
|
||||
@ -103,6 +109,8 @@ class AnalysisResult:
|
||||
_f(self.fishing_pct),
|
||||
_i(self.cluster_id),
|
||||
str(self.season),
|
||||
_opt_f(self.lat),
|
||||
_opt_f(self.lon),
|
||||
str(self.zone), # → zone_code
|
||||
_f(self.dist_to_baseline_nm),
|
||||
str(self.activity_state),
|
||||
|
||||
@ -466,6 +466,9 @@ def run_analysis_cycle():
|
||||
'registered_fishery_code': registered_fishery_code or '',
|
||||
}
|
||||
|
||||
# 분석 시점의 선박 위치 — 특이운항 판별 근거 좌표로 DB에 저장
|
||||
lat_val = last_row.get('lat')
|
||||
lon_val = last_row.get('lon')
|
||||
results.append(AnalysisResult(
|
||||
mmsi=mmsi,
|
||||
timestamp=ts,
|
||||
@ -474,6 +477,8 @@ def run_analysis_cycle():
|
||||
fishing_pct=c['fishing_pct'],
|
||||
cluster_id=fleet_info.get('cluster_id', -1),
|
||||
season=c['season'],
|
||||
lat=float(lat_val) if lat_val is not None else None,
|
||||
lon=float(lon_val) if lon_val is not None else None,
|
||||
zone=zone_info.get('zone', 'EEZ_OR_BEYOND'),
|
||||
dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0),
|
||||
activity_state=activity,
|
||||
@ -615,6 +620,8 @@ def run_analysis_cycle():
|
||||
vessel_type='UNKNOWN',
|
||||
confidence=0.0,
|
||||
fishing_pct=0.0,
|
||||
lat=float(lat) if lat is not None else None,
|
||||
lon=float(lon) if lon is not None else None,
|
||||
zone=zone_info.get('zone', 'EEZ_OR_BEYOND'),
|
||||
dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0),
|
||||
activity_state=state,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user