wing-ops/frontend/src/tabs/prediction/components/OilBoomSection.tsx

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