wing-ops/frontend/src/tabs/admin/components/MonitorRealtimePanel.tsx
2026-03-24 16:56:03 +09:00

437 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import {
getRecentObservation,
OBS_STATION_CODES,
type RecentObservation,
} from '@tabs/weather/services/khoaApi';
import {
getUltraShortForecast,
getMarineForecast,
convertToGridCoords,
getCurrentBaseDateTime,
MARINE_REGIONS,
type WeatherForecastData,
type MarineWeatherData,
} from '@tabs/weather/services/weatherApi';
const KEY_TO_NAME: Record<string, string> = {
incheon: '인천',
gunsan: '군산',
mokpo: '목포',
yeosu: '여수',
tongyeong: '통영',
ulsan: '울산',
pohang: '포항',
donghae: '동해',
sokcho: '속초',
jeju: '제주',
};
// 조위관측소 목록
const STATIONS = Object.entries(OBS_STATION_CODES).map(([key, code]) => ({
key,
code,
name: KEY_TO_NAME[key] ?? key,
}));
// 기상청 초단기실황 지점 (위경도)
const WEATHER_STATIONS = [
{ name: '인천', lat: 37.4563, lon: 126.7052 },
{ name: '군산', lat: 35.9679, lon: 126.7361 },
{ name: '목포', lat: 34.8118, lon: 126.3922 },
{ name: '부산', lat: 35.1028, lon: 129.0323 },
{ name: '제주', lat: 33.5131, lon: 126.5297 },
];
// 해역 목록
const MARINE_REGION_LIST = Object.entries(MARINE_REGIONS).map(([name, regId]) => ({
name,
regId,
}));
type TabId = 'khoa' | 'kma-ultra' | 'kma-marine';
interface KhoaRow {
stationName: string;
data: RecentObservation | null;
error: boolean;
}
interface KmaUltraRow {
stationName: string;
data: WeatherForecastData | null;
error: boolean;
}
interface MarineRow {
name: string;
regId: string;
data: MarineWeatherData | null;
error: boolean;
}
const fmt = (v: number | null | undefined, digits = 1): string =>
v != null ? v.toFixed(digits) : '-';
function StatusBadge({ loading, errorCount, total }: { loading: boolean; errorCount: number; total: number }) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-2 text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
...
</span>
);
}
if (errorCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
</span>
);
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</span>
);
}
function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
const headers = ['관측소', '수온(°C)', '기온(°C)', '기압(hPa)', '풍향(°)', '풍속(m/s)', '유향(°)', '유속(m/s)', '조위(cm)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-12" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.water_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_temp)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.air_pres)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_dir, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.wind_speed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_dir, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.current_speed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolean }) {
const headers = ['지점', '기온(°C)', '풍속(m/s)', '풍향(°)', '파고(m)', '강수(mm)', '습도(%)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-12" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.stationName} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.stationName}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windDirection, 0)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.precipitation)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean }) {
const headers = ['해역명', '파고(m)', '풍속(m/s)', '풍향', '수온(°C)', '상태'];
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-bg-2 text-t3 uppercase tracking-wide">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium border-b border-border-1 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-border-1 animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-2 rounded w-14" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.regId} className="border-b border-border-1 hover:bg-bg-1/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
<td className="px-3 py-2 text-t2">{row.data?.windDirection || '-'}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-xs"></span>
) : row.data ? (
<span className="text-emerald-400 text-xs"></span>
) : (
<span className="text-t3 text-xs">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function MonitorRealtimePanel() {
const [activeTab, setActiveTab] = useState<TabId>('khoa');
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
// KHOA 조위관측소
const [khoaRows, setKhoaRows] = useState<KhoaRow[]>([]);
const [khoaLoading, setKhoaLoading] = useState(false);
// 기상청 초단기실황
const [kmaRows, setKmaRows] = useState<KmaUltraRow[]>([]);
const [kmaLoading, setKmaLoading] = useState(false);
// 기상청 해상예보
const [marineRows, setMarineRows] = useState<MarineRow[]>([]);
const [marineLoading, setMarineLoading] = useState(false);
const fetchKhoa = useCallback(async () => {
setKhoaLoading(true);
const results = await Promise.allSettled(
STATIONS.map((s) => getRecentObservation(s.code))
);
const rows: KhoaRow[] = STATIONS.map((s, i) => {
const result = results[i];
if (result.status === 'fulfilled') {
return { stationName: s.name, data: result.value, error: false };
}
return { stationName: s.name, data: null, error: true };
});
setKhoaRows(rows);
setKhoaLoading(false);
setLastUpdate(new Date());
}, []);
const fetchKmaUltra = useCallback(async () => {
setKmaLoading(true);
const { baseDate, baseTime } = getCurrentBaseDateTime();
const results = await Promise.allSettled(
WEATHER_STATIONS.map((s) => {
const { nx, ny } = convertToGridCoords(s.lat, s.lon);
return getUltraShortForecast(nx, ny, baseDate, baseTime);
})
);
const rows: KmaUltraRow[] = WEATHER_STATIONS.map((s, i) => {
const result = results[i];
if (result.status === 'fulfilled' && result.value.length > 0) {
return { stationName: s.name, data: result.value[0], error: false };
}
return { stationName: s.name, data: null, error: result.status === 'rejected' };
});
setKmaRows(rows);
setKmaLoading(false);
setLastUpdate(new Date());
}, []);
const fetchMarine = useCallback(async () => {
setMarineLoading(true);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const tmFc = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}00`;
const results = await Promise.allSettled(
MARINE_REGION_LIST.map((r) => getMarineForecast(r.regId, tmFc))
);
const rows: MarineRow[] = MARINE_REGION_LIST.map((r, i) => {
const result = results[i];
if (result.status === 'fulfilled') {
return { name: r.name, regId: r.regId, data: result.value, error: false };
}
return { name: r.name, regId: r.regId, data: null, error: true };
});
setMarineRows(rows);
setMarineLoading(false);
setLastUpdate(new Date());
}, []);
// 탭 전환 시 해당 데이터 로드
useEffect(() => {
let isMounted = true;
if (activeTab === 'khoa' && khoaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKhoa(); });
} else if (activeTab === 'kma-ultra' && kmaRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchKmaUltra(); });
} else if (activeTab === 'kma-marine' && marineRows.length === 0) {
void Promise.resolve().then(() => { if (isMounted) void fetchMarine(); });
}
return () => { isMounted = false; };
}, [activeTab, khoaRows.length, kmaRows.length, marineRows.length, fetchKhoa, fetchKmaUltra, fetchMarine]);
const handleRefresh = () => {
if (activeTab === 'khoa') fetchKhoa();
else if (activeTab === 'kma-ultra') fetchKmaUltra();
else fetchMarine();
};
const isLoading = activeTab === 'khoa' ? khoaLoading : activeTab === 'kma-ultra' ? kmaLoading : marineLoading;
const currentRows = activeTab === 'khoa' ? khoaRows : activeTab === 'kma-ultra' ? kmaRows : marineRows;
const errorCount = currentRows.filter((r) => r.error).length;
const totalCount = activeTab === 'khoa' ? STATIONS.length : activeTab === 'kma-ultra' ? WEATHER_STATIONS.length : MARINE_REGION_LIST.length;
const TABS: { id: TabId; label: string }[] = [
{ id: 'khoa', label: 'KHOA 조위관측소' },
{ id: 'kma-ultra', label: '기상청 초단기실황' },
{ id: 'kma-marine', label: '기상청 해상예보' },
];
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-border-1 shrink-0">
<h2 className="text-sm font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
: {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
<button
onClick={handleRefresh}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-2 hover:bg-bg-3 text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* 탭 */}
<div className="flex gap-0 border-b border-border-1 shrink-0 px-5">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-t3 hover:text-t2'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-border-1 bg-bg-0">
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
<span className="text-xs text-t3">
{activeTab === 'khoa' && `관측소 ${totalCount}`}
{activeTab === 'kma-ultra' && `지점 ${totalCount}`}
{activeTab === 'kma-marine' && `해역 ${totalCount}`}
</span>
</div>
{/* 테이블 콘텐츠 */}
<div className="flex-1 overflow-auto p-5">
{activeTab === 'khoa' && (
<KhoaTable rows={khoaRows} loading={khoaLoading} />
)}
{activeTab === 'kma-ultra' && (
<KmaUltraTable rows={kmaRows} loading={kmaLoading} />
)}
{activeTab === 'kma-marine' && (
<MarineTable rows={marineRows} loading={marineLoading} />
)}
</div>
</div>
);
}