wing-ops/frontend/src/tabs/prediction/components/OilBoomSection.tsx
htlee 34cf046787 fix(css): CSS 회귀 버그 3건 수정 + SCAT 우측 패널 구현
- className 중복 속성 31건 수정 (12파일)
- KOSPS codeBox spread TypeError 해결
- HNS 페놀(C₆H₅OH) 물질 데이터 추가
- ScatRightPanel 280px 우측 패널 신규 구현 (3탭+액션버튼)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:11:21 +09:00

528 lines
26 KiB
TypeScript

import { useState } from 'react'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } 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 OilBoomSection = ({
expanded,
onToggle,
boomLines,
onBoomLinesChange,
oilTrajectory,
incidentCoord,
algorithmSettings,
onAlgorithmSettingsChange,
isDrawingBoom,
onDrawingBoomChange,
drawingPoints,
onDrawingPointsChange,
containmentResult,
onContainmentResultChange,
}: OilBoomSectionProps) => {
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
return (
<div className="border-b border-border">
<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-text-2 font-korean">
🛡
</h3>
<span className="text-[10px] text-text-3">
{expanded ? '▼' : '▶'}
</span>
</div>
{expanded && (
<div className="px-4 pb-4 flex flex-col gap-3">
{/* Tab Buttons + Reset */}
<div className="flex gap-1.5">
{[
{ id: 'ai' as const, label: 'AI 자동 추천' },
{ id: 'manual' as const, label: '수동 배치' },
{ id: 'simulation' as const, label: '시뮬레이션' }
].map(tab => (
<button
key={tab.id}
onClick={() => setBoomPlacementTab(tab.id)}
style={{
padding: '6px 8px',
borderRadius: 'var(--rS)',
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
transition: '0.15s'
}}
className="flex-1 text-[10px] font-semibold cursor-pointer"
>
{tab.label}
</button>
))}
<button
onClick={() => {
onBoomLinesChange([])
onDrawingBoomChange(false)
onDrawingPointsChange([])
onContainmentResultChange(null)
onAlgorithmSettingsChange({
currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60,
minContainmentEfficiency: 80,
waveHeightCorrectionFactor: 1.0,
})
}}
disabled={boomLines.length === 0 && !isDrawingBoom && !containmentResult}
style={{
padding: '6px 10px',
borderRadius: 'var(--rS)',
border: '1px solid var(--bd)',
background: 'var(--bg0)',
color: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'var(--t3)' : 'var(--red)',
cursor: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'not-allowed' : 'pointer',
transition: '0.15s',
}}
className="text-[10px] font-semibold shrink-0"
>
</button>
</div>
{/* Key Metrics (동적) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
{[
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' },
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' }
].map((metric, idx) => (
<div key={idx} style={{
padding: '10px 8px',
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
textAlign: 'center'
}} className="border border-border">
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
{metric.value}
</div>
<div className="text-[8px] text-text-3">
{metric.label}
</div>
</div>
))}
</div>
{/* ===== AI 자동 추천 탭 ===== */}
{boomPlacementTab === 'ai' && (
<>
<div style={{
padding: '12px',
background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 'var(--rM)'
}}>
<div className="flex items-center gap-1 mb-2">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} className="text-[10px] font-bold">
{oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
</span>
</div>
<h4 className="text-[13px] font-bold mb-2">
</h4>
<p className="leading-normal text-[9px] text-text-3 mb-2.5">
{oilTrajectory.length > 0
? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.'
: '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.'
}
</p>
<button
onClick={() => {
const lines = generateAIBoomLines(
oilTrajectory,
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
algorithmSettings
)
onBoomLinesChange(lines)
}}
disabled={oilTrajectory.length === 0}
style={{
background: oilTrajectory.length > 0 ? 'rgba(245,158,11,0.15)' : 'var(--bg0)',
border: oilTrajectory.length > 0 ? '2px solid var(--orange)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: oilTrajectory.length > 0 ? 'var(--orange)' : 'var(--t3)',
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
transition: '0.15s'
}}
className="w-full p-[10px] text-[11px] font-bold"
>
🛡
</button>
</div>
{/* 알고리즘 설정 */}
<div>
<h4 className="text-[11px] font-bold text-primary-cyan mb-2" style={{ letterSpacing: '0.5px' }}>
📊
</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(--bg0)',
borderRadius: 'var(--rS)'
}} className="flex items-center justify-between p-[6px_8px] border border-border">
<span className="text-[9px] text-text-3"> {setting.label}</span>
<div className="flex items-center gap-[2px]">
<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-status-orange">{setting.unit}</span>
</div>
</div>
))}
</div>
</div>
</>
)}
{/* ===== 수동 배치 탭 ===== */}
{boomPlacementTab === 'manual' && (
<>
{/* 드로잉 컨트롤 */}
<div className="flex gap-1.5">
{!isDrawingBoom ? (
<button
onClick={() => { onDrawingBoomChange(true); onDrawingPointsChange([]) }}
style={{
background: 'rgba(245,158,11,0.15)', border: '2px solid var(--orange)',
borderRadius: 'var(--rS)', color: 'var(--orange)', transition: '0.15s'
}}
className="flex-1 p-[10px] text-[11px] font-bold cursor-pointer"
>
🛡
</button>
) : (
<>
<button
onClick={() => {
if (drawingPoints.length >= 2) {
const newLine: BoomLine = {
id: `boom-manual-${Date.now()}`,
name: `수동 방어선 ${boomLines.length + 1}`,
priority: 'HIGH',
type: '기타',
coords: [...drawingPoints],
length: computePolylineLength(drawingPoints),
angle: computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]),
efficiency: 0,
status: 'PLANNED',
}
onBoomLinesChange([...boomLines, newLine])
}
onDrawingBoomChange(false)
onDrawingPointsChange([])
}}
disabled={drawingPoints.length < 2}
style={{
background: drawingPoints.length >= 2 ? 'rgba(34,197,94,0.15)' : 'var(--bg0)',
border: drawingPoints.length >= 2 ? '2px solid var(--green)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: drawingPoints.length >= 2 ? 'var(--green)' : 'var(--t3)',
cursor: drawingPoints.length >= 2 ? 'pointer' : 'not-allowed', transition: '0.15s'
}}
className="flex-1 p-[10px] text-[11px] font-bold"
>
({drawingPoints.length})
</button>
<button
onClick={() => { onDrawingBoomChange(false); onDrawingPointsChange([]) }}
style={{
padding: '10px 14px',
background: 'rgba(239,68,68,0.1)', border: '1px solid var(--red)',
borderRadius: 'var(--rS)', color: 'var(--red)', transition: '0.15s'
}}
className="text-[11px] font-bold cursor-pointer"
>
</button>
</>
)}
</div>
{/* 드로잉 실시간 정보 */}
{isDrawingBoom && drawingPoints.length > 0 && (
<div style={{
padding: '8px 10px', background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)', borderRadius: 'var(--rS)',
}} className="flex gap-3 text-[10px] text-text-2">
<span>: <strong className="text-status-orange font-mono">{drawingPoints.length}</strong></span>
<span>: <strong className="text-primary-cyan font-mono">{computePolylineLength(drawingPoints).toFixed(0)}m</strong></span>
{drawingPoints.length >= 2 && (
<span>: <strong className="font-mono">{computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}°</strong></span>
)}
</div>
)}
{/* 배치된 라인 목록 */}
{boomLines.length === 0 ? (
<p className="text-[10px] text-text-3 text-center py-4">
.
</p>
) : (
boomLines.map((line, idx) => (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)',
borderLeft: `3px solid ${line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'}`,
borderRadius: 'var(--rS)'
}} className="border border-border">
<div className="flex items-center justify-between mb-1.5">
<input
type="text"
value={line.name}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], name: e.target.value }
onBoomLinesChange(updated)
}}
className="flex-1 text-[11px] font-bold bg-transparent border-none outline-none"
/>
<button
onClick={() => onBoomLinesChange(boomLines.filter(l => l.id !== line.id))}
className="text-[10px] text-status-red bg-transparent border-none cursor-pointer px-1.5 py-[2px]"
>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }} className="text-[9px]">
<div>
<span className="text-text-3"></span>
<div className="font-bold font-mono">{line.length.toFixed(0)}m</div>
</div>
<div>
<span className="text-text-3"></span>
<div className="font-bold font-mono">{line.angle.toFixed(0)}°</div>
</div>
<div>
<span className="text-text-3"></span>
<select
value={line.priority}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], priority: e.target.value as BoomLine['priority'] }
onBoomLinesChange(updated)
}}
style={{
background: 'var(--bg0)',
borderRadius: '3px',
padding: '2px',
}}
className="w-full text-[10px] font-semibold border border-border outline-none"
>
<option value="CRITICAL"></option>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
</select>
</div>
</div>
</div>
))
)}
</>
)}
{/* ===== 시뮬레이션 탭 ===== */}
{boomPlacementTab === 'simulation' && (
<>
{/* 전제조건 체크 */}
<div className="flex flex-col gap-1.5">
<div style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span>
</div>
<div style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: boomLines.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: boomLines.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'}
</span>
</div>
</div>
{/* 실행 버튼 */}
<button
onClick={() => {
const result = runContainmentAnalysis(oilTrajectory, boomLines)
onContainmentResultChange(result)
}}
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
style={{
background: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
border: (oilTrajectory.length > 0 && boomLines.length > 0) ? '2px solid var(--cyan)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'var(--cyan)' : 'var(--t3)',
cursor: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'pointer' : 'not-allowed',
transition: '0.15s'
}}
className="w-full p-[10px] text-[11px] font-bold"
>
🔬
</button>
{/* 시뮬레이션 결과 */}
{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(--rM)', textAlign: 'center'
}}>
<div className="text-[28px] font-bold text-primary-cyan font-mono">
{containmentResult.overallEfficiency}%
</div>
<div className="text-[10px] text-text-3 mt-[2px]">
</div>
</div>
{/* 차단/통과 카운트 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
<div className="text-base font-bold text-status-green font-mono">
{containmentResult.blockedParticles}
</div>
<div className="text-[8px] text-text-3"> </div>
</div>
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
<div className="text-base font-bold text-status-red font-mono">
{containmentResult.passedParticles}
</div>
<div className="text-[8px] text-text-3"> </div>
</div>
</div>
{/* 효율 바 */}
<div className="boom-eff-bar">
<div className="boom-eff-fill" style={{
width: `${containmentResult.overallEfficiency}%`,
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)'
}} />
</div>
{/* 라인별 분석 */}
<div>
<h4 className="text-[10px] font-bold text-text-3 mb-1.5">
</h4>
{containmentResult.perLineResults.map((r) => (
<div key={r.boomLineId} style={{
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
}} className="flex items-center justify-between p-[6px_8px] mb-1 border border-border text-[9px]">
<span className="text-text-2 flex-1">{r.boomLineName}</span>
<span style={{ color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', marginLeft: '8px' }} className="font-bold font-mono">
{r.blocked} / {r.efficiency}%
</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 배치된 방어선 카드 (AI/수동 공통 표시) */}
{boomPlacementTab !== 'simulation' && boomLines.length > 0 && boomPlacementTab === 'ai' && (
<>
{boomLines.map((line, idx) => {
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
return (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)',
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)'
}} className="border border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-bold">
🛡 {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-text-3"></span>
<div className="text-sm font-bold font-mono">
{line.length.toFixed(0)}m
</div>
</div>
<div>
<span className="text-[8px] text-text-3"></span>
<div className="text-sm font-bold font-mono">
{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(--green)' : 'var(--orange)' }} />
<span style={{ color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} className="text-[9px] font-semibold">
{line.efficiency}%
</span>
</div>
</div>
)
})}
</>
)}
</div>
)}
</div>
)
}
export default OilBoomSection