437 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|