- DarkVesselDetection: 판정 상세 사이드 패널(점수 산출 내역 P1~P11, GAP 상세, 7일 이력 차트), 선박 위치 gap_start_lat/lon fallback, 클릭 시 지도 하이라이트 - TransferDetection: 5단계 필터 기반 환적 운영 화면 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화) - GearDetection: 모선 추론 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 3개 컬럼 추가 - EnforcementPlan: CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 "탐지 기반 단속 대상" 통합 표시 - darkVesselPatterns: prediction P1~P11 전 패턴 한국어 카탈로그 + buildScoreBreakdown() 점수 산출 유틸 - ScoreBreakdown: 가점/감점 분리 점수 내역 시각화 공통 컴포넌트 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
8.8 KiB
TypeScript
165 lines
8.8 KiB
TypeScript
/**
|
|
* 다크베셀 탐지 패턴 카탈로그
|
|
*
|
|
* SSOT: backend dark_pattern enum (V008 시드 DARK_PATTERN 그룹)
|
|
* 사용처: DarkVesselDetection
|
|
*/
|
|
|
|
import type { BadgeIntent } from '@lib/theme/variants';
|
|
|
|
export type DarkVesselPattern =
|
|
| 'AIS_FULL_BLOCK' // AIS 완전차단
|
|
| 'MMSI_SPOOFING' // MMSI 변조 의심
|
|
| 'LONG_LOSS' // 장기소실
|
|
| 'INTERMITTENT' // 신호 간헐송출
|
|
| 'SPEED_ANOMALY'; // 속도 이상
|
|
|
|
interface DarkVesselPatternMeta {
|
|
code: DarkVesselPattern;
|
|
i18nKey: string;
|
|
fallback: { ko: string; en: string };
|
|
intent: BadgeIntent;
|
|
hex: string;
|
|
}
|
|
|
|
export const DARK_VESSEL_PATTERNS: Record<DarkVesselPattern, DarkVesselPatternMeta> = {
|
|
AIS_FULL_BLOCK: {
|
|
code: 'AIS_FULL_BLOCK',
|
|
i18nKey: 'darkPattern.AIS_FULL_BLOCK',
|
|
fallback: { ko: 'AIS 완전차단', en: 'AIS Full Block' },
|
|
intent: 'critical',
|
|
hex: '#ef4444',
|
|
},
|
|
MMSI_SPOOFING: {
|
|
code: 'MMSI_SPOOFING',
|
|
i18nKey: 'darkPattern.MMSI_SPOOFING',
|
|
fallback: { ko: 'MMSI 변조 의심', en: 'MMSI Spoofing' },
|
|
intent: 'high',
|
|
hex: '#f97316',
|
|
},
|
|
LONG_LOSS: {
|
|
code: 'LONG_LOSS',
|
|
i18nKey: 'darkPattern.LONG_LOSS',
|
|
fallback: { ko: '장기 소실', en: 'Long Loss' },
|
|
intent: 'warning',
|
|
hex: '#eab308',
|
|
},
|
|
INTERMITTENT: {
|
|
code: 'INTERMITTENT',
|
|
i18nKey: 'darkPattern.INTERMITTENT',
|
|
fallback: { ko: '신호 간헐송출', en: 'Intermittent' },
|
|
intent: 'purple',
|
|
hex: '#a855f7',
|
|
},
|
|
SPEED_ANOMALY: {
|
|
code: 'SPEED_ANOMALY',
|
|
i18nKey: 'darkPattern.SPEED_ANOMALY',
|
|
fallback: { ko: '속도 이상', en: 'Speed Anomaly' },
|
|
intent: 'cyan',
|
|
hex: '#06b6d4',
|
|
},
|
|
};
|
|
|
|
/** 한글 라벨 호환 매핑 (mock 데이터에 한글이 들어있어서) */
|
|
const LEGACY_KO: Record<string, DarkVesselPattern> = {
|
|
'AIS 완전차단': 'AIS_FULL_BLOCK',
|
|
'MMSI 변조 의심': 'MMSI_SPOOFING',
|
|
'장기소실': 'LONG_LOSS',
|
|
'장기 소실': 'LONG_LOSS',
|
|
'신호 간헐송출': 'INTERMITTENT',
|
|
'속도 이상': 'SPEED_ANOMALY',
|
|
};
|
|
|
|
export function getDarkVesselPatternMeta(p: string): DarkVesselPatternMeta | undefined {
|
|
if (DARK_VESSEL_PATTERNS[p as DarkVesselPattern]) return DARK_VESSEL_PATTERNS[p as DarkVesselPattern];
|
|
const code = LEGACY_KO[p];
|
|
return code ? DARK_VESSEL_PATTERNS[code] : undefined;
|
|
}
|
|
|
|
export function getDarkVesselPatternIntent(p: string): BadgeIntent {
|
|
return getDarkVesselPatternMeta(p)?.intent ?? 'muted';
|
|
}
|
|
|
|
export function getDarkVesselPatternLabel(
|
|
p: string,
|
|
t: (k: string, opts?: { defaultValue?: string }) => string,
|
|
lang: 'ko' | 'en' = 'ko',
|
|
): string {
|
|
const meta = getDarkVesselPatternMeta(p);
|
|
if (!meta) return p;
|
|
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
|
}
|
|
|
|
// ─── prediction 실제 판정 패턴 (dark_vessel.py P1~P11) ──────────
|
|
// features.dark_patterns 배열에 저장되는 코드 → 한국어 라벨 + 점수 매핑
|
|
|
|
export interface ScoringPatternMeta {
|
|
label: string;
|
|
labelEn: string;
|
|
score: number; // 양수=가점, 음수=감점
|
|
scoreLabel: string; // "+25" / "-50"
|
|
desc: string;
|
|
descEn: string;
|
|
intent: BadgeIntent;
|
|
category: 'movement' | 'zone' | 'history' | 'identity' | 'signal' | 'coverage';
|
|
}
|
|
|
|
export const DARK_SCORING_PATTERNS: Record<string, ScoringPatternMeta> = {
|
|
// P1: 이동 상태
|
|
moving_at_off: { label: '이동중 OFF', labelEn: 'Moving at OFF', score: 25, scoreLabel: '+25', desc: 'SOG > 5kn에서 AIS 꺼짐', descEn: 'AIS off while SOG > 5kn', intent: 'critical', category: 'movement' },
|
|
slow_moving_at_off: { label: '저속 이동중 OFF', labelEn: 'Slow moving at OFF', score: 15, scoreLabel: '+15', desc: 'SOG 2~5kn에서 AIS 꺼짐', descEn: 'AIS off while SOG 2~5kn', intent: 'high', category: 'movement' },
|
|
// P2: 수역
|
|
sensitive_zone: { label: '민감 수역', labelEn: 'Sensitive zone', score: 25, scoreLabel: '+25', desc: '영해/접속수역에서 gap 시작', descEn: 'Gap started in territorial/contiguous zone', intent: 'critical', category: 'zone' },
|
|
special_zone: { label: '특정수역', labelEn: 'Special zone', score: 15, scoreLabel: '+15', desc: '특정어업수역에서 gap 시작', descEn: 'Gap started in special fishing zone', intent: 'high', category: 'zone' },
|
|
// P3: 반복 이력
|
|
repeat_high: { label: '반복 dark (고)', labelEn: 'Repeat dark (high)', score: 30, scoreLabel: '+30', desc: '7일내 3일+ dark 이력', descEn: '3+ dark days in 7 days', intent: 'critical', category: 'history' },
|
|
repeat_low: { label: '반복 dark (저)', labelEn: 'Repeat dark (low)', score: 15, scoreLabel: '+15', desc: '7일내 2일 dark 이력', descEn: '2 dark days in 7 days', intent: 'warning', category: 'history' },
|
|
recent_dark: { label: '최근 dark', labelEn: 'Recent dark', score: 10, scoreLabel: '+10', desc: '24시간내 dark 이력', descEn: 'Dark within 24h', intent: 'warning', category: 'history' },
|
|
// P4: 이동거리
|
|
distance_anomaly: { label: '이동거리 이상', labelEn: 'Distance anomaly', score: 20, scoreLabel: '+20', desc: 'gap 중 예상 대비 2배+ 이동', descEn: 'Moved 2x+ expected during gap', intent: 'high', category: 'movement' },
|
|
// P5: 조업 시간
|
|
daytime_fishing_off: { label: '주간 조업중 OFF', labelEn: 'Daytime fishing OFF', score: 15, scoreLabel: '+15', desc: '06~18시 조업 중 AIS 꺼짐', descEn: 'AIS off while fishing 06-18h', intent: 'high', category: 'movement' },
|
|
// P6: 이상 행동
|
|
teleport_before_gap: { label: 'gap 전 텔레포트', labelEn: 'Teleport before gap', score: 15, scoreLabel: '+15', desc: 'gap 직전 위치 점프', descEn: 'Position jump before gap', intent: 'high', category: 'signal' },
|
|
// P7: 무허가
|
|
unpermitted: { label: '무허가', labelEn: 'Unpermitted', score: 10, scoreLabel: '+10', desc: '허가 목록 미등록 선박', descEn: 'Not in permit registry', intent: 'warning', category: 'identity' },
|
|
// P8: gap 길이
|
|
very_long_gap: { label: '장기 gap (6h+)', labelEn: 'Very long gap (6h+)', score: 15, scoreLabel: '+15', desc: '360분 이상 gap', descEn: 'Gap >= 360min', intent: 'high', category: 'signal' },
|
|
long_gap: { label: 'gap (3h+)', labelEn: 'Long gap (3h+)', score: 10, scoreLabel: '+10', desc: '180분 이상 gap', descEn: 'Gap >= 180min', intent: 'warning', category: 'signal' },
|
|
// P9: 선종 (signal-batch 보강)
|
|
fishing_vessel_dark: { label: '어선 dark', labelEn: 'Fishing vessel dark', score: 10, scoreLabel: '+10', desc: '어선(000020)의 의도적 OFF 가능성', descEn: 'Fishing vessel intentional OFF', intent: 'warning', category: 'identity' },
|
|
cargo_natural_gap: { label: '화물선 자연 gap', labelEn: 'Cargo natural gap', score: -10, scoreLabel: '-10', desc: '화물선 원양 항해 자연 gap', descEn: 'Cargo vessel ocean gap (natural)', intent: 'info', category: 'identity' },
|
|
// P10: 항해 상태
|
|
underway_deliberate_off: { label: '항행중 의도적 OFF', labelEn: 'Underway deliberate OFF', score: 20, scoreLabel: '+20', desc: '항행 상태에서 갑자기 OFF', descEn: 'AIS off while under way', intent: 'critical', category: 'movement' },
|
|
anchored_natural_gap: { label: '정박중 자연 gap', labelEn: 'Anchored natural gap', score: -15, scoreLabel: '-15', desc: '정박/계류 중 gap은 자연스러움', descEn: 'Gap while anchored/moored (natural)', intent: 'info', category: 'movement' },
|
|
// P11: heading/COG
|
|
heading_cog_mismatch: { label: '방향 불일치', labelEn: 'Heading/COG mismatch', score: 15, scoreLabel: '+15', desc: '선수방향과 침로 60°+ 차이', descEn: 'Heading vs COG diff > 60°', intent: 'high', category: 'signal' },
|
|
// 감점
|
|
out_of_coverage: { label: '커버리지 밖', labelEn: 'Out of coverage', score: -50, scoreLabel: '-50', desc: 'AIS 수신범위 외 → 자연 gap', descEn: 'Outside AIS coverage → natural gap', intent: 'muted', category: 'coverage' },
|
|
};
|
|
|
|
/** prediction dark_patterns 코드로 scoring 메타 조회 */
|
|
export function getScoringPatternMeta(code: string): ScoringPatternMeta | undefined {
|
|
return DARK_SCORING_PATTERNS[code];
|
|
}
|
|
|
|
/** dark_patterns 배열 → 점수 내역 (가점/감점 분리) */
|
|
export function buildScoreBreakdown(patterns: string[]): {
|
|
items: (ScoringPatternMeta & { code: string })[];
|
|
totalAdd: number;
|
|
totalSub: number;
|
|
rawTotal: number;
|
|
} {
|
|
const items = patterns
|
|
.map(code => {
|
|
const meta = DARK_SCORING_PATTERNS[code];
|
|
return meta ? { ...meta, code } : null;
|
|
})
|
|
.filter((v): v is ScoringPatternMeta & { code: string } => v !== null)
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
const totalAdd = items.filter(i => i.score > 0).reduce((s, i) => s + i.score, 0);
|
|
const totalSub = items.filter(i => i.score < 0).reduce((s, i) => s + i.score, 0);
|
|
return { items, totalAdd, totalSub, rawTotal: totalAdd + totalSub };
|
|
}
|