377 lines
18 KiB
TypeScript
377 lines
18 KiB
TypeScript
import { useState } from 'react'
|
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
|
import { generateAIBoomLines, runContainmentAnalysis } from '@common/utils/geo'
|
|
|
|
interface OilBoomSectionProps {
|
|
expanded: boolean
|
|
onToggle: () => void
|
|
boomLines: BoomLine[]
|
|
onBoomLinesChange: (lines: BoomLine[]) => void
|
|
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>
|
|
incidentCoord: { lon: number; lat: number }
|
|
algorithmSettings: AlgorithmSettings
|
|
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void
|
|
isDrawingBoom: boolean
|
|
onDrawingBoomChange: (drawing: boolean) => void
|
|
drawingPoints: BoomLineCoord[]
|
|
onDrawingPointsChange: (points: BoomLineCoord[]) => void
|
|
containmentResult: ContainmentResult | null
|
|
onContainmentResultChange: (result: ContainmentResult | null) => void
|
|
}
|
|
|
|
const DEFAULT_SETTINGS: AlgorithmSettings = {
|
|
currentOrthogonalCorrection: 15,
|
|
safetyMarginMinutes: 60,
|
|
minContainmentEfficiency: 80,
|
|
waveHeightCorrectionFactor: 1.0,
|
|
}
|
|
|
|
const OilBoomSection = ({
|
|
expanded,
|
|
onToggle,
|
|
boomLines,
|
|
onBoomLinesChange,
|
|
oilTrajectory,
|
|
incidentCoord,
|
|
algorithmSettings,
|
|
onAlgorithmSettingsChange,
|
|
onDrawingBoomChange,
|
|
onDrawingPointsChange,
|
|
containmentResult,
|
|
onContainmentResultChange,
|
|
}: OilBoomSectionProps) => {
|
|
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'simulation'>('simulation')
|
|
const [showResetConfirm, setShowResetConfirm] = useState(false)
|
|
|
|
const hasData = boomLines.length > 0 || containmentResult !== null
|
|
|
|
/** V자형 오일붐 배치 + 차단 시뮬레이션 실행 */
|
|
const handleRunSimulation = () => {
|
|
// 1단계: V자형 오일붐 자동 배치
|
|
const lines = generateAIBoomLines(
|
|
oilTrajectory,
|
|
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
|
|
algorithmSettings,
|
|
)
|
|
onBoomLinesChange(lines)
|
|
|
|
// 2단계: 차단 시뮬레이션 실행
|
|
const result = runContainmentAnalysis(oilTrajectory, lines)
|
|
onContainmentResultChange(result)
|
|
}
|
|
|
|
/** 초기화 (오일펜스만, 확산예측 유지) */
|
|
const handleReset = () => {
|
|
onBoomLinesChange([])
|
|
onDrawingBoomChange(false)
|
|
onDrawingPointsChange([])
|
|
onContainmentResultChange(null)
|
|
onAlgorithmSettingsChange({ ...DEFAULT_SETTINGS })
|
|
setShowResetConfirm(false)
|
|
}
|
|
|
|
return (
|
|
<div className="border-b border-stroke">
|
|
<div
|
|
onClick={onToggle}
|
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
|
>
|
|
<h3 className="text-[13px] font-bold text-fg-sub font-korean">
|
|
오일펜스 배치 가이드
|
|
</h3>
|
|
<span className="text-[10px] text-fg-disabled">
|
|
{expanded ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="px-4 pb-4 flex flex-col gap-3">
|
|
|
|
{/* 탭 버튼 + 초기화 */}
|
|
<div className="flex gap-1.5">
|
|
{[
|
|
{ id: 'ai' as const, label: 'AI 자동 추천' },
|
|
{ id: 'simulation' as const, label: '시뮬레이션' },
|
|
].map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => {
|
|
if (tab.id === 'ai') {
|
|
alert('AI 자동 추천 기능은 향후 오픈 예정입니다.')
|
|
return
|
|
}
|
|
setBoomPlacementTab(tab.id)
|
|
}}
|
|
style={{
|
|
padding: '6px 8px',
|
|
borderRadius: 'var(--radius-sm)',
|
|
border: boomPlacementTab === tab.id ? '1px solid rgba(6,182,212,0.4)' : '1px solid var(--stroke-default)',
|
|
background: boomPlacementTab === tab.id ? 'rgba(6,182,212,0.08)' : 'var(--bg-base)',
|
|
color: boomPlacementTab === tab.id ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
|
transition: '0.15s',
|
|
}}
|
|
className="flex-1 text-[10px] font-semibold cursor-pointer"
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() => setShowResetConfirm(true)}
|
|
disabled={!hasData}
|
|
style={{
|
|
padding: '6px 10px',
|
|
borderRadius: 'var(--radius-sm)',
|
|
border: '1px solid var(--stroke-default)',
|
|
background: 'var(--bg-base)',
|
|
color: hasData ? 'var(--color-danger)' : 'var(--fg-disabled)',
|
|
cursor: hasData ? 'pointer' : 'not-allowed',
|
|
transition: '0.15s',
|
|
}}
|
|
className="text-[10px] font-semibold shrink-0"
|
|
>
|
|
초기화
|
|
</button>
|
|
</div>
|
|
|
|
{/* 초기화 확인 팝업 */}
|
|
{showResetConfirm && (
|
|
<div style={{
|
|
padding: '14px',
|
|
background: 'rgba(239,68,68,0.06)',
|
|
border: '1px solid rgba(239,68,68,0.3)',
|
|
borderRadius: 'var(--radius-md)',
|
|
}}>
|
|
<div className="text-[11px] font-bold text-fg font-korean mb-2">
|
|
⚠ 오일펜스 배치 가이드를 초기화 합니다
|
|
</div>
|
|
<div className="text-[9px] text-fg-disabled font-korean mb-3">
|
|
배치된 오일펜스 라인과 시뮬레이션 결과가 삭제됩니다. 확산 예측 결과는 유지됩니다.
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleReset}
|
|
style={{
|
|
padding: '6px 14px',
|
|
background: 'rgba(239,68,68,0.15)',
|
|
border: '1px solid var(--color-danger)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
color: 'var(--color-danger)',
|
|
transition: '0.15s',
|
|
}}
|
|
className="flex-1 text-[10px] font-bold cursor-pointer"
|
|
>
|
|
확인
|
|
</button>
|
|
<button
|
|
onClick={() => setShowResetConfirm(false)}
|
|
style={{
|
|
padding: '6px 14px',
|
|
background: 'var(--bg-base)',
|
|
border: '1px solid var(--stroke-default)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
color: 'var(--fg-sub)',
|
|
transition: '0.15s',
|
|
}}
|
|
className="flex-1 text-[10px] font-bold cursor-pointer"
|
|
>
|
|
취소
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Key Metrics */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
|
{[
|
|
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--color-accent)' },
|
|
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--color-accent)' },
|
|
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--color-accent)' },
|
|
].map((metric, idx) => (
|
|
<div key={idx} style={{
|
|
padding: '10px 8px',
|
|
background: 'var(--bg-base)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
textAlign: 'center',
|
|
}} className="border border-stroke">
|
|
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
|
|
{metric.value}
|
|
</div>
|
|
<div className="text-[8px] text-fg-disabled">
|
|
{metric.label}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ===== 시뮬레이션 탭 ===== */}
|
|
{boomPlacementTab === 'simulation' && (
|
|
<>
|
|
{/* 전제조건 체크 */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<div style={{ background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)' }}
|
|
className="flex items-center gap-1.5 p-[6px_10px] border border-stroke text-[10px]">
|
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--color-danger)' }} />
|
|
<span style={{ color: oilTrajectory.length > 0 ? 'var(--color-success)' : 'var(--fg-disabled)' }}>
|
|
확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 알고리즘 설정 */}
|
|
<div>
|
|
<h4 className="text-[11px] font-bold text-fg-sub mb-2" style={{ letterSpacing: '0.5px' }}>
|
|
📊 V자형 배치 알고리즘 설정
|
|
</h4>
|
|
<div className="flex flex-col gap-2">
|
|
{[
|
|
{ label: '해류 직교 보정', key: 'currentOrthogonalCorrection' as const, unit: '°', value: algorithmSettings.currentOrthogonalCorrection },
|
|
{ label: '안전 마진 (도달시간)', key: 'safetyMarginMinutes' as const, unit: '분', value: algorithmSettings.safetyMarginMinutes },
|
|
{ label: '최소 차단 효율', key: 'minContainmentEfficiency' as const, unit: '%', value: algorithmSettings.minContainmentEfficiency },
|
|
{ label: '파고 보정 계수', key: 'waveHeightCorrectionFactor' as const, unit: 'x', value: algorithmSettings.waveHeightCorrectionFactor },
|
|
].map((setting) => (
|
|
<div key={setting.key} style={{
|
|
background: 'var(--bg-base)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
}} className="flex items-center justify-between px-2.5 py-1.5 border border-stroke">
|
|
<span className="flex-1 text-[9px] text-fg-disabled truncate">● {setting.label}</span>
|
|
<div className="flex items-center gap-1 shrink-0 w-[80px] justify-end">
|
|
<input
|
|
type="number"
|
|
value={setting.value}
|
|
onChange={(e) => {
|
|
const val = parseFloat(e.target.value) || 0
|
|
onAlgorithmSettingsChange({ ...algorithmSettings, [setting.key]: val })
|
|
}}
|
|
className="boom-setting-input"
|
|
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
|
|
/>
|
|
<span className="text-[9px] text-fg-disabled w-[14px]">{setting.unit}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* V자형 배치 + 시뮬레이션 실행 버튼 */}
|
|
<button
|
|
onClick={handleRunSimulation}
|
|
disabled={oilTrajectory.length === 0}
|
|
style={{
|
|
background: 'transparent',
|
|
border: '1px solid var(--color-accent)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
color: oilTrajectory.length > 0 ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
|
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
|
|
opacity: oilTrajectory.length > 0 ? 1 : 0.4,
|
|
transition: '0.15s',
|
|
}}
|
|
className="w-full p-[10px] text-[11px] font-bold"
|
|
>
|
|
V자형 오일펜스 배치 + 시뮬레이션 실행
|
|
</button>
|
|
|
|
<p className="text-[9px] text-fg-disabled leading-relaxed font-korean">
|
|
확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.
|
|
</p>
|
|
|
|
{/* 시뮬레이션 결과 */}
|
|
{containmentResult && containmentResult.totalParticles > 0 && (
|
|
<div className="flex flex-col gap-2.5">
|
|
{/* 전체 효율 */}
|
|
<div style={{
|
|
padding: '16px', background: 'rgba(6,182,212,0.05)',
|
|
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--radius-md)', textAlign: 'center',
|
|
}}>
|
|
<div className="text-[28px] font-bold text-color-accent font-mono">
|
|
{containmentResult.overallEfficiency}%
|
|
</div>
|
|
<div className="text-[10px] text-fg-disabled mt-[2px]">전체 차단 효율</div>
|
|
</div>
|
|
|
|
{/* 차단/통과 카운트 */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
|
<div style={{ padding: '10px', background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }} className="border border-stroke">
|
|
<div className="text-base font-bold text-color-success font-mono">{containmentResult.blockedParticles}</div>
|
|
<div className="text-[8px] text-fg-disabled">차단 입자</div>
|
|
</div>
|
|
<div style={{ padding: '10px', background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }} className="border border-stroke">
|
|
<div className="text-base font-bold text-color-danger font-mono">{containmentResult.passedParticles}</div>
|
|
<div className="text-[8px] text-fg-disabled">통과 입자</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 효율 바 */}
|
|
<div className="boom-eff-bar">
|
|
<div className="boom-eff-fill" style={{
|
|
width: `${containmentResult.overallEfficiency}%`,
|
|
background: containmentResult.overallEfficiency >= 80 ? 'var(--color-success)' : containmentResult.overallEfficiency >= 50 ? 'var(--color-warning)' : 'var(--color-danger)',
|
|
}} />
|
|
</div>
|
|
|
|
{/* 라인별 분석 */}
|
|
<div>
|
|
<h4 className="text-[10px] font-bold text-fg-sub mb-1.5">라인별 차단 분석</h4>
|
|
{containmentResult.perLineResults.map((r) => (
|
|
<div key={r.boomLineId} style={{ background: 'var(--bg-base)', borderRadius: 'var(--radius-sm)' }}
|
|
className="flex items-center justify-between p-[6px_8px] mb-1 border border-stroke text-[9px]">
|
|
<span className="text-fg-sub flex-1">{r.boomLineName}</span>
|
|
<span style={{ color: r.efficiency >= 50 ? 'var(--color-success)' : 'var(--color-warning)', marginLeft: '8px' }} className="font-bold font-mono">
|
|
{r.blocked}차단 / {r.efficiency}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 배치된 방어선 카드 */}
|
|
{boomLines.map((line, idx) => {
|
|
const priorityColor = line.priority === 'CRITICAL' ? 'var(--color-danger)' : line.priority === 'HIGH' ? 'var(--color-warning)' : 'var(--color-caution)'
|
|
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
|
|
return (
|
|
<div key={line.id} style={{
|
|
padding: '10px', background: 'var(--bg-base)',
|
|
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--radius-sm)',
|
|
}} className="border border-stroke">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[11px] font-bold text-fg">
|
|
🛡 {idx + 1}차 방어선 ({line.type})
|
|
</span>
|
|
<span style={{
|
|
padding: '2px 6px',
|
|
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
|
|
borderRadius: '3px', color: priorityColor,
|
|
}} className="text-[8px] font-bold">
|
|
{priorityLabel}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }} className="mb-1.5">
|
|
<div>
|
|
<span className="text-[8px] text-fg-disabled">길이</span>
|
|
<div className="text-sm font-bold font-mono text-fg">{line.length.toFixed(0)}m</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-[8px] text-fg-disabled">각도</span>
|
|
<div className="text-sm font-bold font-mono text-fg">{line.angle.toFixed(0)}°</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--color-success)' : 'var(--color-warning)' }} />
|
|
<span style={{ color: line.efficiency >= 80 ? 'var(--color-success)' : 'var(--color-warning)' }} className="text-[9px] font-semibold">
|
|
차단 효율 {line.efficiency}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default OilBoomSection
|