/** * 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>; 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([]); const [corrLoading, setCorrLoading] = useState(false); const [selectedCandidates, setSelectedCandidates] = useState>(new Set()); const [selectedDetail, setSelectedDetail] = useState(null); // 상세 보기 중인 후보 MMSI const [detailMetrics, setDetailMetrics] = useState([]); 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)?.items as unknown[]) ?? []; // 같은 MMSI가 여러 모델에서 중복 → default 모델 우선, 없으면 첫 번째 const byMmsi = new Map(); for (const item of items) { const d = item as Record; 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) => ({ 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) => ({ 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 (
{/* 헤더 */}
어구 판정 상세 {gear.id} {getZoneCodeLabel(gear.zone, t, lang)}
{/* G코드 위반 내역 */} {gear.gCodes.length > 0 && (
G코드 위반 내역 총 {gear.gearViolationScore}점
{gear.gCodes.map((code) => { const meta = GEAR_VIOLATION_CODES[code as keyof typeof GEAR_VIOLATION_CODES]; return (
{code}
{getGearViolationLabel(code, t, lang)}
{getGearViolationDesc(code, lang)}
{meta && +{meta.score}pt}
); })}
)} {/* 어구 그룹 정보 */}
어구 그룹 정보
그룹 키 {gear.groupKey} 그룹 유형 {gear.type} 모선/소유자 {gear.owner} 구성원 수 {gear.memberCount > 0 ? `${gear.memberCount}척` : '-'} 설치 수역 {getZoneCodeLabel(gear.zone, t, lang)} 판정 상태 {gear.status} 허용 어구 {allowedGears.length > 0 ? allowedGears.join(', ') : '없음'} 위치 {gear.lat.toFixed(4)}°N {gear.lng.toFixed(4)}°E
{/* 모선 추론 정보 */}
모선 추론 {parentStatusLabel}
추정 모선 {gear.parentMmsi !== '-' && gear.parentMmsi ? ( ) : '-'} 후보 수 {gear.confidence}
{/* 모선 추론 후보 상세 (Correlation) */}
추론 후보 상세 {corrLoading && } {correlations.length}건
{correlations.length > 0 ? (
{correlations.sort((a, b) => b.score - a.score).map((c, i) => (
{ // 체크박스, 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' }`}>
toggleCandidate(c.targetMmsi)} className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer" aria-label={`${c.targetMmsi} 리플레이 선택`} /> {c.targetName}
= 0.72 ? '#10b981' : c.score >= 0.5 ? '#f59e0b' : '#64748b', }} />
{(c.score * 100).toFixed(0)}% {c.freezeState === 'ACTIVE' ? '활성' : c.freezeState.slice(0, 4)}
근접: {(c.proximityRatio * 100).toFixed(0)}% {c.visitScore > 0 && 방문: {(c.visitScore * 100).toFixed(0)}%} {c.headingCoherence > 0 && 방향: {(c.headingCoherence * 100).toFixed(0)}%} 연속: {c.streak}회 {c.targetType === 'VESSEL' ? '선박' : c.targetType === 'GEAR_BUOY' ? '어구' : c.targetType}
))}
) : !corrLoading ? (
추론 후보 데이터 없음
) : null} {correlations.length > 0 && (
근접: 어구-선박 근접도 방문: 어구 구역 방문 빈도 방향: 침로 일관성
)}
{/* ── 후보 상세 검토 패널 ── */} {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) => (
); return (
{/* 헤더 */}
후보 검토 {cand.targetMmsi} {cand.targetName}
{/* 종합 점수 */}
= 0.72 ? 'text-green-400' : cand.score >= 0.5 ? 'text-yellow-400' : 'text-hint'}`}> {(cand.score * 100).toFixed(1)}%
종합 일치율
{cand.streak}회
연속 관측
{detailMetrics.length}건
raw 메트릭
{/* 점수 근거 상세 */} {detailLoading ? (
) : (
관측 지표 (최근 {detailMetrics.length}건 평균)
{[ { 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 }) => (
{label}
{bar(value, color)}
{pct(value)} {desc}
))} {/* 보정 지표 */}
보정 지표
섀도우 체류 {shadowStayCount}/{detailMetrics.length}건 섀도우 복귀 {shadowReturnCount}/{detailMetrics.length}건 동결 상태 {cand.freezeState === 'ACTIVE' ? '활성' : cand.freezeState} 선박 유형 {cand.targetType === 'VESSEL' ? '선박' : cand.targetType === 'GEAR_BUOY' ? '어구' : cand.targetType}
)} {/* 확정/제외 버튼 */} {resolveMsg && (
{resolveMsg}
)}
); })()} {/* 궤적 리플레이 버튼 */} {/* 쌍끌이 감지 정보 */} {hasPairTrawl && (
쌍끌이 트롤 공조 G-06
상대 선박 {gear.pairTrawlPairMmsi ? ( ) : '-'} {gear.gearViolationEvidence['G-06'] && (() => { const ev = gear.gearViolationEvidence['G-06']; return ( <> {ev.sync_duration_min != null && ( <> 동기 지속 {String(ev.sync_duration_min)}분 )} {ev.mean_separation_nm != null && ( <> 평균 간격 {(Number(ev.mean_separation_nm) * 1852).toFixed(0)}m )} ); })()}
)} {/* 위치 + 액션 */}
위치
위도 {gear.lat.toFixed(6)}°N 경도 {gear.lng.toFixed(6)}°E
); }