feat(frontend): 선박 유형 한글 카탈로그 + 중국 선박 분석 그리드 정합성
vessel_type 카탈로그 - shared/constants/vesselTypes.ts 신규 — TRAWL/PURSE/GILLNET/LONGLINE/ TRAP/CARGO/UNKNOWN 7종 + getVesselTypeLabel / getVesselTypeIntent 헬퍼. 기존 alertLevels 카탈로그 패턴 답습 - catalogRegistry 에 VESSEL_TYPES 등록 — design-system 쇼케이스에 자동 노출 RealVesselAnalysis 필터 props 확장 - Props 에 mmsiPrefix / minRiskScore / size 추가 (all·spoofing mode) - 선박 유형 컬럼을 한글 라벨로 렌더 - RealAllVessels 편의 export 를 mmsiPrefix='412' 로 고정 + 제목을 '중국 선박 전체 분석 결과 (실시간)' 로 변경 효과 - Tab 1 상단 그리드가 중국 선박만 표시해 페이지 성격과 일치 - 선박 유형 '저인망/선망/유자망/연승/통발/운반선/미분류' 한글 표시 - 55점 HIGH 같은 중국 선박이 상단/하단 양쪽에 일관되게 노출
This commit is contained in:
부모
6fb0b04992
커밋
524df19f20
@ -9,6 +9,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -52,18 +53,29 @@ function deriveVesselStatus(score: number): VesselStatus {
|
||||
return '양호';
|
||||
}
|
||||
|
||||
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
|
||||
function mapToVesselItem(
|
||||
item: VesselAnalysisItem,
|
||||
idx: number,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en',
|
||||
): VesselItem {
|
||||
const score = item.algorithms.riskScore.score;
|
||||
const vt = item.classification.vesselType;
|
||||
const hasType = vt && vt !== 'UNKNOWN' && vt !== '';
|
||||
// 이름: fleet_vessels 매핑으로 vessel_type 이 채워진 경우 한글 유형 라벨, 아니면 '중국어선'
|
||||
const name = hasType ? getVesselTypeLabel(vt, t, lang) : '중국어선';
|
||||
// 타입 뱃지: fishingPct 기반 Fishing / 그 외는 vessel_type 라벨
|
||||
const type = item.classification.fishingPct > 0.5
|
||||
? 'Fishing'
|
||||
: hasType ? getVesselTypeLabel(vt, t, lang) : getVesselTypeLabel('UNKNOWN', t, lang);
|
||||
return {
|
||||
id: String(idx + 1),
|
||||
mmsi: item.mmsi,
|
||||
callSign: '-',
|
||||
channel: '',
|
||||
source: 'AIS',
|
||||
name: hasType ? vt : '중국어선',
|
||||
type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류',
|
||||
name,
|
||||
type,
|
||||
country: 'China',
|
||||
status: deriveVesselStatus(score),
|
||||
riskPct: score,
|
||||
@ -289,8 +301,8 @@ export function ChinaFishing() {
|
||||
|
||||
// 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척)
|
||||
const vesselList: VesselItem[] = useMemo(
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx)),
|
||||
[topVessels],
|
||||
() => topVessels.map((item, idx) => mapToVesselItem(item, idx, tcCommon, lang)),
|
||||
[topVessels, tcCommon, lang],
|
||||
);
|
||||
|
||||
// 위험도별 분포 (도넛 차트용) — apiStats 기반
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
|
||||
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
||||
import {
|
||||
getAnalysisVessels,
|
||||
@ -21,6 +23,12 @@ interface Props {
|
||||
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
/** 'all' / 'spoofing' mode 에서 MMSI prefix 필터 (예: '412' — 중국 선박 한정) */
|
||||
mmsiPrefix?: string;
|
||||
/** 'all' / 'spoofing' mode 에서 서버 측 최소 riskScore 필터 */
|
||||
minRiskScore?: number;
|
||||
/** 서버 조회 건수 (dark/transship 기본 200) */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
@ -40,7 +48,9 @@ const ENDPOINT_LABEL: Record<Props['mode'], string> = {
|
||||
spoofing: 'GET /api/analysis/vessels (spoofing_score ≥ 0.3)',
|
||||
};
|
||||
|
||||
export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore, size = 200 }: Props) {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const lang = (i18n.language as 'ko' | 'en') || 'ko';
|
||||
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -51,10 +61,10 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const page = mode === 'dark'
|
||||
? await getDarkVessels({ hours: 1, size: 200 })
|
||||
? await getDarkVessels({ hours: 1, size })
|
||||
: mode === 'transship'
|
||||
? await getTransshipSuspects({ hours: 1, size: 200 })
|
||||
: await getAnalysisVessels({ hours: 1, size: 200 });
|
||||
? await getTransshipSuspects({ hours: 1, size })
|
||||
: await getAnalysisVessels({ hours: 1, size, mmsiPrefix, minRiskScore });
|
||||
setItems(page.content.map(toVesselItem));
|
||||
setAvailable(true);
|
||||
} catch (e: unknown) {
|
||||
@ -63,7 +73,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mode]);
|
||||
}, [mode, mmsiPrefix, minRiskScore, size]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@ -160,8 +170,10 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<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.classification.vesselType}
|
||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
|
||||
{v.classification.confidence > 0 && (
|
||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||
@ -221,4 +233,12 @@ function StatBox({ label, value, color }: { label: string; value: number | undef
|
||||
export const RealDarkVessels = () => <RealVesselAnalysis mode="dark" title="Dark Vessel (실시간)" icon={<EyeOff className="w-4 h-4 text-purple-400" />} />;
|
||||
export const RealSpoofingVessels = () => <RealVesselAnalysis mode="spoofing" title="GPS 스푸핑 의심 (실시간)" icon={<AlertTriangle className="w-4 h-4 text-orange-400" />} />;
|
||||
export const RealTransshipSuspects = () => <RealVesselAnalysis mode="transship" title="전재 의심 (실시간)" icon={<Radar className="w-4 h-4 text-red-400" />} />;
|
||||
export const RealAllVessels = () => <RealVesselAnalysis mode="all" title="전체 분석 결과 (실시간)" icon={<Radar className="w-4 h-4 text-blue-400" />} />;
|
||||
// 중국 선박 감시 페이지 전용 — MMSI prefix 412 고정
|
||||
export const RealAllVessels = () => (
|
||||
<RealVesselAnalysis
|
||||
mode="all"
|
||||
title="중국 선박 전체 분석 결과 (실시간)"
|
||||
icon={<Radar className="w-4 h-4 text-blue-400" />}
|
||||
mmsiPrefix="412"
|
||||
/>
|
||||
);
|
||||
|
||||
@ -43,6 +43,7 @@ import { MLOPS_JOB_STATUSES } from './mlopsJobStatuses';
|
||||
import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurityStatuses';
|
||||
import { ZONE_CODES } from './zoneCodes';
|
||||
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
|
||||
import { VESSEL_TYPES } from './vesselTypes';
|
||||
|
||||
/**
|
||||
* 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마
|
||||
@ -91,6 +92,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
|
||||
source: 'backend ViolationType enum',
|
||||
items: VIOLATION_TYPES,
|
||||
},
|
||||
{
|
||||
id: 'vessel-type',
|
||||
showcaseId: 'TRK-CAT-vessel-type',
|
||||
titleKo: '선박 유형',
|
||||
titleEn: 'Vessel Type',
|
||||
description: 'TRAWL / PURSE / GILLNET / LONGLINE / TRAP / CARGO / UNKNOWN — prediction 분류 + fleet_vessels 매핑',
|
||||
source: 'prediction AnalysisResult.vessel_type',
|
||||
items: VESSEL_TYPES,
|
||||
},
|
||||
{
|
||||
id: 'event-status',
|
||||
showcaseId: 'TRK-CAT-event-status',
|
||||
|
||||
53
frontend/src/shared/constants/vesselTypes.ts
Normal file
53
frontend/src/shared/constants/vesselTypes.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 선박 유형 공통 카탈로그
|
||||
*
|
||||
* SSOT: prediction `AnalysisResult.vessel_type` (TRAWL / PURSE / LONGLINE / TRAP / GILLNET / CARGO / UNKNOWN).
|
||||
* prediction 은 분류 파이프라인 결과 + fleet_vessels 등록 fishery_code → vessel_type 매핑으로 값을 채운다.
|
||||
*
|
||||
* 사용처: RealVesselAnalysis '선박 유형' 컬럼, ChinaFishing 특이운항 리스트,
|
||||
* VesselDetail / EventList 등 선박 유형 배지.
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type VesselTypeCode =
|
||||
| 'TRAWL'
|
||||
| 'PURSE'
|
||||
| 'LONGLINE'
|
||||
| 'TRAP'
|
||||
| 'GILLNET'
|
||||
| 'CARGO'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export interface VesselTypeMeta {
|
||||
code: VesselTypeCode;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
|
||||
TRAWL: { code: 'TRAWL', i18nKey: 'vesselType.trawl', fallback: { ko: '저인망', en: 'Trawl' }, intent: 'warning' },
|
||||
PURSE: { code: 'PURSE', i18nKey: 'vesselType.purse', fallback: { ko: '선망', en: 'Purse Seine' }, intent: 'warning' },
|
||||
GILLNET: { code: 'GILLNET', i18nKey: 'vesselType.gillnet', fallback: { ko: '유자망', en: 'Gillnet' }, intent: 'warning' },
|
||||
LONGLINE: { code: 'LONGLINE', i18nKey: 'vesselType.longline', fallback: { ko: '연승', en: 'Longline' }, intent: 'info' },
|
||||
TRAP: { code: 'TRAP', i18nKey: 'vesselType.trap', fallback: { ko: '통발', en: 'Trap' }, intent: 'info' },
|
||||
CARGO: { code: 'CARGO', i18nKey: 'vesselType.cargo', fallback: { ko: '운반선', en: 'Cargo' }, intent: 'muted' },
|
||||
UNKNOWN: { code: 'UNKNOWN', i18nKey: 'vesselType.unknown', fallback: { ko: '미분류', en: 'Unknown' }, intent: 'muted' },
|
||||
};
|
||||
|
||||
export function getVesselTypeLabel(
|
||||
code: string | null | undefined,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
if (!code) return t(VESSEL_TYPES.UNKNOWN.i18nKey, { defaultValue: VESSEL_TYPES.UNKNOWN.fallback[lang] });
|
||||
const meta = VESSEL_TYPES[code as VesselTypeCode];
|
||||
if (!meta) return code;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getVesselTypeIntent(code: string | null | undefined): BadgeIntent {
|
||||
if (!code) return VESSEL_TYPES.UNKNOWN.intent;
|
||||
return VESSEL_TYPES[code as VesselTypeCode]?.intent ?? 'muted';
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user