kcg-ai-monitoring/frontend/src/features/dashboard/Dashboard.tsx
htlee cc1b1e20df feat: S4 alerts API + AIAlert/Dashboard 위험선박 실데이터 전환
백엔드:
- PredictionAlert 엔티티 + Repository
- AlertController: GET /api/alerts (페이징 + eventId 필터)

프론트:
- AIAlert: mock alerts → GET /api/alerts 실제 호출
- Dashboard 위험선박: vesselStore mock → fetchVesselAnalysis() API
  - riskScore TOP 8 선박, 다크/GPS변조/전재 배지 표시
- Dashboard 이벤트 타임라인: eventStore API 기반 동작 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:09:08 +09:00

643 lines
30 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint, MarkerData } from '@lib/map';
import {
AlertTriangle, Ship, Anchor, Eye, Navigation,
Crosshair, Shield, Waves, Wind, Thermometer, MapPin,
ChevronRight, Activity, Zap, Target, ArrowUpRight, ArrowDownRight,
Radar, TrendingUp, BarChart3
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { usePatrolStore } from '@stores/patrolStore';
import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi';
// ─── 작전 경보 등급 ─────────────────────
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
const ALERT_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400', border: 'border-red-500/30', dot: 'bg-red-500' },
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/30', dot: 'bg-orange-500' },
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', border: 'border-yellow-500/30', dot: 'bg-yellow-500' },
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' },
};
// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ─────────
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
'다크베셀': { icon: Eye, color: '#f97316' },
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
'추적 중': { icon: Crosshair, color: '#06b6d4' },
'나포/검문': { icon: Shield, color: '#10b981' },
// kpiKey 기반 매핑 (백엔드 API 응답)
realtime_detection: { icon: Radar, color: '#3b82f6' },
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
dark_vessel: { icon: Eye, color: '#f97316' },
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
tracking: { icon: Crosshair, color: '#06b6d4' },
enforcement: { icon: Shield, color: '#10b981' },
};
// TODO: /api/risk-grid 연동 예정
const AREA_RISK_DATA = [
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
{ area: '서해 5도', vessels: 11, risk: 88, trend: 'stable' },
{ area: 'EEZ 서부', vessels: 6, risk: 72, trend: 'down' },
{ area: '동해 중부', vessels: 4, risk: 58, trend: 'up' },
{ area: 'EEZ 남부', vessels: 3, risk: 45, trend: 'down' },
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
];
// TODO: /api/stats/daily 연동 예정
const HOURLY_DETECTION = [
{ hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 },
{ hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 },
{ hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 },
];
// TODO: /api/stats/daily 연동 예정
const VESSEL_TYPE_DATA = [
{ name: 'EEZ 침범', value: 18, color: '#ef4444' },
{ name: '다크베셀', value: 12, color: '#f97316' },
{ name: '불법환적', value: 8, color: '#a855f7' },
{ name: 'MMSI변조', value: 5, color: '#eab308' },
{ name: '고속도주', value: 4, color: '#06b6d4' },
];
// TODO: /api/weather 연동 예정
const WEATHER_DATA = {
wind: { speed: 12, direction: 'NW', gust: 18 },
wave: { height: 1.8, period: 6 },
temp: { air: 8, water: 11 },
visibility: 12,
seaState: 3,
};
// ─── 서브 컴포넌트 ─────────────────────
function PulsingDot({ color }: { color: string }) {
return (
<span className="relative flex h-2.5 w-2.5">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${color} opacity-60`} />
<span className={`relative inline-flex rounded-full h-2.5 w-2.5 ${color}`} />
</span>
);
}
function RiskBar({ value, size = 'default' }: { value: number; size?: 'default' | 'sm' }) {
const pct = value * 100;
const color = pct > 90 ? 'bg-red-500' : pct > 80 ? 'bg-orange-500' : pct > 70 ? 'bg-yellow-500' : 'bg-blue-500';
const textColor = pct > 90 ? 'text-red-400' : pct > 80 ? 'text-orange-400' : pct > 70 ? 'text-yellow-400' : 'text-blue-400';
const barW = size === 'sm' ? 'w-16' : 'w-24';
return (
<div className="flex items-center gap-2">
<div className={`${barW} h-1.5 bg-switch-background/60 rounded-full overflow-hidden`}>
<div className={`h-full ${color} rounded-full transition-all duration-700`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-bold tabular-nums ${textColor}`}>{pct.toFixed(0)}</span>
</div>
);
}
interface KpiCardProps { label: string; value: number; prev: number; icon: LucideIcon; color: string; desc: string }
function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps) {
const diff = value - prev;
const isUp = diff > 0;
return (
<div className="relative overflow-hidden rounded-xl border border-border bg-surface-raised p-4 hover:bg-surface-overlay transition-colors group">
<div className="absolute top-0 right-0 w-20 h-20 rounded-full opacity-[0.04] group-hover:opacity-[0.08] transition-opacity" style={{ background: color, filter: 'blur(20px)' }} />
<div className="flex items-start justify-between mb-3">
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
<Icon className="w-4 h-4" style={{ color }} />
</div>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(diff)}
</div>
</div>
<div className="text-2xl font-bold text-heading tabular-nums mb-0.5">{value}</div>
<div className="text-[11px] text-muted-foreground font-medium">{label}</div>
<div className="text-[9px] text-hint mt-0.5">{desc}</div>
</div>
);
}
interface TimelineEvent { time: string; level: AlertLevel; title: string; detail: string; vessel: string; area: string }
function TimelineItem({ event }: { event: TimelineEvent }) {
const c = ALERT_COLORS[event.level];
return (
<div className={`flex gap-3 p-2.5 rounded-lg ${c.bg} border ${c.border} hover:brightness-110 transition-all cursor-pointer group`}>
<div className="flex flex-col items-center gap-1 pt-0.5 shrink-0">
<PulsingDot color={c.dot} />
<span className="text-[9px] text-hint tabular-nums">{event.time}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-xs font-bold ${c.text}`}>{event.title}</span>
<Badge className={`${c.bg} ${c.text} text-[8px] px-1 py-0 border-0`}>{event.level}</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed truncate">{event.detail}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[9px] text-hint flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
<span className="text-[9px] text-hint flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
</div>
</div>
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
</div>
);
}
function PatrolStatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
'추적 중': 'bg-red-500/20 text-red-400 border-red-500/30',
'검문 중': 'bg-orange-500/20 text-orange-400 border-orange-500/30',
'초계 중': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
'귀항 중': 'bg-muted text-muted-foreground border-slate-500/30',
'대기': 'bg-green-500/20 text-green-400 border-green-500/30',
};
return <Badge className={`${styles[status] || 'bg-muted text-muted-foreground'} text-[9px] border px-1.5 py-0`}>{status}</Badge>;
}
function FuelGauge({ percent }: { percent: number }) {
const color = percent > 60 ? 'bg-green-500' : percent > 30 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="flex items-center gap-1.5">
<div className="w-12 h-1 bg-switch-background rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full`} style={{ width: `${percent}%` }} />
</div>
<span className="text-[9px] text-hint tabular-nums">{percent}%</span>
</div>
);
}
// ─── 해역 위협 미니맵 (Leaflet) ───────────────────
// TODO: /api/risk-grid 연동 예정
const THREAT_AREAS = [
{ name: '서해 NLL', lat: 37.80, lng: 124.90, risk: 95, vessels: 8 },
{ name: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 91, vessels: 14 },
{ name: '서해 5도', lat: 37.50, lng: 124.60, risk: 88, vessels: 11 },
{ name: '서해 중부', lat: 36.50, lng: 124.80, risk: 65, vessels: 6 },
{ name: 'EEZ 서부', lat: 36.00, lng: 123.80, risk: 72, vessels: 6 },
{ name: '동해 중부', lat: 37.00, lng: 130.50, risk: 58, vessels: 4 },
{ name: 'EEZ 남부', lat: 34.50, lng: 127.50, risk: 45, vessels: 3 },
{ name: '남해 서부', lat: 34.20, lng: 126.00, risk: 22, vessels: 1 },
];
// 히트맵용 위협 포인트 생성
function generateThreatHeat(): [number, number, number][] {
const pts: [number, number, number][] = [];
THREAT_AREAS.forEach((a) => {
const count = Math.round(a.vessels * 2.5);
const intensity = a.risk / 100;
for (let i = 0; i < count; i++) {
pts.push([
a.lat + (Math.random() - 0.5) * 0.8,
a.lng + (Math.random() - 0.5) * 1.0,
intensity * (0.6 + Math.random() * 0.4),
]);
}
});
return pts;
}
const THREAT_HEAT = generateThreatHeat();
// 모듈 스코프: static mock data → 마커도 module-level
const THREAT_MARKERS: MarkerData[] = THREAT_AREAS.map((a) => ({
lat: a.lat,
lng: a.lng,
color: a.risk > 85 ? '#ef4444' : a.risk > 60 ? '#f97316' : a.risk > 40 ? '#eab308' : '#3b82f6',
radius: Math.max(a.vessels, 5) * 150,
label: a.name,
}));
function SeaAreaMap() {
const mapRef = useRef<MapHandle>(null);
const buildLayers = useCallback(() => [
...STATIC_LAYERS,
createHeatmapLayer('threat-heat', THREAT_HEAT as HeatPoint[], { radiusPixels: 22 }),
createMarkerLayer('threat-markers', THREAT_MARKERS),
], []);
useMapLayers(mapRef, buildLayers, []);
return (
<div className="relative w-full min-h-[300px]">
<BaseMap
ref={mapRef}
center={[35.8, 127.0]}
zoom={7}
height={300}
className="rounded-lg overflow-hidden"
/>
{/* 범례 */}
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
<div className="text-[8px] text-muted-foreground font-bold mb-1"> </div>
<div className="flex items-center gap-1">
<span className="text-[7px] text-blue-400"></span>
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
<span className="text-[7px] text-red-400"></span>
</div>
</div>
{/* LIVE 인디케이터 */}
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<Radar className="w-3 h-3 text-blue-500" />
<span className="text-[9px] text-blue-400 font-medium"> </span>
</div>
</div>
);
}
// ─── 실시간 시계 (격리된 리렌더) ─────────────────
/** 실시간 시계 — React setState/render 완전 우회, DOM 직접 조작 */
function LiveClock() {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const fmt: Intl.DateTimeFormatOptions = {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
};
const update = () => {
if (spanRef.current) {
spanRef.current.textContent = new Date().toLocaleString('ko-KR', fmt);
}
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, []);
return <span ref={spanRef} className="tabular-nums" />;
}
// ─── 해역 미니맵 (memo로 불필요 리렌더 방지) ─────
const MemoSeaAreaMap = memo(SeaAreaMap);
// ─── 메인 대시보드 ─────────────────────
export function Dashboard() {
const { t } = useTranslation('dashboard');
const [defconLevel] = useState(2);
const kpiStore = useKpiStore();
const eventStore = useEventStore();
const patrolStore = usePatrolStore();
const [riskVessels, setRiskVessels] = useState<VesselAnalysisItem[]>([]);
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
useEffect(() => {
fetchVesselAnalysis()
.then((res) => {
if (!res.serviceAvailable) { setRiskVessels([]); return; }
const sorted = [...res.items].sort(
(a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score,
);
setRiskVessels(sorted.slice(0, 8));
})
.catch(() => setRiskVessels([]));
}, []);
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
return {
label: m.label,
value: m.value,
prev: m.prev ?? 0,
icon: ui.icon,
color: ui.color,
desc: m.description ?? '',
};
}), [kpiStore.metrics]);
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
level: e.level,
title: e.title,
detail: e.detail,
vessel: e.vesselName ?? '-',
area: e.area ?? '-',
})), [eventStore.events]);
const TOP_RISK_VESSELS = useMemo(() => riskVessels.map((v) => {
const risk = v.algorithms.riskScore;
return {
id: v.mmsi,
name: v.mmsi,
risk: risk.score,
type: v.classification.vesselType,
riskLevel: risk.level,
zone: v.algorithms.location.zone,
isDark: v.algorithms.darkVessel.isDark,
activity: v.algorithms.activity.state,
isSpoofing: v.algorithms.gpsSpoofing.spoofingScore >= 0.3,
isTransship: v.algorithms.transship.isSuspect,
};
}), [riskVessels]);
const PATROL_SHIPS = useMemo(() => patrolStore.ships.map((s) => ({
name: s.name,
class: s.shipClass,
status: s.status,
target: s.target ?? '-',
area: s.zone ?? '-',
speed: `${s.speed}kt`,
fuel: s.fuel,
})), [patrolStore.ships]);
const defconColors = ['', 'bg-red-600', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-blue-500'];
const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5'];
return (
<div className="space-y-4">
{/* ── 상단 헤더 바 ── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-500" />
{t('dashboard.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('dashboard.desc')}</p>
</div>
<div className={`${defconColors[defconLevel]} px-3 py-1 rounded-md flex items-center gap-2`}>
<span className="text-xs font-bold text-heading">{defconLabels[defconLevel]}</span>
<span className="text-[9px] text-heading/70"></span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PulsingDot color="bg-green-500" />
<span>AI </span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Activity className="w-3.5 h-3.5 text-blue-500" />
<LiveClock />
</div>
</div>
</div>
{/* ── KPI 카드 6개 ── */}
<div className="grid grid-cols-6 gap-3">
{KPI_DATA.map((kpi) => (
<KpiCard key={kpi.label} {...kpi} />
))}
</div>
{/* ── 메인 3단 레이아웃 ── */}
<div className="grid grid-cols-12 gap-3">
{/* ── 좌측: 해역 미니맵 + 해역별 위험도 ── */}
<div className="col-span-4 space-y-3">
<Card>
<CardContent className="p-3">
<MemoSeaAreaMap />
</CardContent>
</Card>
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5 text-orange-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3 pt-2">
<div className="space-y-2">
{AREA_RISK_DATA.map((area) => (
<div key={area.area} className="flex items-center gap-3">
<span className="text-[10px] text-muted-foreground w-16 shrink-0 truncate">{area.area}</span>
<div className="flex-1 h-4 bg-secondary rounded-sm overflow-hidden relative">
<div
className="h-full rounded-sm transition-all duration-700"
style={{
width: `${area.risk}%`,
background: area.risk > 85 ? 'linear-gradient(90deg, #ef4444, #dc2626)'
: area.risk > 60 ? 'linear-gradient(90deg, #f97316, #ea580c)'
: area.risk > 40 ? 'linear-gradient(90deg, #eab308, #ca8a04)'
: 'linear-gradient(90deg, #3b82f6, #2563eb)',
}}
/>
<span className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[8px] text-heading/80 font-bold tabular-nums">{area.vessels}</span>
</div>
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
}}>{area.risk}</span>
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>}
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* ── 중앙: 이벤트 타임라인 ── */}
<div className="col-span-4">
<Card className="bg-surface-raised border-border h-full">
<CardHeader className="px-4 pt-3 pb-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
</CardTitle>
<div className="flex items-center gap-1">
<Badge className="bg-red-500/15 text-red-400 text-[8px] border-0 px-1.5 py-0">
{TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length}
</Badge>
<Badge className="bg-orange-500/15 text-orange-400 text-[8px] border-0 px-1.5 py-0">
{TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="px-3 pb-3 pt-2">
<div className="space-y-1.5 max-h-[520px] overflow-y-auto pr-1">
{TIMELINE_EVENTS.map((event, i) => (
<TimelineItem key={i} event={event} />
))}
</div>
</CardContent>
</Card>
</div>
{/* ── 우측: 함정 배치 + 기상 + 유형별 차트 ── */}
<div className="col-span-4 space-y-3">
{/* 함정 배치 현황 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Navigation className="w-3.5 h-3.5 text-cyan-500" />
<Badge className="bg-cyan-500/15 text-cyan-400 text-[8px] border-0 ml-auto px-1.5 py-0">
{PATROL_SHIPS.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-2">
<div className="space-y-1">
{PATROL_SHIPS.map((ship) => (
<div key={ship.name} className="flex items-center gap-2 p-2 rounded-lg bg-surface-overlay hover:bg-secondary/70 transition-colors">
<div className="w-14 shrink-0">
<div className="text-[11px] text-heading font-bold">{ship.name}</div>
<div className="text-[8px] text-hint">{ship.class}</div>
</div>
<PatrolStatusBadge status={ship.status} />
<div className="flex-1 min-w-0 text-[9px] text-muted-foreground truncate">
{ship.target !== '-' ? ship.target : ship.area}
</div>
<span className="text-[9px] text-hint tabular-nums shrink-0">{ship.speed}</span>
<FuelGauge percent={ship.fuel} />
</div>
))}
</div>
</CardContent>
</Card>
{/* 기상/해상 정보 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3 pt-2">
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Wind className="w-3.5 h-3.5 text-muted-foreground mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wind.speed}m/s</div>
<div className="text-[8px] text-hint">{WEATHER_DATA.wind.direction} </div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div>
</div>
</div>
</CardContent>
</Card>
{/* 유형별 탐지 비율 */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Target className="w-3.5 h-3.5 text-purple-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3 pt-1">
<div className="flex items-center gap-3">
<PieChart data={VESSEL_TYPE_DATA} height={100} innerRadius={25} outerRadius={42} />
<div className="flex-1 space-y-1.5">
{VESSEL_TYPE_DATA.map((item) => (
<div key={item.name} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: item.color }} />
<span className="text-[10px] text-muted-foreground flex-1">{item.name}</span>
<span className="text-[10px] text-heading font-bold tabular-nums">{item.value}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* ── 시간대별 탐지 추이 차트 ── */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-500" />
</CardTitle>
<div className="flex items-center gap-3 text-[9px]">
<span className="flex items-center gap-1"><span className="w-2 h-1 rounded bg-blue-500" /> </span>
<span className="flex items-center gap-1"><span className="w-2 h-1 rounded bg-red-500" />EEZ </span>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-3 pt-1">
<AreaChart data={HOURLY_DETECTION} xKey="hour" height={140} series={[{ key: 'count', name: '전체 탐지', color: '#3b82f6' }, { key: 'eez', name: 'EEZ 침범', color: '#ef4444' }]} />
</CardContent>
</Card>
{/* ── 고위험 선박 추적 테이블 ── */}
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Crosshair className="w-3.5 h-3.5 text-red-500" />
(AI )
</CardTitle>
<Badge className="bg-red-500/15 text-red-400 text-[9px] border-0">{TOP_RISK_VESSELS.length} </Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{/* 테이블 헤더 */}
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
<span>#</span>
<span>MMSI</span>
<span></span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
<div className="space-y-0.5">
{TOP_RISK_VESSELS.map((vessel, index) => (
<div
key={vessel.id}
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
>
<span className="text-hint text-xs font-bold">#{index + 1}</span>
<div className="flex items-center gap-1.5">
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
</div>
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
<div className="flex items-center gap-1">
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0"></Badge>}
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0"></Badge>}
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
</div>
<RiskBar value={vessel.risk} />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}