wing-ops/frontend/src/tabs/admin/components/VesselSignalPanel.tsx

226 lines
7.7 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
// ─── 타입 ──────────────────────────────────────────────────
const SIGNAL_SOURCES = ['VTS', 'VTS-AIS', 'V-PASS', 'E-NAVI', 'S&P AIS'] as const;
type SignalSource = (typeof SIGNAL_SOURCES)[number];
interface SignalSlot {
time: string; // HH:mm
sources: Record<SignalSource, { count: number; status: 'ok' | 'warn' | 'error' | 'none' }>;
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
none: 'rgba(255,255,255,0.06)',
};
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function generateTimeSlots(date: string): SignalSlot[] {
const now = new Date();
const isToday = date === now.toISOString().slice(0, 10);
const currentHour = isToday ? now.getHours() : 24;
const currentMin = isToday ? now.getMinutes() : 0;
const slots: SignalSlot[] = [];
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 10) {
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const isPast = h < currentHour || (h === currentHour && m <= currentMin);
const sources = {} as Record<
SignalSource,
{ count: number; status: 'ok' | 'warn' | 'error' | 'none' }
>;
for (const src of SIGNAL_SOURCES) {
if (!isPast) {
sources[src] = { count: 0, status: 'none' };
} else {
const rand = Math.random();
const count = Math.floor(Math.random() * 200) + 10;
sources[src] = {
count,
status: rand > 0.15 ? 'ok' : rand > 0.05 ? 'warn' : 'error',
};
}
}
slots.push({ time, sources });
}
}
return slots;
}
// ─── 타임라인 바 (10분 단위 셀) ────────────────────────────
function TimelineBar({ slots, source }: { slots: SignalSlot[]; source: SignalSource }) {
if (slots.length === 0) return null;
// 144개 슬롯을 각각 1칸씩 렌더링 (10분 = 1칸)
return (
<div
className="w-full h-5 overflow-hidden flex"
style={{ background: 'rgba(255,255,255,0.04)' }}
>
{slots.map((slot, i) => {
const s = slot.sources[source];
const color = STATUS_COLOR[s.status] || STATUS_COLOR.none;
const statusLabel =
s.status === 'ok'
? '정상'
: s.status === 'warn'
? '지연'
: s.status === 'error'
? '오류'
: '미수신';
return (
<div
key={i}
className="h-full"
style={{
width: `${100 / 144}%`,
backgroundColor: color,
borderRight: '0.5px solid rgba(0,0,0,0.15)',
}}
title={`${slot.time} ${statusLabel}${s.status !== 'none' ? ` (${s.count}건)` : ''}`}
/>
);
})}
</div>
);
}
// ─── 메인 패널 ─────────────────────────────────────────────
export default function VesselSignalPanel() {
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [slots, setSlots] = useState<SignalSlot[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(() => {
setLoading(true);
// TODO: 실제 API 연동 시 fetch 호출로 교체
setTimeout(() => {
setSlots(generateTimeSlots(date));
setLoading(false);
}, 300);
}, [date]);
useEffect(() => {
const timer = setTimeout(() => load(), 0);
return () => clearTimeout(timer);
}, [load]);
// 통계 계산
const stats = SIGNAL_SOURCES.map((src) => {
let total = 0,
ok = 0,
warn = 0,
error = 0;
for (const slot of slots) {
const s = slot.sources[src];
if (s.status !== 'none') {
total++;
if (s.status === 'ok') ok++;
else if (s.status === 'warn') warn++;
else error++;
}
}
return { src, total, ok, warn, error, rate: total > 0 ? ((ok / total) * 100).toFixed(1) : '-' };
});
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
<h2 className="text-body-2 font-semibold text-fg"> </h2>
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg"
/>
<button
onClick={load}
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
>
</button>
</div>
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center h-full">
<span className="text-caption text-fg-disabled"> ...</span>
</div>
) : (
<div className="flex gap-2">
{/* 좌측: 소스 라벨 고정 열 */}
<div className="flex-shrink-0 flex flex-col" style={{ width: 64 }}>
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map((src) => {
const c = SOURCE_COLORS[src];
const st = stats.find((s) => s.src === src)!;
return (
<div
key={src}
className="flex flex-col justify-center mb-4"
style={{ height: 20 }}
>
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
{src}
</span>
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
</div>
);
})}
</div>
{/* 우측: 시간축 + 타임라인 바 */}
<div className="flex-1 min-w-0 flex flex-col">
{/* 시간 축 (상단) */}
<div className="relative h-5 mb-3">
{HOURS.map((h) => (
<span
key={h}
className="absolute text-caption text-fg-disabled font-mono"
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
>
{String(h).padStart(2, '0')}
</span>
))}
<span
className="absolute text-caption text-fg-disabled font-mono"
style={{ right: 0 }}
>
24
</span>
</div>
{/* 소스별 타임라인 바 */}
{SIGNAL_SOURCES.map((src) => (
<div key={src} className="mb-4 flex items-center" style={{ height: 20 }}>
<TimelineBar slots={slots} source={src} />
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}