wing-ops/frontend/src/tabs/prediction/components/LeftPanel.tsx
htlee f099ff29b1 refactor(frontend): 탭 단위 패키지 구조 전환 (tabs/)
- 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>
2026-02-28 14:08:34 +09:00

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>
)
}