- Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) - HNS: 분석 CRUD 5개 API (013_hns_analysis.sql) - Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql) - Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql) - Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql) - backtrackMockData.ts 삭제 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
12 KiB
TypeScript
Executable File
280 lines
12 KiB
TypeScript
Executable File
import { useState, useRef, useEffect } from 'react'
|
|
import { createHnsAnalysis } from '../services/hnsApi'
|
|
|
|
interface HNSRecalcModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSubmit: () => void
|
|
}
|
|
|
|
type RecalcPhase = 'editing' | 'running' | 'done'
|
|
|
|
const HNS_SUBSTANCES = ['톨루엔', '암모니아', '메탄올', '수소', '벤젠', '스티렌', 'LNG', '염소', '황화수소']
|
|
const RELEASE_TYPES = ['순간 유출', '연속 유출', '반연속']
|
|
const MODELS = ['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian']
|
|
const STABILITIES = ['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)']
|
|
const PRED_TIMES = [1, 3, 6, 12, 24, 48]
|
|
|
|
export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProps) {
|
|
const backdropRef = useRef<HTMLDivElement>(null)
|
|
|
|
const [substance, setSubstance] = useState('톨루엔')
|
|
const [releaseType, setReleaseType] = useState('순간 유출')
|
|
const [amount, setAmount] = useState(2.5)
|
|
const [unit, setUnit] = useState<'t' | 'kg' | 'm³' | 'L'>('t')
|
|
const [windDir, setWindDir] = useState('SW')
|
|
const [windSpeed, setWindSpeed] = useState(5.2)
|
|
const [temp, setTemp] = useState(18.5)
|
|
const [stability, setStability] = useState('D (중립)')
|
|
const [model, setModel] = useState('ALOHA')
|
|
const [predTime, setPredTime] = useState(6)
|
|
const [lat, setLat] = useState(35.4215)
|
|
const [lon, setLon] = useState(129.3542)
|
|
const [phase, setPhase] = useState<RecalcPhase>('editing')
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (isOpen) setPhase('editing')
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (e.target === backdropRef.current) onClose()
|
|
}
|
|
if (isOpen) document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [isOpen, onClose])
|
|
|
|
const handleRun = async () => {
|
|
setPhase('running')
|
|
try {
|
|
await createHnsAnalysis({
|
|
anlysNm: `HNS 재계산 — ${substance}`,
|
|
lon,
|
|
lat,
|
|
sbstNm: substance,
|
|
spilQty: amount,
|
|
spilUnitCd: unit,
|
|
fcstHr: predTime,
|
|
algoCd: model,
|
|
windSpd: windSpeed,
|
|
windDir,
|
|
temp,
|
|
atmStblCd: stability.charAt(0),
|
|
})
|
|
setPhase('done')
|
|
setTimeout(() => {
|
|
onSubmit()
|
|
onClose()
|
|
}, 800)
|
|
} catch (err) {
|
|
console.error('[hns] 재계산 실패:', err)
|
|
setPhase('editing')
|
|
alert('재계산 실행에 실패했습니다.')
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div
|
|
ref={backdropRef}
|
|
style={{
|
|
position: 'fixed', inset: 0, zIndex: 9999,
|
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(4px)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: '400px', maxHeight: 'calc(100vh - 100px)',
|
|
background: 'var(--bg1)', border: '1px solid var(--bd)',
|
|
borderRadius: '14px', overflow: 'hidden',
|
|
display: 'flex', flexDirection: 'column',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: '16px 20px', borderBottom: '1px solid var(--bd)',
|
|
display: 'flex', alignItems: 'center', gap: '12px',
|
|
}}>
|
|
<div style={{
|
|
width: '36px', height: '36px', borderRadius: '10px',
|
|
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(239,68,68,0.15))',
|
|
border: '1px solid rgba(249,115,22,0.3)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '16px',
|
|
}}>🔄</div>
|
|
<div style={{ flex: 1 }}>
|
|
<h2 style={{ fontSize: '15px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', margin: 0 }}>
|
|
HNS 대기확산 재계산
|
|
</h2>
|
|
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
|
|
물질·기상조건을 수정하여 대기확산 예측을 재실행합니다
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} style={{
|
|
width: '28px', height: '28px', borderRadius: '6px',
|
|
border: '1px solid var(--bd)', background: 'var(--bg3)',
|
|
color: 'var(--t3)', fontSize: '12px', cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>✕</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div style={{
|
|
flex: 1, overflowY: 'auto', padding: '16px 20px',
|
|
display: 'flex', flexDirection: 'column', gap: '14px',
|
|
scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent',
|
|
}}>
|
|
{/* 현재 분석 정보 */}
|
|
<div style={{
|
|
padding: '10px 12px', background: 'rgba(249,115,22,0.04)',
|
|
border: '1px solid rgba(249,115,22,0.15)', borderRadius: '8px',
|
|
}}>
|
|
<div style={{ fontSize: '9px', fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>
|
|
현재 분석 정보
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px', fontSize: '9px' }}>
|
|
<InfoRow label="사고명" value="울산 온산항 톨루엔 유출" />
|
|
<InfoRow label="물질" value="톨루엔 (Toluene)" />
|
|
<InfoRow label="유출량" value="2.5 ton" />
|
|
<InfoRow label="확산 모델" value="ALOHA v5.4.7" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* HNS 물질 */}
|
|
<FG label="HNS 물질">
|
|
<select className="prd-i" value={substance} onChange={e => setSubstance(e.target.value)}>
|
|
{HNS_SUBSTANCES.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</FG>
|
|
|
|
{/* 유출 유형 + 유출량 */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
|
<FG label="유출 유형">
|
|
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
|
|
{RELEASE_TYPES.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</FG>
|
|
<FG label="유출량">
|
|
<div style={{ display: 'flex', gap: '4px' }}>
|
|
<input className="prd-i" type="number" value={amount} onChange={e => setAmount(Number(e.target.value))} step={0.1} style={{ flex: 1 }} />
|
|
<select className="prd-i" value={unit} onChange={e => setUnit(e.target.value as typeof unit)} style={{ width: '55px' }}>
|
|
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
|
|
</select>
|
|
</div>
|
|
</FG>
|
|
</div>
|
|
|
|
{/* 풍향 / 풍속 / 기온 */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '10px' }}>
|
|
<FG label="풍향">
|
|
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
|
|
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
</FG>
|
|
<FG label="풍속 (m/s)">
|
|
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(Number(e.target.value))} step={0.1} />
|
|
</FG>
|
|
<FG label="기온 (°C)">
|
|
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(Number(e.target.value))} step={0.1} />
|
|
</FG>
|
|
</div>
|
|
|
|
{/* 대기안정도 + 확산 모델 */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
|
<FG label="대기안정도 (Pasquill)">
|
|
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
|
|
{STABILITIES.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</FG>
|
|
<FG label="확산 모델">
|
|
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
|
|
{MODELS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
</select>
|
|
</FG>
|
|
</div>
|
|
|
|
{/* 예측 시간 */}
|
|
<FG label="예측 시간">
|
|
<select className="prd-i" value={predTime} onChange={e => setPredTime(Number(e.target.value))}>
|
|
{PRED_TIMES.map(h => <option key={h} value={h}>{h}시간</option>)}
|
|
</select>
|
|
</FG>
|
|
|
|
{/* 유출 위치 */}
|
|
<FG label="유출 위치 (좌표)">
|
|
<div style={{ display: 'flex', gap: '6px' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '3px' }}>위도 (N)</div>
|
|
<input className="prd-i" type="number" value={lat} step={0.0001} onChange={e => setLat(Number(e.target.value))} style={{ fontFamily: 'var(--fM)' }} />
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '3px' }}>경도 (E)</div>
|
|
<input className="prd-i" type="number" value={lon} step={0.0001} onChange={e => setLon(Number(e.target.value))} style={{ fontFamily: 'var(--fM)' }} />
|
|
</div>
|
|
</div>
|
|
</FG>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{ padding: '14px 20px', borderTop: '1px solid var(--bd)', display: 'flex', gap: '8px' }}>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={phase !== 'editing'}
|
|
style={{
|
|
flex: 1, padding: '10px', fontSize: '12px', fontWeight: 600,
|
|
fontFamily: 'var(--fK)', borderRadius: '8px', cursor: 'pointer',
|
|
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
|
color: 'var(--t2)', opacity: phase !== 'editing' ? 0.5 : 1,
|
|
}}
|
|
>취소</button>
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={phase !== 'editing'}
|
|
style={{
|
|
flex: 2, padding: '10px', fontSize: '12px', fontWeight: 700,
|
|
fontFamily: 'var(--fK)', borderRadius: '8px',
|
|
cursor: phase === 'editing' ? 'pointer' : 'wait',
|
|
background: phase === 'done'
|
|
? 'rgba(34,197,94,0.15)'
|
|
: phase === 'running'
|
|
? 'var(--bg3)'
|
|
: 'linear-gradient(135deg, var(--orange), #ef4444)',
|
|
border: phase === 'done'
|
|
? '1px solid rgba(34,197,94,0.4)'
|
|
: phase === 'running'
|
|
? '1px solid var(--bd)'
|
|
: 'none',
|
|
color: phase === 'done'
|
|
? '#22c55e'
|
|
: phase === 'running'
|
|
? 'var(--orange)'
|
|
: '#fff',
|
|
}}
|
|
>
|
|
{phase === 'done' ? '✅ 재계산 완료!' : phase === 'running' ? '⏳ 재계산 실행중...' : '🔄 재계산 실행'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FG({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>{label}</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
<span style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{label}</span>
|
|
<span style={{ color: 'var(--t1)', fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</span>
|
|
</div>
|
|
)
|
|
}
|