이슈: "Select element must have an accessible name" — 스크린 리더가 용도를 인지할 수 없어 WCAG 2.1 Level A 위반. 수정: - Select 공통 컴포넌트 타입을 union으로 강제 - aria-label | aria-labelledby | title 중 하나는 TypeScript 컴파일 타임에 필수 - 누락 시 tsc 단계에서 즉시 실패 → 회귀 방지 - 네이티브 <select> 5곳 aria-label 추가: - admin/SystemConfig: 대분류 필터 - detection/RealVesselAnalysis: 해역 필터 - detection/RealGearGroups: 그룹 유형 필터 - detection/ChinaFishing: 관심영역 선택 - detection/GearIdentification: SelectField에 label prop 추가 - 쇼케이스 FormSection Select 샘플에 aria-label 추가 이제 모든 Select 사용처가 접근 이름을 가지며, 향후 신규 Select 사용 시 tsc가 누락을 차단함.
204 lines
9.8 KiB
TypeScript
204 lines
9.8 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
|
import {
|
|
fetchVesselAnalysis,
|
|
type VesselAnalysisItem,
|
|
type VesselAnalysisStats,
|
|
} from '@/services/vesselAnalysisApi';
|
|
|
|
/**
|
|
* iran 백엔드의 실시간 vessel analysis 결과를 표시.
|
|
* - mode: 'dark' (Dark Vessel만) / 'spoofing' (스푸핑 의심) / 'transship' (전재) / 'all'
|
|
* - 위험도 통계 + 필터링된 선박 테이블
|
|
*/
|
|
|
|
interface Props {
|
|
mode: 'dark' | 'spoofing' | 'transship' | 'all';
|
|
title: string;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
|
|
|
const ZONE_LABELS: Record<string, string> = {
|
|
TERRITORIAL_SEA: '영해',
|
|
CONTIGUOUS_ZONE: '접속수역',
|
|
EEZ_OR_BEYOND: 'EEZ 외',
|
|
ZONE_I: '특정해역 I',
|
|
ZONE_II: '특정해역 II',
|
|
ZONE_III: '특정해역 III',
|
|
ZONE_IV: '특정해역 IV',
|
|
};
|
|
|
|
export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
|
const [items, setItems] = useState<VesselAnalysisItem[]>([]);
|
|
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
|
|
const [available, setAvailable] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [zoneFilter, setZoneFilter] = useState<string>('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError('');
|
|
try {
|
|
const res = await fetchVesselAnalysis();
|
|
setItems(res.items);
|
|
setStats(res.stats);
|
|
setAvailable(res.serviceAvailable);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'unknown');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const filtered = useMemo(() => {
|
|
let result = items;
|
|
if (mode === 'dark') result = result.filter((i) => i.algorithms.darkVessel.isDark);
|
|
else if (mode === 'spoofing') result = result.filter((i) => i.algorithms.gpsSpoofing.spoofingScore >= 0.3);
|
|
else if (mode === 'transship') result = result.filter((i) => i.algorithms.transship.isSuspect);
|
|
if (zoneFilter) result = result.filter((i) => i.algorithms.location.zone === zoneFilter);
|
|
return result;
|
|
}, [items, mode, zoneFilter]);
|
|
|
|
const sortedByRisk = useMemo(
|
|
() => [...filtered].sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score),
|
|
[filtered],
|
|
);
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
|
{icon} {title}
|
|
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
|
</div>
|
|
<div className="text-[10px] text-hint mt-0.5">
|
|
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
|
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
|
<option value="">전체 해역</option>
|
|
<option value="TERRITORIAL_SEA">영해</option>
|
|
<option value="CONTIGUOUS_ZONE">접속수역</option>
|
|
<option value="EEZ_OR_BEYOND">EEZ 외</option>
|
|
</select>
|
|
<button type="button" onClick={load}
|
|
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
{stats && (
|
|
<div className="grid grid-cols-6 gap-2">
|
|
<StatBox label="전체" value={stats.total} color="text-heading" />
|
|
<StatBox label="CRITICAL" value={stats.critical} color="text-red-400" />
|
|
<StatBox label="HIGH" value={stats.high} color="text-orange-400" />
|
|
<StatBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
|
<StatBox label="Dark" value={stats.dark} color="text-purple-400" />
|
|
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
|
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
|
|
|
{!loading && (
|
|
<div className="overflow-x-auto max-h-96">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-surface-overlay text-hint sticky top-0">
|
|
<tr>
|
|
<th className="px-2 py-1.5 text-left">MMSI</th>
|
|
<th className="px-2 py-1.5 text-left">선박 유형</th>
|
|
<th className="px-2 py-1.5 text-center">위험도</th>
|
|
<th className="px-2 py-1.5 text-right">점수</th>
|
|
<th className="px-2 py-1.5 text-left">해역</th>
|
|
<th className="px-2 py-1.5 text-left">활동</th>
|
|
<th className="px-2 py-1.5 text-center">Dark</th>
|
|
<th className="px-2 py-1.5 text-right">Spoofing</th>
|
|
<th className="px-2 py-1.5 text-center">전재</th>
|
|
<th className="px-2 py-1.5 text-left">갱신</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedByRisk.length === 0 && (
|
|
<tr><td colSpan={10} className="px-3 py-6 text-center text-hint">필터된 데이터가 없습니다.</td></tr>
|
|
)}
|
|
{sortedByRisk.slice(0, 100).map((v) => (
|
|
<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>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
|
{v.algorithms.riskScore.level}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right text-heading font-bold">{v.algorithms.riskScore.score}</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
|
{ZONE_LABELS[v.algorithms.location.zone] || v.algorithms.location.zone}
|
|
<span className="text-hint ml-1">({v.algorithms.location.distToBaselineNm.toFixed(1)}NM)</span>
|
|
</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
{v.algorithms.darkVessel.isDark ? (
|
|
<Badge intent="purple" size="sm">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
|
) : <span className="text-hint">-</span>}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right">
|
|
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
|
|
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
|
|
) : <span className="text-hint">-</span>}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-center">
|
|
{v.algorithms.transship.isSuspect ? (
|
|
<Badge intent="critical" size="sm">{v.algorithms.transship.durationMin}분</Badge>
|
|
) : <span className="text-hint">-</span>}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
|
{v.timestamp ? new Date(v.timestamp).toLocaleTimeString('ko-KR') : '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{sortedByRisk.length > 100 && (
|
|
<div className="text-center text-[10px] text-hint mt-2">
|
|
상위 100건만 표시 (전체 {sortedByRisk.length}건, 위험도순)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
|
return (
|
|
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
|
<div className="text-[9px] text-hint">{label}</div>
|
|
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 편의 export: 모드별 default props
|
|
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" />} />;
|