Merge pull request 'release: 2026-04-16.6 (5건 커밋)' (#64) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s

This commit is contained in:
htlee 2026-04-16 14:40:48 +09:00
커밋 2f94c2a0a4
20개의 변경된 파일1572개의 추가작업 그리고 186개의 파일을 삭제

파일 보기

@ -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,17 @@
## [Unreleased]
## [2026-04-16.6]
### 추가
- **중국어선 감시 화면 실데이터 연동 (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">
<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) => (
{vesselTabs.map((tab) => {
const disabled = tab !== '특이운항';
return (
<button type="button"
key={tab}
onClick={() => setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
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,10 +547,15 @@ export function ChinaFishing() {
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div>
)}
{vesselList.map((v) => (
{vesselList.map((v) => {
const selected = v.mmsi === selectedMmsi;
return (
<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"
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">
@ -512,7 +573,8 @@ export function ChinaFishing() {
</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">
<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">
<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">
<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 && (
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
<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="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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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));
}

파일 보기

@ -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,