feat(prediction): 오일펜스 배치 가이드 UI 개선
- AI 자동 추천: 클릭 시 "향후 오픈 예정" 팝업 표시 - 수동 배치 탭 제거 - 시뮬레이션: V자형 오일붐 자동 배치 + 차단 시뮬레이션 통합 실행 알고리즘 설정(해류 직교 보정, 안전 마진, 최소 차단 효율, 파고 보정) 시뮬레이션 탭 내 통합 - 초기화: 확인 팝업 추가 (오일펜스만 초기화, 확산예측 결과 유지) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
bb3bd8358b
커밋
b25eccee37
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
||||||
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo'
|
import { generateAIBoomLines, runContainmentAnalysis } from '@common/utils/geo'
|
||||||
|
|
||||||
interface OilBoomSectionProps {
|
interface OilBoomSectionProps {
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
@ -19,6 +19,13 @@ interface OilBoomSectionProps {
|
|||||||
onContainmentResultChange: (result: ContainmentResult | null) => void
|
onContainmentResultChange: (result: ContainmentResult | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AlgorithmSettings = {
|
||||||
|
currentOrthogonalCorrection: 15,
|
||||||
|
safetyMarginMinutes: 60,
|
||||||
|
minContainmentEfficiency: 80,
|
||||||
|
waveHeightCorrectionFactor: 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
const OilBoomSection = ({
|
const OilBoomSection = ({
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
@ -28,14 +35,40 @@ const OilBoomSection = ({
|
|||||||
incidentCoord,
|
incidentCoord,
|
||||||
algorithmSettings,
|
algorithmSettings,
|
||||||
onAlgorithmSettingsChange,
|
onAlgorithmSettingsChange,
|
||||||
isDrawingBoom,
|
|
||||||
onDrawingBoomChange,
|
onDrawingBoomChange,
|
||||||
drawingPoints,
|
|
||||||
onDrawingPointsChange,
|
onDrawingPointsChange,
|
||||||
containmentResult,
|
containmentResult,
|
||||||
onContainmentResultChange,
|
onContainmentResultChange,
|
||||||
}: OilBoomSectionProps) => {
|
}: OilBoomSectionProps) => {
|
||||||
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
|
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 (
|
return (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
@ -54,23 +87,28 @@ const OilBoomSection = ({
|
|||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-4 pb-4 flex flex-col gap-3">
|
<div className="px-4 pb-4 flex flex-col gap-3">
|
||||||
|
|
||||||
{/* Tab Buttons + Reset */}
|
{/* 탭 버튼 + 초기화 */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{[
|
{[
|
||||||
{ id: 'ai' as const, label: 'AI 자동 추천' },
|
{ id: 'ai' as const, label: 'AI 자동 추천' },
|
||||||
{ id: 'manual' as const, label: '수동 배치' },
|
{ id: 'simulation' as const, label: '시뮬레이션' },
|
||||||
{ id: 'simulation' as const, label: '시뮬레이션' }
|
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setBoomPlacementTab(tab.id)}
|
onClick={() => {
|
||||||
|
if (tab.id === 'ai') {
|
||||||
|
alert('AI 자동 추천 기능은 향후 오픈 예정입니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBoomPlacementTab(tab.id)
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
borderRadius: 'var(--rS)',
|
borderRadius: 'var(--rS)',
|
||||||
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
|
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
|
||||||
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
|
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
|
||||||
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
|
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
|
||||||
transition: '0.15s'
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
className="flex-1 text-[10px] font-semibold cursor-pointer"
|
className="flex-1 text-[10px] font-semibold cursor-pointer"
|
||||||
>
|
>
|
||||||
@ -78,26 +116,15 @@ const OilBoomSection = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setShowResetConfirm(true)}
|
||||||
onBoomLinesChange([])
|
disabled={!hasData}
|
||||||
onDrawingBoomChange(false)
|
|
||||||
onDrawingPointsChange([])
|
|
||||||
onContainmentResultChange(null)
|
|
||||||
onAlgorithmSettingsChange({
|
|
||||||
currentOrthogonalCorrection: 15,
|
|
||||||
safetyMarginMinutes: 60,
|
|
||||||
minContainmentEfficiency: 80,
|
|
||||||
waveHeightCorrectionFactor: 1.0,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
disabled={boomLines.length === 0 && !isDrawingBoom && !containmentResult}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
borderRadius: 'var(--rS)',
|
borderRadius: 'var(--rS)',
|
||||||
border: '1px solid var(--bd)',
|
border: '1px solid var(--bd)',
|
||||||
background: 'var(--bg0)',
|
background: 'var(--bg0)',
|
||||||
color: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'var(--t3)' : 'var(--red)',
|
color: hasData ? 'var(--red)' : 'var(--t3)',
|
||||||
cursor: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'not-allowed' : 'pointer',
|
cursor: hasData ? 'pointer' : 'not-allowed',
|
||||||
transition: '0.15s',
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
className="text-[10px] font-semibold shrink-0"
|
className="text-[10px] font-semibold shrink-0"
|
||||||
@ -106,18 +133,65 @@ const OilBoomSection = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Metrics (동적) */}
|
{/* 초기화 확인 팝업 */}
|
||||||
|
{showResetConfirm && (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px',
|
||||||
|
background: 'rgba(239,68,68,0.06)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
borderRadius: 'var(--rM)',
|
||||||
|
}}>
|
||||||
|
<div className="text-[11px] font-bold text-text-1 font-korean mb-2">
|
||||||
|
⚠ 오일펜스 배치 가이드를 초기화 합니다
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-3 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(--red)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: 'var(--red)',
|
||||||
|
transition: '0.15s',
|
||||||
|
}}
|
||||||
|
className="flex-1 text-[10px] font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: 'var(--bg0)',
|
||||||
|
border: '1px solid var(--bd)',
|
||||||
|
borderRadius: 'var(--rS)',
|
||||||
|
color: 'var(--t2)',
|
||||||
|
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' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||||
{[
|
{[
|
||||||
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
|
{ 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 ? `${(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)' }
|
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' },
|
||||||
].map((metric, idx) => (
|
].map((metric, idx) => (
|
||||||
<div key={idx} style={{
|
<div key={idx} style={{
|
||||||
padding: '10px 8px',
|
padding: '10px 8px',
|
||||||
background: 'var(--bg0)',
|
background: 'var(--bg0)',
|
||||||
borderRadius: 'var(--rS)',
|
borderRadius: 'var(--rS)',
|
||||||
textAlign: 'center'
|
textAlign: 'center',
|
||||||
}} className="border border-border">
|
}} className="border border-border">
|
||||||
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
|
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
@ -129,61 +203,24 @@ const OilBoomSection = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== AI 자동 추천 탭 ===== */}
|
{/* ===== 시뮬레이션 탭 ===== */}
|
||||||
{boomPlacementTab === 'ai' && (
|
{boomPlacementTab === 'simulation' && (
|
||||||
<>
|
<>
|
||||||
<div style={{
|
{/* 전제조건 체크 */}
|
||||||
padding: '12px',
|
<div className="flex flex-col gap-1.5">
|
||||||
background: 'rgba(245,158,11,0.05)',
|
<div style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
|
||||||
border: '1px solid rgba(245,158,11,0.3)',
|
className="flex items-center gap-1.5 p-[6px_10px] border border-border text-[10px]">
|
||||||
borderRadius: 'var(--rM)'
|
<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)' }}>
|
||||||
<div className="flex items-center gap-1 mb-2">
|
확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* 알고리즘 설정 */}
|
{/* 알고리즘 설정 */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[11px] font-bold text-primary-cyan mb-2" style={{ letterSpacing: '0.5px' }}>
|
<h4 className="text-[11px] font-bold text-primary-cyan mb-2" style={{ letterSpacing: '0.5px' }}>
|
||||||
📊 배치 알고리즘 설정
|
📊 V자형 배치 알고리즘 설정
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{[
|
{[
|
||||||
@ -194,7 +231,7 @@ const OilBoomSection = ({
|
|||||||
].map((setting) => (
|
].map((setting) => (
|
||||||
<div key={setting.key} style={{
|
<div key={setting.key} style={{
|
||||||
background: 'var(--bg0)',
|
background: 'var(--bg0)',
|
||||||
borderRadius: 'var(--rS)'
|
borderRadius: 'var(--rS)',
|
||||||
}} className="flex items-center justify-between p-[6px_8px] border border-border">
|
}} className="flex items-center justify-between p-[6px_8px] border border-border">
|
||||||
<span className="text-[9px] text-text-3">● {setting.label}</span>
|
<span className="text-[9px] text-text-3">● {setting.label}</span>
|
||||||
<div className="flex items-center gap-[2px]">
|
<div className="flex items-center gap-[2px]">
|
||||||
@ -214,227 +251,50 @@ const OilBoomSection = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== 수동 배치 탭 ===== */}
|
{/* V자형 배치 + 시뮬레이션 실행 버튼 */}
|
||||||
{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
|
<button
|
||||||
onClick={() => {
|
onClick={handleRunSimulation}
|
||||||
const result = runContainmentAnalysis(oilTrajectory, boomLines)
|
disabled={oilTrajectory.length === 0}
|
||||||
onContainmentResultChange(result)
|
|
||||||
}}
|
|
||||||
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
|
|
||||||
style={{
|
style={{
|
||||||
background: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
|
background: oilTrajectory.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)',
|
border: oilTrajectory.length > 0 ? '2px solid var(--cyan)' : '1px solid var(--bd)',
|
||||||
borderRadius: 'var(--rS)',
|
borderRadius: 'var(--rS)',
|
||||||
color: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'var(--cyan)' : 'var(--t3)',
|
color: oilTrajectory.length > 0 ? 'var(--cyan)' : 'var(--t3)',
|
||||||
cursor: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'pointer' : 'not-allowed',
|
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
|
||||||
transition: '0.15s'
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
className="w-full p-[10px] text-[11px] font-bold"
|
className="w-full p-[10px] text-[11px] font-bold"
|
||||||
>
|
>
|
||||||
🔬 차단 시뮬레이션 실행
|
🛡 V자형 오일펜스 배치 + 시뮬레이션 실행
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-text-3 leading-relaxed font-korean">
|
||||||
|
확산 궤적을 분석하여 해류 직교 방향 1차 방어선(V형), U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 배치하고 차단 시뮬레이션을 실행합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* 시뮬레이션 결과 */}
|
{/* 시뮬레이션 결과 */}
|
||||||
{containmentResult && containmentResult.totalParticles > 0 && (
|
{containmentResult && containmentResult.totalParticles > 0 && (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{/* 전체 효율 */}
|
{/* 전체 효율 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px', background: 'rgba(6,182,212,0.05)',
|
padding: '16px', background: 'rgba(6,182,212,0.05)',
|
||||||
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center'
|
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">
|
<div className="text-[28px] font-bold text-primary-cyan font-mono">
|
||||||
{containmentResult.overallEfficiency}%
|
{containmentResult.overallEfficiency}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-3 mt-[2px]">
|
<div className="text-[10px] text-text-3 mt-[2px]">전체 차단 효율</div>
|
||||||
전체 차단 효율
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 차단/통과 카운트 */}
|
{/* 차단/통과 카운트 */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
<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 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">
|
<div className="text-base font-bold text-status-green font-mono">{containmentResult.blockedParticles}</div>
|
||||||
{containmentResult.blockedParticles}
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] text-text-3">차단 입자</div>
|
<div className="text-[8px] text-text-3">차단 입자</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '10px', background: 'var(--bg0)', borderRadius: 'var(--rS)', textAlign: 'center' }} className="border border-border">
|
<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">
|
<div className="text-base font-bold text-status-red font-mono">{containmentResult.passedParticles}</div>
|
||||||
{containmentResult.passedParticles}
|
|
||||||
</div>
|
|
||||||
<div className="text-[8px] text-text-3">통과 입자</div>
|
<div className="text-[8px] text-text-3">통과 입자</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -443,20 +303,16 @@ const OilBoomSection = ({
|
|||||||
<div className="boom-eff-bar">
|
<div className="boom-eff-bar">
|
||||||
<div className="boom-eff-fill" style={{
|
<div className="boom-eff-fill" style={{
|
||||||
width: `${containmentResult.overallEfficiency}%`,
|
width: `${containmentResult.overallEfficiency}%`,
|
||||||
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)'
|
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)',
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 라인별 분석 */}
|
{/* 라인별 분석 */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold text-text-3 mb-1.5">
|
<h4 className="text-[10px] font-bold text-text-3 mb-1.5">라인별 차단 분석</h4>
|
||||||
라인별 차단 분석
|
|
||||||
</h4>
|
|
||||||
{containmentResult.perLineResults.map((r) => (
|
{containmentResult.perLineResults.map((r) => (
|
||||||
<div key={r.boomLineId} style={{
|
<div key={r.boomLineId} style={{ background: 'var(--bg0)', borderRadius: 'var(--rS)' }}
|
||||||
background: 'var(--bg0)',
|
className="flex items-center justify-between p-[6px_8px] mb-1 border border-border text-[9px]">
|
||||||
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 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">
|
<span style={{ color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', marginLeft: '8px' }} className="font-bold font-mono">
|
||||||
{r.blocked}차단 / {r.efficiency}%
|
{r.blocked}차단 / {r.efficiency}%
|
||||||
@ -464,60 +320,52 @@ const OilBoomSection = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 배치된 방어선 카드 */}
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 배치된 방어선 카드 (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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user