kcg-ai-monitoring/frontend/src/features/detection/ChinaFishing.tsx
htlee c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
2026-04-08 13:04:23 +09:00

684 lines
31 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer } from '@shared/components/layout';
import {
Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud,
Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat';
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
import { GearIdentification } from './GearIdentification';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { PieChart as EcPieChart } from '@lib/charts';
import {
fetchVesselAnalysis,
filterDarkVessels,
filterTransshipSuspects,
type VesselAnalysisItem,
type VesselAnalysisStats,
} from '@/services/vesselAnalysisApi';
// ─── 중국 MMSI prefix ─────────────
const CHINA_MMSI_PREFIX = '412';
function isChinaVessel(mmsi: string): boolean {
return mmsi.startsWith(CHINA_MMSI_PREFIX);
}
// ─── 특이운항 선박 리스트 타입 ────────────────
type VesselStatus = '의심' | '양호' | '경고';
interface VesselItem {
id: string;
mmsi: string;
callSign: string;
channel: string;
source: string;
name: string;
type: string;
country: string;
status: VesselStatus;
riskPct: number;
}
function deriveVesselStatus(score: number): VesselStatus {
if (score >= 70) return '경고';
if (score >= 40) return '의심';
return '양호';
}
function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem {
const score = item.algorithms.riskScore.score;
return {
id: String(idx + 1),
mmsi: item.mmsi,
callSign: '-',
channel: '',
source: 'AIS',
name: item.classification.vesselType || item.mmsi,
type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo',
country: 'China',
status: deriveVesselStatus(score),
riskPct: score,
};
}
// ─── VTS 연계 항목 ─────────────────────
const VTS_ITEMS = [
{ name: '경인연안', active: true },
{ name: '평택항', active: false },
{ name: '경인항', active: false },
{ name: '대산항', active: true },
{ name: '인천항', active: true },
{ name: '태안연안', active: false },
];
// ─── 환적 탐지 뷰: RealTransshipSuspects 컴포넌트 사용 ───
// ─── 서브 컴포넌트 ─────────────────────
function SemiGauge({ value, label, color }: { value: number; label: string; color: string }) {
const angle = (value / 100) * 180;
return (
<div className="flex flex-col items-center">
<div className="relative w-28 h-16 overflow-hidden">
<svg viewBox="0 0 120 65" className="w-full h-full">
{/* 배경 호 */}
<path d="M 10 60 A 50 50 0 0 1 110 60" fill="none" stroke="#1e293b" strokeWidth="10" strokeLinecap="round" />
{/* 값 호 */}
<path
d="M 10 60 A 50 50 0 0 1 110 60"
fill="none"
stroke={color}
strokeWidth="10"
strokeLinecap="round"
strokeDasharray={`${(angle / 180) * 157} 157`}
/>
</svg>
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 text-center">
<span className="text-xl font-extrabold text-heading">{value.toFixed(2)}</span>
<span className="text-xs text-muted-foreground ml-0.5">%</span>
</div>
</div>
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
</div>
);
}
function CircleGauge({ value, label }: { value: number; label: string }) {
const circumference = 2 * Math.PI * 42;
const offset = circumference - (value / 100) * circumference;
return (
<div className="flex flex-col items-center">
<div className="relative w-24 h-24">
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90">
<circle cx="50" cy="50" r="42" fill="none" stroke="#1e293b" strokeWidth="8" />
<circle
cx="50" cy="50" r="42" fill="none"
stroke="#10b981" strokeWidth="8" strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-extrabold text-heading">{value}</span>
<span className="text-[10px] text-muted-foreground">%</span>
</div>
</div>
<span className="text-[10px] text-muted-foreground mt-1">{label}</span>
</div>
);
}
function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) {
const meta = getVesselRingMeta(status);
const c = { ring: meta.hex, text: `text-${meta.intent === 'critical' ? 'red' : meta.intent === 'high' ? 'orange' : 'green'}-400` };
const circumference = 2 * Math.PI * 18;
const offset = circumference - (riskPct / 100) * circumference;
return (
<div className="relative w-12 h-12 shrink-0">
<svg viewBox="0 0 44 44" className="w-full h-full -rotate-90">
<circle cx="22" cy="22" r="18" fill="none" stroke="#1e293b" strokeWidth="3" />
<circle cx="22" cy="22" r="18" fill="none" stroke={c.ring} strokeWidth="3" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset} />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-[8px] font-bold ${c.text}`}>{status}</span>
<span className="text-[9px] font-bold text-heading">{riskPct}%</span>
</div>
</div>
);
}
// ─── 메인 페이지 ──────────────────────
// ─── 환적 탐지 뷰 ─────────────────────
function TransferView() {
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-bold text-heading">· </h2>
<p className="text-[10px] text-hint mt-0.5"> </p>
</div>
{/* 탐지 조건 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-4">
<div className="text-[10px] text-muted-foreground mb-2"> </div>
<div className="grid grid-cols-3 gap-3">
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1"></div>
<div className="text-lg font-bold text-heading"> 100m</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1"></div>
<div className="text-lg font-bold text-heading"> 30</div>
</div>
<div className="bg-surface-overlay rounded-lg p-3 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1"></div>
<div className="text-lg font-bold text-heading"> 3kn</div>
</div>
</div>
</CardContent>
</Card>
{/* prediction 분석 결과 기반 환적 의심 선박 */}
<RealTransshipSuspects />
</div>
);
}
// ─── 메인 페이지 ──────────────────────
export function ChinaFishing() {
const { t: tcCommon } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
// API state
const [allItems, setAllItems] = useState<VesselAnalysisItem[]>([]);
const [apiStats, setApiStats] = useState<VesselAnalysisStats | null>(null);
const [serviceAvailable, setServiceAvailable] = useState(true);
const [apiLoading, setApiLoading] = useState(false);
const [apiError, setApiError] = useState('');
const loadApi = useCallback(async () => {
setApiLoading(true);
setApiError('');
try {
const res = await fetchVesselAnalysis();
setServiceAvailable(res.serviceAvailable);
setAllItems(res.items);
setApiStats(res.stats);
} catch (e: unknown) {
setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
setServiceAvailable(false);
} finally {
setApiLoading(false);
}
}, []);
useEffect(() => { loadApi(); }, [loadApi]);
// 중국어선 필터
const chinaVessels = useMemo(
() => allItems.filter((i) => isChinaVessel(i.mmsi)),
[allItems],
);
const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]);
const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]);
// 센서 카운터 (API 기반)
const countersRow1 = useMemo(() => [
{ label: '통합', count: allItems.length, color: '#6b7280' },
{ label: 'AIS', count: allItems.length, color: '#3b82f6' },
{ label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' },
{ label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' },
], [allItems]);
const countersRow2 = useMemo(() => [
{ label: '중국어선', count: chinaVessels.length, color: '#f97316' },
{ label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' },
{ label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' },
{ label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' },
], [chinaVessels, chinaDark, chinaTransship]);
// 특이운항 선박 리스트 (중국어선 중 riskScore >= 40)
const vesselList: VesselItem[] = useMemo(
() => chinaVessels
.filter((i) => i.algorithms.riskScore.score >= 40)
.sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score)
.slice(0, 20)
.map((item, idx) => mapToVesselItem(item, idx)),
[chinaVessels],
);
// 위험도별 분포 (도넛 차트용)
const riskDistribution = useMemo(() => {
const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length;
const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length;
const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length;
const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length;
return { critical, high, medium, low, total: chinaVessels.length };
}, [chinaVessels]);
// 안전도 지수 계산
const safetyIndex = useMemo(() => {
if (chinaVessels.length === 0) return { risk: 0, safety: 100 };
const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length;
return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) };
}, [chinaVessels]);
const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const;
const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const;
const modeTabs = [
{ key: 'dashboard' as const, icon: Brain, label: 'AI 감시 대시보드' },
{ key: 'transfer' as const, icon: RefreshCw, label: '환적·접촉 탐지' },
{ key: 'gear' as const, icon: CrosshairIcon, label: '어구/어망 판별' },
];
return (
<PageContainer size="sm">
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
{modeTabs.map((tab) => (
<button type="button"
key={tab.key}
onClick={() => setMode(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
mode === tab.key
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</button>
))}
</div>
{/* 환적 탐지 모드 */}
{mode === 'transfer' && <TransferView />}
{/* 어구/어망 판별 모드 */}
{mode === 'gear' && <GearIdentification />}
{/* AI 대시보드 모드 */}
{mode === 'dashboard' && <>
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
</div>
)}
{apiError && <div className="text-xs text-red-400">: {apiError}</div>}
{apiLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{/* iran 백엔드 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span>
</div>
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
<RotateCcw className="w-3.5 h-3.5" />
</button>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" />
<input aria-label="해역 또는 해구 번호 검색"
placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/>
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
</div>
</div>
{/* ── 상단 영역: 통항량 + 안전도 분석 + 관심영역 ── */}
<div className="grid grid-cols-12 gap-3">
{/* 해역별 통항량 */}
<div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-heading"> </span>
{apiStats && (
<div className="flex items-center gap-2 text-[10px]">
<span className="text-hint"> </span>
<span className="text-heading font-bold font-mono">{apiStats.total.toLocaleString()}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 mb-4 text-[10px] text-muted-foreground">
<span> </span>
<span className="text-lg font-extrabold text-heading">{allItems.length.toLocaleString()}</span>
<span className="text-hint">()</span>
</div>
{/* 카운터 Row 1 */}
<div className="grid grid-cols-4 gap-2 mb-2">
{countersRow1.map((c) => (
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1">{c.label}</div>
<div className="text-lg font-extrabold text-heading font-mono">{c.count.toLocaleString()}</div>
</div>
))}
</div>
{/* 카운터 Row 2 */}
<div className="grid grid-cols-4 gap-2">
{countersRow2.map((c) => (
<div key={c.label} className="bg-surface-overlay rounded-lg p-2.5 text-center border border-slate-700/30">
<div className="text-[9px] text-hint mb-1">{c.label}</div>
<div className={`text-lg font-extrabold font-mono ${c.count > 0 ? 'text-heading' : 'text-muted'}`}>
{c.count > 0 ? c.count.toLocaleString() : '-'}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 안전도 분석 */}
<div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4">
<span className="text-sm font-bold text-heading"> </span>
<div className="flex items-center justify-around mt-4">
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-orange-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div>
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-blue-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 관심영역 안전도 */}
<div className="col-span-4">
<Card className="bg-surface-raised border-slate-700/30 h-full">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-heading"> </span>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<option> A</option>
<option> B</option>
</select>
</div>
<p className="text-[9px] text-hint mb-3"> .</p>
<div className="flex items-center gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2 text-[11px]">
<Eye className="w-3.5 h-3.5 text-blue-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<Radio className="w-3.5 h-3.5 text-purple-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
</div>
</div>
<CircleGauge value={chinaVessels.length > 0 ? Number(((1 - riskDistribution.critical / Math.max(chinaVessels.length, 1)) * 100).toFixed(1)) : 100} label="" />
</div>
</CardContent>
</Card>
</div>
</div>
{/* ── 하단 영역: 선박 리스트 + 통계 ── */}
<div className="grid grid-cols-12 gap-3">
{/* 좌: 선박 리스트 (탭) */}
<div className="col-span-5">
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 헤더 */}
<div className="flex border-b border-slate-700/30">
{vesselTabs.map((tab) => (
<button type="button"
key={tab}
onClick={() => setVesselTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
vesselTab === tab
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
>
{tab}
</button>
))}
</div>
{/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto">
{vesselList.length === 0 && (
<div className="px-4 py-8 text-center text-hint text-xs">
{apiLoading ? '데이터 로딩 중...' : '중국어선 특이운항 데이터가 없습니다'}
</div>
)}
{vesselList.map((v) => (
<div
key={v.id}
className="flex items-center gap-3 px-4 py-3 border-b border-slate-700/20 hover:bg-surface-overlay transition-colors cursor-pointer group"
>
<StatusRing status={v.status} riskPct={v.riskPct} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-[10px] text-hint mb-0.5">
<span>MMSI | <span className="text-label">{v.mmsi}</span></span>
<span> | <span className="text-label">{v.source}</span></span>
</div>
<div className="flex items-center gap-2">
<span className="text-[12px] font-bold text-heading">{v.name}</span>
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
</div>
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
<span>{v.country}</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-hint group-hover:text-muted-foreground shrink-0" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 우: 통계 + 하단 카드 3개 */}
<div className="col-span-7 space-y-3">
{/* 통계 차트 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 */}
<div className="flex border-b border-slate-700/30">
{statsTabs.map((tab) => (
<button type="button"
key={tab}
onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
>
{tab}
</button>
))}
</div>
<div className="p-4 flex gap-4">
{/* 월별 통계 - API 미지원, 준비중 안내 */}
<div className="flex-1 flex flex-col items-center justify-center py-8">
<div className="text-muted-foreground text-xs mb-2"> </div>
<div className="text-hint text-[10px] bg-surface-overlay rounded-lg px-4 py-3 border border-border">
API . .
</div>
</div>
{/* 위험도 분포 도넛 */}
<div className="flex flex-col items-center justify-center gap-3 w-28">
<div className="relative w-[80px] h-[80px]">
<EcPieChart
data={[
{ name: 'CRITICAL', value: riskDistribution.critical || 1, color: '#ef4444' },
{ name: 'HIGH', value: riskDistribution.high || 1, color: '#f97316' },
{ name: 'MEDIUM', value: riskDistribution.medium || 1, color: '#eab308' },
{ name: 'LOW', value: riskDistribution.low || 1, color: '#3b82f6' },
]}
height={80}
innerRadius={24}
outerRadius={34}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-sm font-extrabold text-heading">{riskDistribution.total}</span>
<span className="text-[7px] text-hint"></span>
</div>
</div>
<div className="space-y-0.5 text-[8px]">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => (
<div key={lv} className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[lv].classes.dot}`} />
<span className="text-hint">
{getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']}
</span>
</div>
))}
</div>
</div>
</div>
{/* 다운로드 버튼 */}
<div className="px-4 pb-3 flex justify-end">
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
</button>
</div>
</CardContent>
</Card>
{/* 하단 카드 3개 */}
<div className="grid grid-cols-3 gap-3">
{/* 최근 위성영상 분석 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
<span className="text-hint shrink-0">VIIRS</span>
<span className="text-label">| 2023-08-11 02:00:00</span>
</div>
<div className="flex gap-2">
<span className="text-hint shrink-0"></span>
<span className="text-label truncate">| BSG-117-20230194-orho.tif</span>
</div>
<div className="flex gap-2">
<span className="text-hint shrink-0">CSV</span>
<span className="text-label truncate">| 2023.07.17_ship_dection.clustog.csv</span>
</div>
</div>
</CardContent>
</Card>
{/* 기상 예보 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading"> </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<div className="text-center">
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
</div>
<div>
<div className="text-[9px] text-muted-foreground"></div>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-extrabold text-heading">28.1</span>
<span className="text-sm text-muted-foreground">°C</span>
</div>
<div className="text-[9px] text-hint"> ~ 🌊 0.5~0.5m</div>
</div>
</div>
</CardContent>
</Card>
{/* VTS연계 현황 */}
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
<div
key={vts.name}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
vts.active
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
{vts.name}
</div>
))}
</div>
<div className="flex justify-between mt-2">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</>}
</PageContainer>
);
}