- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1238 lines
59 KiB
TypeScript
Executable File
1238 lines
59 KiB
TypeScript
Executable File
import { useState, useMemo } from 'react'
|
|
import { LayerTree } from '@common/components/layer/LayerTree'
|
|
import { useLayerTree } from '@common/hooks/useLayers'
|
|
import { layerData } from '../../../data/layerData'
|
|
import type { LayerNode } from '../../../data/layerData'
|
|
import type { Layer } from '../../../data/layerDatabase'
|
|
import { decimalToDMS } from '@common/utils/coordinates'
|
|
import { ComboBox } from '@common/components/ui/ComboBox'
|
|
import { ALL_MODELS } from './OilSpillView'
|
|
import type { PredictionModel } from './OilSpillView'
|
|
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
|
|
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo'
|
|
import type { Analysis } from './AnalysisListTable'
|
|
|
|
interface LeftPanelProps {
|
|
selectedAnalysis?: Analysis | null
|
|
enabledLayers: Set<string>
|
|
onToggleLayer: (layerId: string, enabled: boolean) => void
|
|
incidentCoord: { lon: number; lat: number }
|
|
onCoordChange: (coord: { lon: number; lat: number }) => void
|
|
onMapSelectClick: () => void
|
|
onRunSimulation: () => void
|
|
isRunningSimulation: boolean
|
|
selectedModels: Set<PredictionModel>
|
|
onModelsChange: (models: Set<PredictionModel>) => void
|
|
predictionTime: number
|
|
onPredictionTimeChange: (time: number) => void
|
|
spillType: string
|
|
onSpillTypeChange: (type: string) => void
|
|
oilType: string
|
|
onOilTypeChange: (type: string) => void
|
|
spillAmount: number
|
|
onSpillAmountChange: (amount: number) => void
|
|
// 오일펜스 배치 관련
|
|
boomLines: BoomLine[]
|
|
onBoomLinesChange: (lines: BoomLine[]) => void
|
|
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: 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
|
|
// 레이어 스타일
|
|
layerOpacity: number
|
|
onLayerOpacityChange: (val: number) => void
|
|
layerBrightness: number
|
|
onLayerBrightnessChange: (val: number) => void
|
|
}
|
|
|
|
export function LeftPanel({
|
|
selectedAnalysis,
|
|
enabledLayers,
|
|
onToggleLayer,
|
|
incidentCoord,
|
|
onCoordChange,
|
|
onMapSelectClick,
|
|
onRunSimulation,
|
|
isRunningSimulation,
|
|
selectedModels,
|
|
onModelsChange,
|
|
predictionTime,
|
|
onPredictionTimeChange,
|
|
spillType,
|
|
onSpillTypeChange,
|
|
oilType,
|
|
onOilTypeChange,
|
|
spillAmount,
|
|
onSpillAmountChange,
|
|
boomLines,
|
|
onBoomLinesChange,
|
|
oilTrajectory,
|
|
algorithmSettings,
|
|
onAlgorithmSettingsChange,
|
|
isDrawingBoom,
|
|
onDrawingBoomChange,
|
|
drawingPoints,
|
|
onDrawingPointsChange,
|
|
containmentResult,
|
|
onContainmentResultChange,
|
|
layerOpacity,
|
|
onLayerOpacityChange,
|
|
layerBrightness,
|
|
onLayerBrightnessChange,
|
|
}: LeftPanelProps) {
|
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
|
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
|
const [uploadedFileName, setUploadedFileName] = useState<string>('')
|
|
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
|
|
const [expandedSections, setExpandedSections] = useState({
|
|
predictionInput: true,
|
|
incident: false,
|
|
impactResources: false,
|
|
infoLayer: true,
|
|
oilBoom: false,
|
|
})
|
|
|
|
// API에서 레이어 트리 데이터 가져오기
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { data: layerTree, isLoading, error } = useLayerTree()
|
|
|
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
|
|
|
|
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
|
|
const staticLayers = useMemo(() => {
|
|
const convert = (node: LayerNode): Layer => ({
|
|
id: node.code,
|
|
parentId: node.parentCode,
|
|
name: node.name,
|
|
fullName: node.fullName,
|
|
level: node.level,
|
|
wmsLayer: node.layerName,
|
|
icon: node.icon,
|
|
count: node.count,
|
|
children: node.children?.map(convert),
|
|
})
|
|
return layerData.map(convert)
|
|
}, [])
|
|
|
|
// API 데이터 우선, 실패 시 정적 데이터 폴백
|
|
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
|
|
|
|
const toggleSection = (section: keyof typeof expandedSections) => {
|
|
setExpandedSections(prev => ({
|
|
...prev,
|
|
[section]: !prev[section]
|
|
}))
|
|
}
|
|
|
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
setUploadedFileName(file.name)
|
|
const reader = new FileReader()
|
|
reader.onload = (event) => {
|
|
setUploadedImage(event.target?.result as string)
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
}
|
|
|
|
const removeUploadedImage = () => {
|
|
setUploadedImage(null)
|
|
setUploadedFileName('')
|
|
}
|
|
|
|
return (
|
|
<div className="w-80 min-w-[320px] bg-bg-1 border-r border-border flex flex-col">
|
|
{/* Scrollable Content */}
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent">
|
|
{/* Prediction Input Section */}
|
|
<div className="border-b border-border">
|
|
<div
|
|
onClick={() => toggleSection('predictionInput')}
|
|
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">
|
|
{expandedSections.predictionInput ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expandedSections.predictionInput && (
|
|
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
{/* Input Mode Selection */}
|
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', fontSize: '11px', color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'direct'}
|
|
onChange={() => setInputMode('direct')}
|
|
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
|
|
/>
|
|
직접 입력
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'upload'}
|
|
onChange={() => setInputMode('upload')}
|
|
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
|
|
/>
|
|
이미지 업로드
|
|
</label>
|
|
</div>
|
|
|
|
{/* Direct Input Mode */}
|
|
{inputMode === 'direct' && (
|
|
<>
|
|
<input className="prd-i" placeholder="사고명 직접 입력" />
|
|
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" />
|
|
</>
|
|
)}
|
|
|
|
{/* Image Upload Mode */}
|
|
{inputMode === 'upload' && (
|
|
<>
|
|
<input className="prd-i" placeholder="여수 유조선 충돌" />
|
|
<ComboBox
|
|
className="prd-i"
|
|
value=""
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '', label: '여수 유조선 충돌 (INC-0042)' },
|
|
{ value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' }
|
|
]}
|
|
placeholder="사고 선택"
|
|
/>
|
|
|
|
{/* Upload Success Message */}
|
|
{uploadedImage && (
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
padding: '6px 8px',
|
|
background: 'rgba(34,197,94,0.1)',
|
|
border: '1px solid rgba(34,197,94,0.3)',
|
|
borderRadius: 'var(--rS)',
|
|
fontSize: '10px',
|
|
color: '#22c55e',
|
|
fontFamily: 'var(--fK)',
|
|
fontWeight: 600
|
|
}}>
|
|
<span style={{ fontSize: '12px' }}>✓</span>
|
|
내 이미지가 업로드됨
|
|
</div>
|
|
)}
|
|
|
|
{/* File Upload Area */}
|
|
{!uploadedImage ? (
|
|
<label style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: '20px',
|
|
background: 'var(--bg0)',
|
|
border: '2px dashed var(--bd)',
|
|
borderRadius: 'var(--rS)',
|
|
cursor: 'pointer',
|
|
transition: '0.15s',
|
|
fontSize: '11px',
|
|
color: 'var(--t3)',
|
|
fontFamily: 'var(--fK)'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--cyan)'
|
|
e.currentTarget.style.background = 'rgba(6,182,212,0.05)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--bd)'
|
|
e.currentTarget.style.background = 'var(--bg0)'
|
|
}}>
|
|
📁 이미지 파일을 선택하세요
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageUpload}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</label>
|
|
) : (
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '8px 10px',
|
|
background: 'var(--bg0)',
|
|
border: '1px solid var(--bd)',
|
|
borderRadius: 'var(--rS)',
|
|
fontSize: '10px',
|
|
fontFamily: 'var(--fM)'
|
|
}}>
|
|
<span style={{ color: 'var(--t2)' }}>📄 {uploadedFileName || 'example_plot_0.gif'}</span>
|
|
<button
|
|
onClick={removeUploadedImage}
|
|
style={{
|
|
padding: '2px 6px',
|
|
fontSize: '10px',
|
|
color: 'var(--t3)',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
transition: '0.15s'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = 'var(--red)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = 'var(--t3)'
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dropdowns */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value=""
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '', label: '유출회사' },
|
|
{ value: 'company1', label: '회사A' },
|
|
{ value: 'company2', label: '회사B' }
|
|
]}
|
|
placeholder="유출회사"
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value=""
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: '', label: '예상시각' },
|
|
{ value: '09:00', label: '09:00' },
|
|
{ value: '12:00', label: '12:00' }
|
|
]}
|
|
placeholder="예상시각"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Coordinates + Map Button */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '4px', alignItems: 'center' }}>
|
|
<input
|
|
className="prd-i"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lat ?? ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
|
onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value })
|
|
}}
|
|
placeholder="위도°"
|
|
/>
|
|
<input
|
|
className="prd-i"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lon ?? ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
|
onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value })
|
|
}}
|
|
placeholder="경도°"
|
|
/>
|
|
<button className="prd-map-btn" onClick={onMapSelectClick}>📍 지도</button>
|
|
</div>
|
|
{/* 도분초 표시 */}
|
|
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
|
<div style={{
|
|
fontSize: '9px',
|
|
color: 'var(--t3)',
|
|
fontFamily: 'var(--fM)',
|
|
padding: '4px 8px',
|
|
background: 'var(--bg0)',
|
|
borderRadius: 'var(--rS)',
|
|
border: '1px solid var(--bd)'
|
|
}}>
|
|
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Oil Type + Oil Kind */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={spillType}
|
|
onChange={onSpillTypeChange}
|
|
options={[
|
|
{ value: '연속', label: '연속' },
|
|
{ value: '비연속', label: '비연속' },
|
|
{ value: '순간 유출', label: '순간 유출' }
|
|
]}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={oilType}
|
|
onChange={onOilTypeChange}
|
|
options={[
|
|
{ value: '벙커C유', label: '벙커C유' },
|
|
{ value: '경유', label: '경유' },
|
|
{ value: '원유', label: '원유' },
|
|
{ value: '중유', label: '중유' },
|
|
{ value: '등유', label: '등유' },
|
|
{ value: '휘발유', label: '휘발유' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* Volume + Unit + Duration */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 65px 1fr', gap: '4px', alignItems: 'center' }}>
|
|
<input
|
|
className="prd-i"
|
|
placeholder="유출량"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={spillAmount}
|
|
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value="kL"
|
|
onChange={() => {}}
|
|
options={[
|
|
{ value: 'kL', label: 'kL' },
|
|
{ value: 'ton', label: 'Ton' },
|
|
{ value: 'barrel', label: '배럴' }
|
|
]}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={predictionTime}
|
|
onChange={(v) => onPredictionTimeChange(parseInt(v))}
|
|
options={[
|
|
{ value: '6', label: '6시간' },
|
|
{ value: '12', label: '12시간' },
|
|
{ value: '24', label: '24시간' },
|
|
{ value: '48', label: '48시간' },
|
|
{ value: '72', label: '72시간' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* Image Analysis Note (Upload Mode Only) */}
|
|
{inputMode === 'upload' && uploadedImage && (
|
|
<div style={{
|
|
padding: '8px',
|
|
background: 'rgba(59,130,246,0.08)',
|
|
border: '1px solid rgba(59,130,246,0.2)',
|
|
borderRadius: 'var(--rS)',
|
|
fontSize: '9px',
|
|
color: 'var(--t3)',
|
|
fontFamily: 'var(--fK)',
|
|
lineHeight: '1.4'
|
|
}}>
|
|
📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요.
|
|
</div>
|
|
)}
|
|
|
|
{/* Divider */}
|
|
<div style={{ height: '1px', background: 'var(--bd)', margin: '2px 0' }} />
|
|
|
|
{/* Model Selection (다중 선택) */}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
|
|
{([
|
|
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
|
|
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
|
|
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
|
] as const).map(m => (
|
|
<div
|
|
key={m.id}
|
|
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''}`}
|
|
onClick={() => {
|
|
const next = new Set(selectedModels)
|
|
if (next.has(m.id)) {
|
|
next.delete(m.id)
|
|
} else {
|
|
next.add(m.id)
|
|
}
|
|
onModelsChange(next)
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<span className="prd-md" style={{ background: m.color }} />
|
|
{m.id}
|
|
</div>
|
|
))}
|
|
<div
|
|
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''}`}
|
|
onClick={() => {
|
|
if (selectedModels.size === ALL_MODELS.length) {
|
|
onModelsChange(new Set(['KOSPS']))
|
|
} else {
|
|
onModelsChange(new Set(ALL_MODELS))
|
|
}
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
|
앙상블
|
|
</div>
|
|
</div>
|
|
|
|
{/* Run Button */}
|
|
<button
|
|
className="prd-btn pri"
|
|
style={{ padding: '7px', fontSize: '11px', marginTop: '2px' }}
|
|
onClick={onRunSimulation}
|
|
disabled={isRunningSimulation}
|
|
>
|
|
{isRunningSimulation ? '⏳ 실행 중...' : '🔬 확산예측 실행'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Incident Section */}
|
|
<div className="border-b border-border">
|
|
<div
|
|
onClick={() => toggleSection('incident')}
|
|
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">
|
|
{expandedSections.incident ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expandedSections.incident && (
|
|
<div className="px-4 pb-4 space-y-3">
|
|
{/* Status Badge */}
|
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
|
진행중
|
|
</div>
|
|
|
|
{/* Info Grid */}
|
|
<div className="grid gap-1">
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
|
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
|
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Impact Resources Section */}
|
|
<div className="border-b border-border">
|
|
<div
|
|
onClick={() => toggleSection('impactResources')}
|
|
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">
|
|
{expandedSections.impactResources ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expandedSections.impactResources && (
|
|
<div className="px-4 pb-4">
|
|
<p className="text-[11px] text-text-3">영향받는 민감자원 목록</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info Layer Section */}
|
|
<div className="border-b border-border">
|
|
<div
|
|
className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]"
|
|
>
|
|
<h3
|
|
onClick={() => toggleSection('infoLayer')}
|
|
className="text-[13px] font-bold text-text-1 font-korean cursor-pointer"
|
|
>
|
|
📂 정보 레이어
|
|
</h3>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
// Get all layer IDs from layerTree recursively
|
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
|
const ids: string[] = []
|
|
layers?.forEach(layer => {
|
|
ids.push(layer.id)
|
|
if (layer.children) {
|
|
ids.push(...getAllLayerIds(layer.children))
|
|
}
|
|
})
|
|
return ids
|
|
}
|
|
const allIds = getAllLayerIds(effectiveLayers)
|
|
allIds.forEach(id => onToggleLayer(id, true))
|
|
}}
|
|
style={{
|
|
padding: '4px 8px',
|
|
fontSize: '10px',
|
|
fontWeight: 600,
|
|
fontFamily: 'var(--fK)',
|
|
border: '1px solid var(--cyan)',
|
|
borderRadius: 'var(--rS)',
|
|
background: 'transparent',
|
|
color: 'var(--cyan)',
|
|
cursor: 'pointer',
|
|
transition: '0.15s'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'rgba(6,182,212,0.1)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'transparent'
|
|
}}
|
|
>
|
|
전체 켜기
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
// Get all layer IDs from layerTree recursively
|
|
const getAllLayerIds = (layers: Layer[]): string[] => {
|
|
const ids: string[] = []
|
|
layers?.forEach(layer => {
|
|
ids.push(layer.id)
|
|
if (layer.children) {
|
|
ids.push(...getAllLayerIds(layer.children))
|
|
}
|
|
})
|
|
return ids
|
|
}
|
|
const allIds = getAllLayerIds(effectiveLayers)
|
|
allIds.forEach(id => onToggleLayer(id, false))
|
|
}}
|
|
style={{
|
|
padding: '4px 8px',
|
|
fontSize: '10px',
|
|
fontWeight: 600,
|
|
fontFamily: 'var(--fK)',
|
|
border: '1px solid var(--red)',
|
|
borderRadius: 'var(--rS)',
|
|
background: 'transparent',
|
|
color: 'var(--red)',
|
|
cursor: 'pointer',
|
|
transition: '0.15s'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'rgba(239,68,68,0.1)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'transparent'
|
|
}}
|
|
>
|
|
전체 끄기
|
|
</button>
|
|
<span
|
|
onClick={() => toggleSection('infoLayer')}
|
|
className="text-[10px] text-text-3 cursor-pointer"
|
|
>
|
|
{expandedSections.infoLayer ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedSections.infoLayer && (
|
|
<div className="px-4 pb-2">
|
|
{isLoading && effectiveLayers.length === 0 ? (
|
|
<p className="text-[11px] text-text-3 py-2">레이어 로딩 중...</p>
|
|
) : effectiveLayers.length === 0 ? (
|
|
<p className="text-[11px] text-text-3 py-2">레이어 데이터가 없습니다.</p>
|
|
) : (
|
|
<LayerTree
|
|
layers={effectiveLayers}
|
|
enabledLayers={enabledLayers}
|
|
onToggleLayer={onToggleLayer}
|
|
layerColors={layerColors}
|
|
onColorChange={(id, color) => setLayerColors(prev => ({ ...prev, [id]: color }))}
|
|
/>
|
|
)}
|
|
|
|
{/* 레이어 스타일 조절 */}
|
|
<div className="lyr-style-box">
|
|
<div className="lyr-style-label">레이어 스타일</div>
|
|
<div className="lyr-style-row">
|
|
<span className="lyr-style-name">투명도</span>
|
|
<input
|
|
type="range"
|
|
className="lyr-style-slider"
|
|
min={0} max={100} value={layerOpacity}
|
|
onChange={e => onLayerOpacityChange(Number(e.target.value))}
|
|
/>
|
|
<span className="lyr-style-val">{layerOpacity}%</span>
|
|
</div>
|
|
<div className="lyr-style-row">
|
|
<span className="lyr-style-name">밝기</span>
|
|
<input
|
|
type="range"
|
|
className="lyr-style-slider"
|
|
min={0} max={100} value={layerBrightness}
|
|
onChange={e => onLayerBrightnessChange(Number(e.target.value))}
|
|
/>
|
|
<span className="lyr-style-val">{layerBrightness}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Oil Boom Placement Guide Section */}
|
|
<div className="border-b border-border">
|
|
<div
|
|
onClick={() => toggleSection('oilBoom')}
|
|
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">
|
|
{expandedSections.oilBoom ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expandedSections.oilBoom && (
|
|
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
|
|
{/* Tab Buttons + Reset */}
|
|
<div style={{ display: 'flex', gap: '6px' }}>
|
|
{[
|
|
{ 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={{
|
|
flex: 1,
|
|
padding: '6px 8px',
|
|
fontSize: '10px',
|
|
fontWeight: 600,
|
|
fontFamily: 'var(--fK)',
|
|
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)',
|
|
cursor: 'pointer',
|
|
transition: '0.15s'
|
|
}}
|
|
>
|
|
{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',
|
|
fontSize: '10px',
|
|
fontWeight: 600,
|
|
fontFamily: 'var(--fK)',
|
|
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',
|
|
flexShrink: 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)',
|
|
border: '1px solid var(--bd)',
|
|
borderRadius: 'var(--rS)',
|
|
textAlign: 'center'
|
|
}}>
|
|
<div style={{ fontSize: '18px', fontWeight: 700, color: metric.color, fontFamily: 'var(--fM)', marginBottom: '2px' }}>
|
|
{metric.value}
|
|
</div>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
|
{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 style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '8px' }}>
|
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} />
|
|
<span style={{ fontSize: '10px', fontWeight: 700, color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
|
{oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
|
|
</span>
|
|
</div>
|
|
|
|
<h4 style={{ fontSize: '13px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '8px' }}>
|
|
확산 예측 기반 최적 배치안
|
|
</h4>
|
|
|
|
<p style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: '1.5', marginBottom: '10px' }}>
|
|
{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={{
|
|
width: '100%',
|
|
padding: '10px',
|
|
fontSize: '11px',
|
|
fontWeight: 700,
|
|
fontFamily: 'var(--fK)',
|
|
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'
|
|
}}
|
|
>
|
|
🛡 추천 배치안 적용하기
|
|
</button>
|
|
</div>
|
|
|
|
{/* 알고리즘 설정 */}
|
|
<div>
|
|
<h4 style={{ fontSize: '11px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fK)', marginBottom: '8px', letterSpacing: '0.5px' }}>
|
|
📊 배치 알고리즘 설정
|
|
</h4>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{[
|
|
{ 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={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '6px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)'
|
|
}}>
|
|
<span style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>● {setting.label}</span>
|
|
<div style={{ display: 'flex', alignItems: '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 style={{ fontSize: '9px', color: 'var(--orange)', fontFamily: 'var(--fK)' }}>{setting.unit}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ===== 수동 배치 탭 ===== */}
|
|
{boomPlacementTab === 'manual' && (
|
|
<>
|
|
{/* 드로잉 컨트롤 */}
|
|
<div style={{ display: 'flex', gap: '6px' }}>
|
|
{!isDrawingBoom ? (
|
|
<button
|
|
onClick={() => { onDrawingBoomChange(true); onDrawingPointsChange([]) }}
|
|
style={{
|
|
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
background: 'rgba(245,158,11,0.15)', border: '2px solid var(--orange)',
|
|
borderRadius: 'var(--rS)', color: 'var(--orange)', cursor: 'pointer', transition: '0.15s'
|
|
}}
|
|
>
|
|
🛡 배치 시작
|
|
</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={{
|
|
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
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'
|
|
}}
|
|
>
|
|
배치 완료 ({drawingPoints.length}점)
|
|
</button>
|
|
<button
|
|
onClick={() => { onDrawingBoomChange(false); onDrawingPointsChange([]) }}
|
|
style={{
|
|
padding: '10px 14px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
background: 'rgba(239,68,68,0.1)', border: '1px solid var(--red)',
|
|
borderRadius: 'var(--rS)', color: 'var(--red)', cursor: 'pointer', transition: '0.15s'
|
|
}}
|
|
>
|
|
취소
|
|
</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)',
|
|
display: 'flex', gap: '12px', fontSize: '10px', fontFamily: 'var(--fK)', color: 'var(--t2)'
|
|
}}>
|
|
<span>포인트: <strong style={{ color: 'var(--orange)', fontFamily: 'var(--fM)' }}>{drawingPoints.length}</strong></span>
|
|
<span>길이: <strong style={{ color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>{computePolylineLength(drawingPoints).toFixed(0)}m</strong></span>
|
|
{drawingPoints.length >= 2 && (
|
|
<span>방위각: <strong style={{ color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}°</strong></span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 배치된 라인 목록 */}
|
|
{boomLines.length === 0 ? (
|
|
<p style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', padding: '16px 0' }}>
|
|
배치된 오일펜스 라인이 없습니다.
|
|
</p>
|
|
) : (
|
|
boomLines.map((line, idx) => (
|
|
<div key={line.id} style={{
|
|
padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)',
|
|
borderLeft: `3px solid ${line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'}`,
|
|
borderRadius: 'var(--rS)'
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
|
|
<input
|
|
type="text"
|
|
value={line.name}
|
|
onChange={(e) => {
|
|
const updated = [...boomLines]
|
|
updated[idx] = { ...updated[idx], name: e.target.value }
|
|
onBoomLinesChange(updated)
|
|
}}
|
|
style={{
|
|
flex: 1, fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
background: 'transparent', border: 'none', color: 'var(--t1)', outline: 'none'
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => onBoomLinesChange(boomLines.filter(l => l.id !== line.id))}
|
|
style={{
|
|
fontSize: '10px', color: 'var(--red)', background: 'none', border: 'none',
|
|
cursor: 'pointer', padding: '2px 6px'
|
|
}}
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
|
|
<div>
|
|
<span style={{ color: 'var(--t3)' }}>길이</span>
|
|
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.length.toFixed(0)}m</div>
|
|
</div>
|
|
<div>
|
|
<span style={{ color: 'var(--t3)' }}>각도</span>
|
|
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.angle.toFixed(0)}°</div>
|
|
</div>
|
|
<div>
|
|
<span style={{ color: 'var(--t3)' }}>우선순위</span>
|
|
<select
|
|
value={line.priority}
|
|
onChange={(e) => {
|
|
const updated = [...boomLines]
|
|
updated[idx] = { ...updated[idx], priority: e.target.value as BoomLine['priority'] }
|
|
onBoomLinesChange(updated)
|
|
}}
|
|
style={{
|
|
width: '100%', fontSize: '10px', fontWeight: 600, fontFamily: 'var(--fK)',
|
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: '3px',
|
|
color: 'var(--t1)', padding: '2px', outline: 'none'
|
|
}}
|
|
>
|
|
<option value="CRITICAL">긴급</option>
|
|
<option value="HIGH">중요</option>
|
|
<option value="MEDIUM">보통</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ===== 시뮬레이션 탭 ===== */}
|
|
{boomPlacementTab === 'simulation' && (
|
|
<>
|
|
{/* 전제조건 체크 */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
|
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
|
fontSize: '10px', fontFamily: 'var(--fK)'
|
|
}}>
|
|
<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={{
|
|
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
|
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
|
fontSize: '10px', fontFamily: 'var(--fK)'
|
|
}}>
|
|
<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={{
|
|
width: '100%', padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
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'
|
|
}}
|
|
>
|
|
🔬 차단 시뮬레이션 실행
|
|
</button>
|
|
|
|
{/* 시뮬레이션 결과 */}
|
|
{containmentResult && containmentResult.totalParticles > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
{/* 전체 효율 */}
|
|
<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 style={{ fontSize: '28px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
|
|
{containmentResult.overallEfficiency}%
|
|
</div>
|
|
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
|
|
전체 차단 효율
|
|
</div>
|
|
</div>
|
|
|
|
{/* 차단/통과 카운트 */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
|
|
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--green)', fontFamily: 'var(--fM)' }}>
|
|
{containmentResult.blockedParticles}
|
|
</div>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>차단 입자</div>
|
|
</div>
|
|
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--red)', fontFamily: 'var(--fM)' }}>
|
|
{containmentResult.passedParticles}
|
|
</div>
|
|
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>통과 입자</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 style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>
|
|
라인별 차단 분석
|
|
</h4>
|
|
{containmentResult.perLineResults.map((r) => (
|
|
<div key={r.boomLineId} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '6px 8px', marginBottom: '4px',
|
|
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
|
|
fontSize: '9px', fontFamily: 'var(--fK)'
|
|
}}>
|
|
<span style={{ color: 'var(--t2)', flex: 1 }}>{r.boomLineName}</span>
|
|
<span style={{ fontWeight: 700, color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fM)', marginLeft: '8px' }}>
|
|
{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)', border: '1px solid var(--bd)',
|
|
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)'
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
|
🛡 {idx + 1}차 방어선 ({line.type})
|
|
</span>
|
|
<span style={{
|
|
padding: '2px 6px', fontSize: '8px', fontWeight: 700, fontFamily: 'var(--fK)',
|
|
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
|
|
borderRadius: '3px', color: priorityColor
|
|
}}>
|
|
{priorityLabel}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', marginBottom: '6px' }}>
|
|
<div>
|
|
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>길이</span>
|
|
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
|
|
{line.length.toFixed(0)}m
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>각도</span>
|
|
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
|
|
{line.angle.toFixed(0)}°
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
|
|
<span style={{ fontSize: '9px', fontWeight: 600, color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fK)' }}>
|
|
차단 효율 {line.efficiency}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|