/** * 다크베셀 탐지 패턴 카탈로그 * * 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 = { 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 = { '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 = { // 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 }; }