30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:
**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입
**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역
**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)
**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)
**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭
**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup
**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지
**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`
**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
643 lines
31 KiB
TypeScript
643 lines
31 KiB
TypeScript
/**
|
|
* GearDetailPanel — 어구 그룹 판정 상세 사이드 패널
|
|
*
|
|
* 테이블 행 클릭 시 우측에 슬라이드 표시.
|
|
* G코드 위반 내역, 어구 그룹 정보, 모선 추론 결과 + correlation 상세,
|
|
* 궤적 리플레이 시작 버튼을 종합 표시.
|
|
*/
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { Button } from '@shared/components/ui/button';
|
|
import {
|
|
getGearViolationIntent,
|
|
getGearViolationLabel,
|
|
getGearViolationDesc,
|
|
GEAR_VIOLATION_CODES,
|
|
} from '@shared/constants/gearViolationCodes';
|
|
import { getZoneCodeIntent, getZoneCodeLabel, getZoneAllowedGears } from '@shared/constants/zoneCodes';
|
|
import { getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
|
|
import { X, Anchor, MapPin, ShieldAlert, Users, Ship, Play, TrendingUp, Loader2, CheckCircle, XCircle, BarChart3 } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useSettingsStore } from '@stores/settingsStore';
|
|
import { fetchGroupCorrelations, fetchVesselTracks, fetchCandidateMetrics, resolveParent, type CandidateMetricItem } from '@/services/vesselAnalysisApi';
|
|
import { useGearReplayStore } from '@stores/gearReplayStore';
|
|
|
|
interface CorrelationItem {
|
|
targetMmsi: string;
|
|
targetName: string;
|
|
targetType: string;
|
|
score: number;
|
|
streak: number;
|
|
freezeState: string;
|
|
proximityRatio: number;
|
|
visitScore: number;
|
|
headingCoherence: number;
|
|
modelName: string;
|
|
}
|
|
|
|
interface GearData {
|
|
id: string;
|
|
groupKey: string;
|
|
type: string;
|
|
owner: string;
|
|
zone: string;
|
|
status: string;
|
|
permit: string;
|
|
risk: string;
|
|
lat: number;
|
|
lng: number;
|
|
parentStatus: string;
|
|
parentMmsi: string;
|
|
confidence: string;
|
|
gCodes: string[];
|
|
gearViolationScore: number;
|
|
gearViolationEvidence: Record<string, Record<string, unknown>>;
|
|
pairTrawlDetected: boolean;
|
|
pairTrawlPairMmsi: string;
|
|
memberCount: number;
|
|
members: Array<{ mmsi: string; name?: string; lat?: number; lon?: number; role?: string; isParent?: boolean }>;
|
|
allowedGears: string[];
|
|
}
|
|
|
|
interface GearDetailPanelProps {
|
|
gear: GearData | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation('detection');
|
|
const { t: tc } = useTranslation('common');
|
|
const lang = useSettingsStore((s) => s.language);
|
|
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
|
|
const [corrLoading, setCorrLoading] = useState(false);
|
|
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set());
|
|
const [selectedDetail, setSelectedDetail] = useState<string | null>(null); // 상세 보기 중인 후보 MMSI
|
|
const [detailMetrics, setDetailMetrics] = useState<CandidateMetricItem[]>([]);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [resolveLoading, setResolveLoading] = useState(false);
|
|
const [resolveMsg, setResolveMsg] = useState('');
|
|
const replayGroupKey = useGearReplayStore(s => s.groupKey);
|
|
|
|
const toggleCandidate = useCallback((mmsi: string) => {
|
|
setSelectedCandidates(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// 후보 상세 메트릭 로드
|
|
const loadCandidateDetail = useCallback(async (mmsi: string) => {
|
|
if (!gear) return;
|
|
setSelectedDetail(mmsi);
|
|
setDetailLoading(true);
|
|
setResolveMsg('');
|
|
try {
|
|
const data = await fetchCandidateMetrics(gear.groupKey, mmsi);
|
|
setDetailMetrics(data.items ?? []);
|
|
} catch {
|
|
setDetailMetrics([]);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}, [gear]);
|
|
|
|
// 모선 확정/제외 처리
|
|
const handleResolve = useCallback(async (action: 'confirm' | 'reject') => {
|
|
if (!gear || !selectedDetail) return;
|
|
setResolveLoading(true);
|
|
setResolveMsg('');
|
|
try {
|
|
const result = await resolveParent(gear.groupKey, action, selectedDetail);
|
|
if (result.ok) {
|
|
setResolveMsg(action === 'confirm' ? '모선 확정 완료' : '후보 제외 완료');
|
|
} else {
|
|
setResolveMsg(result.message ?? '처리 실패');
|
|
}
|
|
} catch {
|
|
setResolveMsg('요청 실패');
|
|
} finally {
|
|
setResolveLoading(false);
|
|
}
|
|
}, [gear, selectedDetail]);
|
|
|
|
const loadCorrelations = useCallback(async (groupKey: string) => {
|
|
setCorrLoading(true);
|
|
try {
|
|
const data = await fetchGroupCorrelations(groupKey, 0.3);
|
|
// API 응답을 CorrelationItem 형태로 매핑
|
|
const items = Array.isArray(data) ? data : ((data as Record<string, unknown>)?.items as unknown[]) ?? [];
|
|
// 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째
|
|
const byMmsi = new Map<string, CorrelationItem>();
|
|
for (const item of items) {
|
|
const d = item as Record<string, unknown>;
|
|
const mmsi = String(d.targetMmsi ?? '');
|
|
const mapped: CorrelationItem = {
|
|
targetMmsi: mmsi,
|
|
targetName: String(d.target_name ?? d.targetName ?? ''),
|
|
targetType: String(d.target_type ?? d.targetType ?? ''),
|
|
score: Number(d.score ?? d.current_score ?? d.currentScore ?? 0),
|
|
streak: Number(d.streak ?? d.streak_count ?? d.streakCount ?? 0),
|
|
freezeState: String(d.freeze_state ?? d.freezeState ?? 'ACTIVE'),
|
|
proximityRatio: Number(d.proximity_ratio ?? d.proximityRatio ?? 0),
|
|
visitScore: Number(d.visit_score ?? d.visitScore ?? 0),
|
|
headingCoherence: Number(d.heading_coherence ?? d.headingCoherence ?? 0),
|
|
modelName: String(d.model_name ?? d.modelName ?? 'default'),
|
|
};
|
|
const existing = byMmsi.get(mmsi);
|
|
if (!existing || mapped.modelName === 'default' || mapped.score > existing.score) {
|
|
byMmsi.set(mmsi, mapped);
|
|
}
|
|
}
|
|
const sorted = Array.from(byMmsi.values()).sort((a, b) => b.score - a.score);
|
|
setCorrelations(sorted);
|
|
// 최고 일치율 후보 자동 선택
|
|
if (sorted.length > 0) {
|
|
loadCandidateDetail(sorted[0].targetMmsi);
|
|
}
|
|
} catch {
|
|
setCorrelations([]);
|
|
} finally {
|
|
setCorrLoading(false);
|
|
}
|
|
}, [loadCandidateDetail]);
|
|
|
|
useEffect(() => {
|
|
if (gear?.groupKey) {
|
|
loadCorrelations(gear.groupKey);
|
|
} else {
|
|
setCorrelations([]);
|
|
}
|
|
}, [gear?.groupKey, loadCorrelations]);
|
|
|
|
const [replayLoading, setReplayLoading] = useState(false);
|
|
|
|
const handleStartReplay = useCallback(async () => {
|
|
if (!gear) return;
|
|
setReplayLoading(true);
|
|
try {
|
|
// 자체 백엔드 API로 group history + correlation 조회
|
|
const groupKeyEnc = encodeURIComponent(gear.groupKey);
|
|
const [detailRes, tracksRes] = await Promise.all([
|
|
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/detail`)
|
|
.then(r => r.json()).catch(() => ({ history: [] })),
|
|
fetch(`/api/vessel-analysis/groups/${groupKeyEnc}/correlations?minScore=0.1`)
|
|
.then(r => r.json()).catch(() => ({ items: [] })),
|
|
]);
|
|
// detailRes.history → frames 변환
|
|
const historyRes = { frames: (detailRes.history ?? []) };
|
|
|
|
const frames = (historyRes.frames ?? []).map((f: Record<string, unknown>) => ({
|
|
snapshotTime: String(f.snapshotTime ?? ''),
|
|
centerLat: Number(f.centerLat ?? 0),
|
|
centerLon: Number(f.centerLon ?? 0),
|
|
memberCount: Number(f.memberCount ?? 0),
|
|
polygon: f.polygon ?? null,
|
|
members: Array.isArray(f.members) ? f.members : [],
|
|
}));
|
|
|
|
// correlation 응답 → loadHistory용 CorrelationItem 형태로 매핑
|
|
const corrItems = tracksRes.items ?? [];
|
|
const replayCorrelations = Array.isArray(corrItems) ? corrItems.map((v: Record<string, unknown>) => ({
|
|
targetMmsi: String(v.targetMmsi ?? ''),
|
|
targetName: String(v.targetName ?? ''),
|
|
score: Number(v.score ?? v.currentScore ?? 0),
|
|
freezeState: String(v.freezeState ?? 'ACTIVE'),
|
|
})) : [];
|
|
|
|
if (frames.length === 0) {
|
|
frames.push({
|
|
snapshotTime: new Date().toISOString(),
|
|
centerLat: gear.lat,
|
|
centerLon: gear.lng,
|
|
memberCount: gear.memberCount,
|
|
polygon: null,
|
|
members: gear.members ?? [],
|
|
});
|
|
}
|
|
|
|
// 선택된 후보 선박 항적 조회 (24시간)
|
|
let candidateTracks: { vesselId: string; shipName: string; geometry: [number, number][]; timestamps: string[] }[] = [];
|
|
if (selectedCandidates.size > 0) {
|
|
const now = new Date();
|
|
const h24ago = new Date(now.getTime() - 24 * 3600_000);
|
|
try {
|
|
candidateTracks = await fetchVesselTracks(
|
|
[...selectedCandidates],
|
|
h24ago.toISOString(),
|
|
now.toISOString(),
|
|
);
|
|
} catch {
|
|
// 항적 조회 실패해도 리플레이는 진행
|
|
}
|
|
}
|
|
|
|
useGearReplayStore.getState().loadHistory(gear.groupKey, frames, replayCorrelations, candidateTracks);
|
|
useGearReplayStore.getState().play();
|
|
} catch {
|
|
// silent fallback
|
|
} finally {
|
|
setReplayLoading(false);
|
|
}
|
|
}, [gear, selectedCandidates]);
|
|
|
|
if (!gear) return null;
|
|
|
|
const allowedGears = gear.allowedGears.length > 0
|
|
? gear.allowedGears
|
|
: getZoneAllowedGears(gear.zone);
|
|
|
|
const parentStatusLabel =
|
|
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? '직접매칭' :
|
|
gear.parentStatus === 'AUTO_PROMOTED' ? '자동승격' :
|
|
gear.parentStatus === 'REVIEW_REQUIRED' ? '심사필요' :
|
|
gear.parentStatus === 'UNRESOLVED' ? '미결정' :
|
|
gear.parentStatus;
|
|
|
|
const parentStatusIntent: 'success' | 'info' | 'warning' | 'muted' =
|
|
gear.parentStatus === 'DIRECT_PARENT_MATCH' ? 'success' :
|
|
gear.parentStatus === 'AUTO_PROMOTED' ? 'info' :
|
|
gear.parentStatus === 'REVIEW_REQUIRED' ? 'warning' :
|
|
'muted';
|
|
|
|
const hasPairTrawl = gear.pairTrawlDetected || gear.gCodes.includes('G-06');
|
|
const isReplayActive = replayGroupKey === gear.groupKey;
|
|
|
|
return (
|
|
<div className="fixed inset-y-0 right-0 w-[420px] bg-background border-l border-border z-50 overflow-y-auto shadow-2xl">
|
|
{/* 헤더 */}
|
|
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
|
|
<span className="font-bold text-heading text-sm">어구 판정 상세</span>
|
|
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
|
|
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
|
|
{getZoneCodeLabel(gear.zone, t, lang)}
|
|
</Badge>
|
|
</div>
|
|
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
|
|
<X className="w-4 h-4 text-hint" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
{/* G코드 위반 내역 */}
|
|
{gear.gCodes.length > 0 && (
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
|
<span className="text-label font-medium">G코드 위반 내역</span>
|
|
<span className="text-hint text-[10px]">총 {gear.gearViolationScore}점</span>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{gear.gCodes.map((code) => {
|
|
const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES];
|
|
return (
|
|
<div key={code} className="flex items-start gap-2 py-1.5 border-b border-border last:border-0">
|
|
<Badge intent={getGearViolationIntent(code)} size="sm" className="shrink-0 mt-0.5">{code}</Badge>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs text-label font-medium">{getGearViolationLabel(code, t, lang)}</div>
|
|
<div className="text-[10px] text-hint mt-0.5">{getGearViolationDesc(code, lang)}</div>
|
|
</div>
|
|
{meta && <span className="text-[10px] font-mono text-label shrink-0">+{meta.score}pt</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 어구 그룹 정보 */}
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
|
<span className="text-label font-medium">어구 그룹 정보</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">그룹 키</span>
|
|
<span className="text-label text-right font-mono text-[10px]">{gear.groupKey}</span>
|
|
<span className="text-hint">그룹 유형</span>
|
|
<span className="text-label text-right">{gear.type}</span>
|
|
<span className="text-hint">모선/소유자</span>
|
|
<span className="text-label text-right font-mono">{gear.owner}</span>
|
|
<span className="text-hint">구성원 수</span>
|
|
<span className="text-label text-right">{gear.memberCount > 0 ? `${gear.memberCount}척` : '-'}</span>
|
|
<span className="text-hint">설치 수역</span>
|
|
<span className="text-right">
|
|
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">{getZoneCodeLabel(gear.zone, t, lang)}</Badge>
|
|
</span>
|
|
<span className="text-hint">판정 상태</span>
|
|
<span className="text-right">
|
|
<Badge intent={getGearJudgmentIntent(gear.status)} size="sm">{gear.status}</Badge>
|
|
</span>
|
|
<span className="text-hint">허용 어구</span>
|
|
<span className="text-label text-right text-[10px]">{allowedGears.length > 0 ? allowedGears.join(', ') : '없음'}</span>
|
|
<span className="text-hint">위치</span>
|
|
<span className="text-label text-right font-mono text-[10px]">
|
|
{gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모선 추론 정보 */}
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
|
|
<span className="text-label font-medium">모선 추론</span>
|
|
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">추정 모선</span>
|
|
<span className="text-label text-right font-mono">
|
|
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
|
|
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
|
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
|
|
{gear.parentMmsi}
|
|
</button>
|
|
) : '-'}
|
|
</span>
|
|
<span className="text-hint">후보 수</span>
|
|
<span className="text-label text-right">{gear.confidence}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모선 추론 후보 상세 (Correlation) */}
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
|
<span className="text-label font-medium">추론 후보 상세</span>
|
|
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
|
|
<span className="text-hint text-[10px]">{correlations.length}건</span>
|
|
</div>
|
|
{correlations.length > 0 ? (
|
|
<div className="space-y-1.5 max-h-[240px] overflow-y-auto">
|
|
{correlations.sort((a, b) => b.score - a.score).map((c, i) => (
|
|
<div key={`${i}-${c.targetMmsi}`}
|
|
onClick={(e) => {
|
|
// 체크박스, MMSI 링크 클릭은 전파 방지됨 → 나머지 영역 클릭 시 상세 로드
|
|
if ((e.target as HTMLElement).closest('input, a, button')) return;
|
|
loadCandidateDetail(c.targetMmsi);
|
|
}}
|
|
className={`py-1.5 px-2 rounded text-xs cursor-pointer transition-colors ${
|
|
selectedDetail === c.targetMmsi
|
|
? 'bg-purple-500/10 border border-purple-500/30'
|
|
: c.targetMmsi === gear.parentMmsi
|
|
? 'bg-cyan-500/10 border border-cyan-500/30'
|
|
: 'hover:bg-surface-overlay'
|
|
}`}>
|
|
<div className="flex items-center gap-2">
|
|
<input type="checkbox" checked={selectedCandidates.has(c.targetMmsi)}
|
|
onChange={() => toggleCandidate(c.targetMmsi)}
|
|
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
|
|
aria-label={`${c.targetMmsi} 리플레이 선택`} />
|
|
<button type="button"
|
|
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
|
|
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
|
|
{c.targetMmsi}
|
|
</button>
|
|
<span className="text-hint text-[9px] truncate max-w-[80px]">{c.targetName}</span>
|
|
<div className="flex-1" />
|
|
<div className="w-14 h-1.5 bg-surface-overlay rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full transition-all"
|
|
style={{
|
|
width: `${Math.min(100, c.score * 100)}%`,
|
|
backgroundColor: c.score >= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b',
|
|
}} />
|
|
</div>
|
|
<span className="font-mono text-[10px] text-label w-10 text-right">
|
|
{(c.score * 100).toFixed(0)}%
|
|
</span>
|
|
<Badge intent={c.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">
|
|
{c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1 ml-6 text-[9px] text-hint">
|
|
<span>근접: {(c.proximityRatio * 100).toFixed(0)}%</span>
|
|
{c.visitScore > 0 && <span>방문: {(c.visitScore * 100).toFixed(0)}%</span>}
|
|
{c.headingCoherence > 0 && <span>방향: {(c.headingCoherence * 100).toFixed(0)}%</span>}
|
|
<span>연속: {c.streak}회</span>
|
|
<Badge intent={c.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">
|
|
{c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : !corrLoading ? (
|
|
<div className="text-hint text-[10px] text-center py-2">추론 후보 데이터 없음</div>
|
|
) : null}
|
|
{correlations.length > 0 && (
|
|
<div className="flex items-center gap-3 pt-1.5 border-t border-border text-[9px] text-hint">
|
|
<span>근접: 어구-선박 근접도</span>
|
|
<span>방문: 어구 구역 방문 빈도</span>
|
|
<span>방향: 침로 일관성</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 후보 상세 검토 패널 ── */}
|
|
{selectedDetail && (() => {
|
|
const cand = correlations.find(c => c.targetMmsi === selectedDetail);
|
|
if (!cand) return null;
|
|
// 최근 메트릭 평균 계산
|
|
const avg = (arr: (number | null)[]): number | null => {
|
|
const valid = arr.filter((v): v is number => v != null && v > 0);
|
|
return valid.length > 0 ? valid.reduce((a, b) => a + b, 0) / valid.length : null;
|
|
};
|
|
const avgProximity = avg(detailMetrics.map(m => m.proximityRatio));
|
|
const avgVisit = avg(detailMetrics.map(m => m.visitScore));
|
|
const avgActivity = avg(detailMetrics.map(m => m.activitySync));
|
|
const avgDtw = avg(detailMetrics.map(m => m.dtwSimilarity));
|
|
const avgSpeed = avg(detailMetrics.map(m => m.speedCorrelation));
|
|
const avgHeading = avg(detailMetrics.map(m => m.headingCoherence));
|
|
const avgDrift = avg(detailMetrics.map(m => m.driftSimilarity));
|
|
const shadowStayCount = detailMetrics.filter(m => m.shadowStay).length;
|
|
const shadowReturnCount = detailMetrics.filter(m => m.shadowReturn).length;
|
|
|
|
const pct = (v: number | null) => v != null ? `${(v * 100).toFixed(1)}%` : '-';
|
|
const bar = (v: number | null, color: string) => (
|
|
<div className="w-full h-1.5 bg-surface-overlay rounded-full overflow-hidden">
|
|
<div className="h-full rounded-full" style={{ width: `${Math.min(100, (v ?? 0) * 100)}%`, backgroundColor: color }} />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
|
|
<span className="text-label font-medium">후보 검토</span>
|
|
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
|
|
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
|
|
</div>
|
|
|
|
{/* 종합 점수 */}
|
|
<div className="flex items-center gap-3 py-2 border-y border-border">
|
|
<div className="text-center flex-1">
|
|
<div className={`text-lg font-bold ${cand.score >= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}>
|
|
{(cand.score * 100).toFixed(1)}%
|
|
</div>
|
|
<div className="text-[9px] text-hint">종합 일치율</div>
|
|
</div>
|
|
<div className="text-center flex-1">
|
|
<div className="text-sm font-bold text-label">{cand.streak}회</div>
|
|
<div className="text-[9px] text-hint">연속 관측</div>
|
|
</div>
|
|
<div className="text-center flex-1">
|
|
<div className="text-sm font-bold text-label">{detailMetrics.length}건</div>
|
|
<div className="text-[9px] text-hint">raw 메트릭</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 점수 근거 상세 */}
|
|
{detailLoading ? (
|
|
<div className="flex items-center justify-center py-4"><Loader2 className="w-4 h-4 animate-spin text-hint" /></div>
|
|
) : (
|
|
<div className="space-y-2 text-[10px]">
|
|
<div className="text-hint font-medium text-[9px]">관측 지표 (최근 {detailMetrics.length}건 평균)</div>
|
|
{[
|
|
{ label: '근접도', value: avgProximity, color: '#06b6d4', desc: '어구-선박 거리 근접 비율' },
|
|
{ label: '방문 점수', value: avgVisit, color: '#8b5cf6', desc: '어구 구역 방문 빈도' },
|
|
{ label: '활동 동기화', value: avgActivity, color: '#f59e0b', desc: '어구-선박 활동 시간 일치' },
|
|
{ label: 'DTW 유사도', value: avgDtw, color: '#ec4899', desc: '궤적 형태 유사도' },
|
|
{ label: '속도 상관', value: avgSpeed, color: '#14b8a6', desc: '속도 변화 패턴 일치' },
|
|
{ label: '침로 일관성', value: avgHeading, color: '#3b82f6', desc: '방향 변화 일치' },
|
|
{ label: '드리프트 유사도', value: avgDrift, color: '#64748b', desc: '표류 패턴 유사' },
|
|
].map(({ label, value, color, desc }) => (
|
|
<div key={label} className="flex items-center gap-2">
|
|
<span className="w-[70px] text-label shrink-0">{label}</span>
|
|
<div className="flex-1">{bar(value, color)}</div>
|
|
<span className="w-10 text-right font-mono text-label">{pct(value)}</span>
|
|
<span className="w-[90px] text-hint text-[8px] truncate" title={desc}>{desc}</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* 보정 지표 */}
|
|
<div className="text-hint font-medium text-[9px] pt-1 border-t border-border">보정 지표</div>
|
|
<div className="grid grid-cols-2 gap-y-1 gap-x-4">
|
|
<span className="text-hint">섀도우 체류</span>
|
|
<span className="text-label text-right">{shadowStayCount}/{detailMetrics.length}건</span>
|
|
<span className="text-hint">섀도우 복귀</span>
|
|
<span className="text-label text-right">{shadowReturnCount}/{detailMetrics.length}건</span>
|
|
<span className="text-hint">동결 상태</span>
|
|
<span className="text-right"><Badge intent={cand.freezeState === 'ACTIVE' ? 'success' : 'muted'} size="sm">{cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState}</Badge></span>
|
|
<span className="text-hint">선박 유형</span>
|
|
<span className="text-right"><Badge intent={cand.targetType === 'VESSEL' ? 'info' : 'muted'} size="sm">{cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType}</Badge></span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 확정/제외 버튼 */}
|
|
{resolveMsg && (
|
|
<div className={`text-[10px] text-center py-1 rounded ${resolveMsg.includes('완료') ? 'text-green-400 bg-green-500/10' : 'text-red-400 bg-red-500/10'}`}>
|
|
{resolveMsg}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button variant="primary" size="sm" className="flex-1"
|
|
onClick={() => handleResolve('confirm')} disabled={resolveLoading}>
|
|
<CheckCircle className="w-3.5 h-3.5 mr-1" />
|
|
{resolveLoading ? '처리 중...' : '모선 확정'}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="flex-1"
|
|
onClick={() => handleResolve('reject')} disabled={resolveLoading}>
|
|
<XCircle className="w-3.5 h-3.5 mr-1" />
|
|
후보 제외
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 궤적 리플레이 버튼 */}
|
|
<Button
|
|
variant={isReplayActive ? 'secondary' : 'primary'}
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={handleStartReplay}
|
|
disabled={replayLoading}
|
|
>
|
|
{replayLoading ? (
|
|
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
|
|
) : (
|
|
<Play className="w-3.5 h-3.5 mr-1" />
|
|
)}
|
|
{replayLoading ? '데이터 로딩...'
|
|
: isReplayActive ? '리플레이 재시작'
|
|
: selectedCandidates.size > 0
|
|
? `리플레이 (후보 ${selectedCandidates.size}척 포함)`
|
|
: '24시간 궤적 리플레이'}
|
|
</Button>
|
|
|
|
{/* 쌍끌이 감지 정보 */}
|
|
{hasPairTrawl && (
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
|
|
<span className="text-label font-medium">쌍끌이 트롤 공조</span>
|
|
<Badge intent="critical" size="sm">G-06</Badge>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">상대 선박</span>
|
|
<span className="text-label text-right font-mono">
|
|
{gear.pairTrawlPairMmsi ? (
|
|
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
|
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
|
|
{gear.pairTrawlPairMmsi}
|
|
</button>
|
|
) : '-'}
|
|
</span>
|
|
{gear.gearViolationEvidence['G-06'] && (() => {
|
|
const ev = gear.gearViolationEvidence['G-06'];
|
|
return (
|
|
<>
|
|
{ev.sync_duration_min != null && (
|
|
<>
|
|
<span className="text-hint">동기 지속</span>
|
|
<span className="text-label text-right font-mono">{String(ev.sync_duration_min)}분</span>
|
|
</>
|
|
)}
|
|
{ev.mean_separation_nm != null && (
|
|
<>
|
|
<span className="text-hint">평균 간격</span>
|
|
<span className="text-label text-right font-mono">{(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m</span>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 위치 + 액션 */}
|
|
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
|
|
<span className="text-label font-medium">위치</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-1 text-xs">
|
|
<span className="text-hint">위도</span>
|
|
<span className="text-label text-right font-mono">{gear.lat.toFixed(6)}°N</span>
|
|
<span className="text-hint">경도</span>
|
|
<span className="text-label text-right font-mono">{gear.lng.toFixed(6)}°E</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" className="flex-1"
|
|
onClick={() => navigate(`/vessel/${gear.owner}`)}>
|
|
<Ship className="w-3.5 h-3.5 mr-1" /> 선박 상세
|
|
</Button>
|
|
<Button variant="primary" size="sm" className="flex-1"
|
|
onClick={() => { /* TODO: 단속 대상 등록 API */ }}>
|
|
<ShieldAlert className="w-3.5 h-3.5 mr-1" /> 단속 등록
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|