feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
expanded: boolean
|
||||
@ -19,6 +19,13 @@ interface OilBoomSectionProps {
|
||||
onContainmentResultChange: (result: ContainmentResult | null) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AlgorithmSettings = {
|
||||
currentOrthogonalCorrection: 15,
|
||||
safetyMarginMinutes: 60,
|
||||
minContainmentEfficiency: 80,
|
||||
waveHeightCorrectionFactor: 1.0,
|
||||
}
|
||||
|
||||
const OilBoomSection = ({
|
||||
expanded,
|
||||
onToggle,
|
||||
@ -28,14 +35,40 @@ const OilBoomSection = ({
|
||||
incidentCoord,
|
||||
algorithmSettings,
|
||||
onAlgorithmSettingsChange,
|
||||
isDrawingBoom,
|
||||
onDrawingBoomChange,
|
||||
drawingPoints,
|
||||
onDrawingPointsChange,
|
||||
containmentResult,
|
||||
onContainmentResultChange,
|
||||
}: 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 (
|
||||
<div className="border-b border-border">
|
||||
@ -54,23 +87,28 @@ const OilBoomSection = ({
|
||||
{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: '시뮬레이션' }
|
||||
{ id: 'simulation' as const, label: '시뮬레이션' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setBoomPlacementTab(tab.id)}
|
||||
onClick={() => {
|
||||
if (tab.id === 'ai') {
|
||||
alert('AI 자동 추천 기능은 향후 오픈 예정입니다.')
|
||||
return
|
||||
}
|
||||
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'
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="flex-1 text-[10px] font-semibold cursor-pointer"
|
||||
>
|
||||
@ -78,26 +116,15 @@ const OilBoomSection = ({
|
||||
</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}
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
disabled={!hasData}
|
||||
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',
|
||||
color: hasData ? 'var(--red)' : 'var(--t3)',
|
||||
cursor: hasData ? 'pointer' : 'not-allowed',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="text-[10px] font-semibold shrink-0"
|
||||
@ -106,18 +133,65 @@ const OilBoomSection = ({
|
||||
</button>
|
||||
</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' }}>
|
||||
{[
|
||||
{ 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)' }
|
||||
{ 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'
|
||||
textAlign: 'center',
|
||||
}} className="border border-border">
|
||||
<div style={{ color: metric.color }} className="text-lg font-bold font-mono mb-[2px]">
|
||||
{metric.value}
|
||||
@ -129,61 +203,24 @@ const OilBoomSection = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ===== AI 자동 추천 탭 ===== */}
|
||||
{boomPlacementTab === 'ai' && (
|
||||
{/* ===== 시뮬레이션 탭 ===== */}
|
||||
{boomPlacementTab === 'simulation' && (
|
||||
<>
|
||||
<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 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
|
||||
{/* 전제조건 체크 */}
|
||||
<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>
|
||||
|
||||
<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' }}>
|
||||
📊 배치 알고리즘 설정
|
||||
📊 V자형 배치 알고리즘 설정
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
@ -194,7 +231,7 @@ const OilBoomSection = ({
|
||||
].map((setting) => (
|
||||
<div key={setting.key} style={{
|
||||
background: 'var(--bg0)',
|
||||
borderRadius: 'var(--rS)'
|
||||
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]">
|
||||
@ -214,227 +251,50 @@ const OilBoomSection = ({
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* 실행 버튼 */}
|
||||
{/* V자형 배치 + 시뮬레이션 실행 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const result = runContainmentAnalysis(oilTrajectory, boomLines)
|
||||
onContainmentResultChange(result)
|
||||
}}
|
||||
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
|
||||
onClick={handleRunSimulation}
|
||||
disabled={oilTrajectory.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)',
|
||||
background: oilTrajectory.length > 0 ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
|
||||
border: oilTrajectory.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'
|
||||
color: oilTrajectory.length > 0 ? 'var(--cyan)' : 'var(--t3)',
|
||||
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: '0.15s',
|
||||
}}
|
||||
className="w-full p-[10px] text-[11px] font-bold"
|
||||
>
|
||||
🔬 차단 시뮬레이션 실행
|
||||
🛡 V자형 오일펜스 배치 + 시뮬레이션 실행
|
||||
</button>
|
||||
|
||||
<p className="text-[9px] text-text-3 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(--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">
|
||||
{containmentResult.overallEfficiency}%
|
||||
</div>
|
||||
<div className="text-[10px] text-text-3 mt-[2px]">
|
||||
전체 차단 효율
|
||||
</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-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-base font-bold text-status-red font-mono">{containmentResult.passedParticles}</div>
|
||||
<div className="text-[8px] text-text-3">통과 입자</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -443,20 +303,16 @@ const OilBoomSection = ({
|
||||
<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)'
|
||||
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>
|
||||
<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]">
|
||||
<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}%
|
||||
@ -464,60 +320,52 @@ const OilBoomSection = ({
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 배치된 방어선 카드 (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>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user