KCG AI 기반 불법조업 탐지·차단 플랫폼 프론트엔드. React 19 + TypeScript 5.9 + Vite 8 + MapLibre + deck.gl + Zustand + Tailwind CSS. SFR 20개 전체 UI 구현 완료, 백엔드 연동 대기. - npm + Nexus 프록시 레지스트리 설정 - 팀 워크플로우 v1.6.1 부트스트랩 파일 배치 - .githooks (commit-msg, post-checkout) - package.json name: kcg-ai-monitoring v0.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
631 lines
30 KiB
TypeScript
631 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 { useVesselStore } from '@stores/vesselStore';
|
|
|
|
// ─── 작전 경보 등급 ─────────────────────
|
|
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 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑) ─────────
|
|
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' },
|
|
};
|
|
|
|
|
|
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' },
|
|
];
|
|
|
|
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 },
|
|
];
|
|
|
|
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' },
|
|
];
|
|
|
|
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) ───────────────────
|
|
|
|
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 vesselStore = useVesselStore();
|
|
const patrolStore = usePatrolStore();
|
|
|
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
|
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
|
useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
|
|
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
|
|
|
|
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => ({
|
|
label: m.label,
|
|
value: m.value,
|
|
prev: m.prev ?? 0,
|
|
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
|
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
|
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(() => vesselStore.suspects.slice(0, 8).map((v) => ({
|
|
id: v.id,
|
|
name: v.name,
|
|
risk: v.risk / 100,
|
|
type: v.pattern ?? v.type,
|
|
flag: v.flag === 'CN' ? '중국' : v.flag === 'KR' ? '한국' : '미상',
|
|
tonnage: v.tonnage ?? null,
|
|
speed: v.speed != null ? `${v.speed}kt` : '-',
|
|
heading: v.heading != null ? `${v.heading}°` : '-',
|
|
lastAIS: v.lastSignal ?? '-',
|
|
location: `N${v.lat.toFixed(2)} E${v.lng.toFixed(2)}`,
|
|
pattern: v.status,
|
|
})), [vesselStore.suspects]);
|
|
|
|
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_80px_90px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
|
<span>#</span>
|
|
<span>선박명 / ID</span>
|
|
<span>위반 유형</span>
|
|
<span>국적/지역</span>
|
|
<span>속력/침로</span>
|
|
<span>AIS 상태</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_80px_90px_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>
|
|
<div className="flex items-center gap-1.5">
|
|
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.8 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
|
<span className="text-heading text-[11px] font-bold">{vessel.name}</span>
|
|
</div>
|
|
<span className="text-[8px] text-hint ml-5">{vessel.id}</span>
|
|
</div>
|
|
<Badge className={`text-[8px] px-1.5 py-0 border ${
|
|
vessel.type.includes('침범') || vessel.type.includes('선단') ? 'bg-red-500/15 text-red-400 border-red-500/30'
|
|
: vessel.type.includes('다크') ? 'bg-orange-500/15 text-orange-400 border-orange-500/30'
|
|
: vessel.type.includes('환적') ? 'bg-purple-500/15 text-purple-400 border-purple-500/30'
|
|
: vessel.type.includes('MMSI') ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
|
|
: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
|
|
}`}>{vessel.type}</Badge>
|
|
<span className="text-[10px] text-muted-foreground">{vessel.flag}</span>
|
|
<div>
|
|
<div className="text-[10px] text-heading">{vessel.speed}</div>
|
|
<div className="text-[8px] text-hint">{vessel.heading}</div>
|
|
</div>
|
|
<span className={`text-[10px] ${vessel.lastAIS === '소실' ? 'text-red-400 font-bold' : 'text-muted-foreground'}`}>{vessel.lastAIS}</span>
|
|
<Badge className={`text-[8px] px-1 py-0 border-0 ${
|
|
vessel.pattern === '도주' ? 'bg-red-500/20 text-red-400'
|
|
: vessel.pattern === '조업 중' ? 'bg-orange-500/20 text-orange-400'
|
|
: vessel.pattern === '정박/접현' ? 'bg-purple-500/20 text-purple-400'
|
|
: vessel.pattern === '고속이동' ? 'bg-cyan-500/20 text-cyan-400'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}>{vessel.pattern}</Badge>
|
|
<span className="text-[9px] text-hint tabular-nums">{vessel.location}</span>
|
|
<RiskBar value={vessel.risk} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|