wing-ops/frontend/src/tabs/hns/components/HNSAnalysisListTable.tsx
Nan Kyung Lee 8f98f63aa5 feat(aerial): CCTV 실시간 HLS 스트림 + HNS 분석 고도화
CCTV 실시간 영상:
- CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생)
- 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성)
- KHOA 15개 + KBS 6개 실제 해안 CCTV 연동
- Vite dev proxy, 스트림 타입 자동 감지 유틸리티

HNS 분석:
- HNS 시나리오 저장/불러오기/재계산 기능
- 물질 DB 검색 및 상세 정보 연동
- 좌표/파라미터 입력 UI 개선
- Python 확산 모델 스크립트 (hns_dispersion.py)

공통:
- 3D 지도 토글, 보고서 생성 개선
- useSubMenu 훅, mapUtils 확장
- ESLint set-state-in-effect 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:21:41 +09:00

270 lines
13 KiB
TypeScript
Executable File

import { useState, useEffect, useCallback } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
interface HNSAnalysisListTableProps {
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void
}
const RISK_LABEL: Record<string, string> = {
CRITICAL: '심각',
HIGH: '위험',
MEDIUM: '경고',
LOW: '관찰',
}
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: 'var(--red)' },
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--red)' },
MEDIUM: { bg: 'rgba(249,115,22,0.15)', color: 'var(--orange)' },
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--green)' },
}
function formatDate(dtm: string | null, mode: 'full' | 'date') {
if (!dtm) return '—'
const d = new Date(dtm)
if (mode === 'date') return d.toISOString().slice(0, 10)
return d.toISOString().slice(0, 16).replace('T', ' ')
}
function substanceTag(sbstNm: string | null): string {
if (!sbstNm) return '—'
const match = sbstNm.match(/\(([^)]+)\)/)
if (match) return match[1]
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm
}
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await fetchHnsAnalyses()
setAnalyses(items)
} catch (err) {
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err)
// DB 실패 시 localStorage에서 불러오기
try {
const localRaw = localStorage.getItem('hns_saved_analyses')
if (localRaw) {
const localItems = JSON.parse(localRaw) as Record<string, unknown>[]
const mapped: HnsAnalysisItem[] = localItems.map((entry) => {
const rslt = entry.rsltData as Record<string, unknown> | null
const inputP = rslt?.inputParams as Record<string, unknown> | null
const coord = rslt?.coord as { lon: number; lat: number } | null
const weather = rslt?.weather as Record<string, unknown> | null
return {
hnsAnlysSn: (entry.id as number) || 0,
anlysNm: (entry.anlysNm as string) || '로컬 저장 분석',
acdntDtm: (entry.acdntDtm as string)
|| (inputP?.accidentDate && inputP?.accidentTime
? `${inputP.accidentDate as string}T${inputP.accidentTime as string}:00`
: (inputP?.accidentDate as string))
|| null,
locNm: coord ? `${coord.lat.toFixed(4)} / ${coord.lon.toFixed(4)}` : null,
lon: coord?.lon ?? null,
lat: coord?.lat ?? null,
sbstNm: (entry.sbstNm as string) || null,
spilQty: (entry.spilQty as number) ?? null,
spilUnitCd: (entry.spilUnitCd as string) || null,
fcstHr: (entry.fcstHr as number) ?? null,
algoCd: (inputP?.algorithm as string) || null,
critMdlCd: (inputP?.criteriaModel as string) || null,
windSpd: (weather?.windSpeed as number) ?? null,
windDir: weather?.windDirection != null ? String(weather.windDirection) : null,
execSttsCd: 'COMPLETED',
riskCd: (entry.riskCd as string) || null,
analystNm: (entry.analystNm as string) || null,
rsltData: rslt ?? null,
regDtm: (entry.regDtm as string) || new Date().toISOString(),
_isLocal: true,
} as HnsAnalysisItem & { _isLocal?: boolean }
})
setAnalyses(mapped)
}
} catch {
// localStorage 파싱 실패 무시
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
return (
<div className="flex flex-col h-full bg-bg-0">
{/* Header */}
<div className="px-5 py-4 border-b border-border flex justify-between items-center bg-bg-1">
<div className="flex items-center gap-3">
<div className="text-base font-bold flex items-center gap-2">
<span className="text-[18px]">📋</span>
HNS
</div>
<span className="text-[10px] text-text-3 bg-bg-3 font-mono px-[10px] py-1 rounded-xl">
{analyses.length}
</span>
</div>
<div className="flex gap-2 items-center">
<input
type="text"
placeholder="검색..."
className="bg-bg-3 border border-border rounded-sm text-[11px] px-3 py-2 w-[200px]"
/>
<button
onClick={() => onTabChange('analysis')}
className="text-status-orange text-[11px] font-semibold cursor-pointer flex items-center gap-1 px-4 py-2 rounded-sm"
style={{
border: '1px solid rgba(249,115,22,0.3)',
background: 'linear-gradient(135deg, rgba(249,115,22,0.15), rgba(239,68,68,0.1))',
}}
>
<span className="text-sm">+</span>
</button>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center text-text-3 text-[12px] py-20"> ...</div>
) : (
<table className="w-full text-[11px] border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-bg-2 border-b border-border">
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[50px]"></th>
<th className="text-left text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[180px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[100px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[130px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[100px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[120px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[90px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[80px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
<div className="flex flex-col items-center gap-0.5">
<span>AEGL-3</span>
<span className="text-[8px] text-text-3"></span>
</div>
</th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
<div className="flex flex-col items-center gap-0.5">
<span>AEGL-2</span>
<span className="text-[8px] text-text-3"></span>
</div>
</th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 w-[70px]">
<div className="flex flex-col items-center gap-0.5">
<span>AEGL-1</span>
<span className="text-[8px] text-text-3"></span>
</div>
</th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[80px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[90px]"></th>
<th className="text-center text-[10px] font-semibold text-text-2 px-4 py-3 min-w-[120px]"></th>
</tr>
</thead>
<tbody>
{analyses.map((item, index) => {
const rslt = item.rsltData as Record<string, unknown> | null
const isLocal = !!(item as HnsAnalysisItem & { _isLocal?: boolean })._isLocal
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
const aegl3 = rslt?.aegl3 as boolean | undefined
const aegl2 = rslt?.aegl2 as boolean | undefined
const aegl1 = rslt?.aegl1 as boolean | undefined
const damageRadius = (rslt?.damageRadius as string) || '—'
const amount = item.spilQty != null ? `${item.spilQty} ${item.spilUnitCd || 'KL'}` : '—'
return (
<tr
key={item.hnsAnlysSn}
className="border-b border-border cursor-pointer"
onClick={() => onSelectAnalysis?.(item.hnsAnlysSn, isLocal && rslt ? rslt : undefined)}
style={{
transition: 'background 0.15s',
background: index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={(e) => e.currentTarget.style.background = index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'}
>
<td className="text-center text-text-3 font-mono px-4 py-3">{item.hnsAnlysSn}</td>
<td className="font-medium px-4 py-3">{item.anlysNm}</td>
<td className="text-center px-4 py-3">
<span
className="text-[9px] font-semibold text-status-orange px-2 py-1 rounded"
style={{ background: 'rgba(249,115,22,0.12)' }}
>
{substanceTag(item.sbstNm)}
</span>
</td>
<td className="text-center text-text-2 font-mono text-[10px] px-4 py-3">{formatDate(item.acdntDtm, 'full')}</td>
<td className="text-center text-text-3 font-mono text-[10px] px-4 py-3">{formatDate(item.regDtm, 'date')}</td>
<td className="text-center text-text-2 px-4 py-3">{item.locNm || '—'}</td>
<td className="text-center text-text-2 font-mono px-4 py-3">{amount}</td>
<td className="text-center px-4 py-3">
<span
className="text-[9px] font-semibold text-primary-cyan px-2 py-1 rounded"
style={{ background: 'rgba(6,182,212,0.12)' }}
>
{item.algoCd || '—'}
</span>
</td>
<td className="text-center text-text-2 font-mono px-4 py-3">{item.fcstHr ? `${item.fcstHr}H` : '—'}</td>
<td className="text-center px-4 py-3">
<div
className="w-6 h-6 rounded-full mx-auto"
style={{
background: aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
border: aegl3 ? 'none' : '1px solid var(--bd)'
}}
/>
</td>
<td className="text-center px-4 py-3">
<div
className="w-6 h-6 rounded-full mx-auto"
style={{
background: aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
border: aegl2 ? 'none' : '1px solid var(--bd)'
}}
/>
</td>
<td className="text-center px-4 py-3">
<div
className="w-6 h-6 rounded-full mx-auto"
style={{
background: aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
border: aegl1 ? 'none' : '1px solid var(--bd)'
}}
/>
</td>
<td className="text-center px-4 py-3">
<span
className="text-[9px] font-semibold px-[10px] py-1 rounded"
style={{ background: riskStyle.bg, color: riskStyle.color }}
>
{riskLabel}
</span>
</td>
<td className="text-center text-text-2 font-mono px-4 py-3">{damageRadius}</td>
<td className="text-center text-text-3 text-[10px] px-4 py-3">{item.analystNm || '—'}</td>
</tr>
)
})}
</tbody>
</table>
)}
{!loading && analyses.length === 0 && (
<div className="text-center text-text-3 text-[12px] py-20"> .</div>
)}
</div>
</div>
)
}