226 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|