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 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 onModelsChange: (models: Set) => 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(null) const [uploadedFileName, setUploadedFileName] = useState('') 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>({}) // 정적 데이터를 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) => { 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 (
{/* Scrollable Content */}
{/* Prediction Input Section */}
toggleSection('predictionInput')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" >

예측정보 입력

{expandedSections.predictionInput ? '▼' : '▶'}
{expandedSections.predictionInput && (
{/* Input Mode Selection */}
{/* Direct Input Mode */} {inputMode === 'direct' && ( <> )} {/* Image Upload Mode */} {inputMode === 'upload' && ( <> {}} options={[ { value: '', label: '여수 유조선 충돌 (INC-0042)' }, { value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' } ]} placeholder="사고 선택" /> {/* Upload Success Message */} {uploadedImage && (
내 이미지가 업로드됨
)} {/* File Upload Area */} {!uploadedImage ? ( ) : (
📄 {uploadedFileName || 'example_plot_0.gif'}
)} {/* Dropdowns */}
{}} options={[ { value: '', label: '유출회사' }, { value: 'company1', label: '회사A' }, { value: 'company2', label: '회사B' } ]} placeholder="유출회사" /> {}} options={[ { value: '', label: '예상시각' }, { value: '09:00', label: '09:00' }, { value: '12:00', label: '12:00' } ]} placeholder="예상시각" />
)} {/* Coordinates + Map Button */}
{ const value = e.target.value === '' ? 0 : parseFloat(e.target.value) onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value }) }} placeholder="위도°" /> { const value = e.target.value === '' ? 0 : parseFloat(e.target.value) onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value }) }} placeholder="경도°" />
{/* 도분초 표시 */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
)}
{/* Oil Type + Oil Kind */}
{/* Volume + Unit + Duration */}
onSpillAmountChange(parseInt(e.target.value) || 0)} /> {}} options={[ { value: 'kL', label: 'kL' }, { value: 'ton', label: 'Ton' }, { value: 'barrel', label: '배럴' } ]} /> onPredictionTimeChange(parseInt(v))} options={[ { value: '6', label: '6시간' }, { value: '12', label: '12시간' }, { value: '24', label: '24시간' }, { value: '48', label: '48시간' }, { value: '72', label: '72시간' } ]} />
{/* Image Analysis Note (Upload Mode Only) */} {inputMode === 'upload' && uploadedImage && (
📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요.
)} {/* Divider */}
{/* Model Selection (다중 선택) */}
{([ { 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 => (
{ const next = new Set(selectedModels) if (next.has(m.id)) { next.delete(m.id) } else { next.add(m.id) } onModelsChange(next) }} style={{ cursor: 'pointer' }} > {m.id}
))}
{ if (selectedModels.size === ALL_MODELS.length) { onModelsChange(new Set(['KOSPS'])) } else { onModelsChange(new Set(ALL_MODELS)) } }} style={{ cursor: 'pointer' }} > 앙상블
{/* Run Button */}
)}
{/* Incident Section */}
toggleSection('incident')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" >

사고정보

{expandedSections.incident ? '▼' : '▶'}
{expandedSections.incident && (
{/* Status Badge */}
진행중
{/* Info Grid */}
사고코드 {selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}
사고명 {selectedAnalysis?.name || '씨프린스호'}
사고일시 {selectedAnalysis?.occurredAt || '2025-02-10 06:30'}
유종 {selectedAnalysis?.oilType || 'BUNKER_C'}
유출량 {selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}
담당자 {selectedAnalysis?.analyst || '남해청, 방재과'}
위치 {selectedAnalysis?.location || '여수 돌산 남방 5NM'}
)}
{/* Impact Resources Section */}
toggleSection('impactResources')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" >

영향 민감자원

{expandedSections.impactResources ? '▼' : '▶'}
{expandedSections.impactResources && (

영향받는 민감자원 목록

)}
{/* Info Layer Section */}

toggleSection('infoLayer')} className="text-[13px] font-bold text-text-1 font-korean cursor-pointer" > 📂 정보 레이어

toggleSection('infoLayer')} className="text-[10px] text-text-3 cursor-pointer" > {expandedSections.infoLayer ? '▼' : '▶'}
{expandedSections.infoLayer && (
{isLoading && effectiveLayers.length === 0 ? (

레이어 로딩 중...

) : effectiveLayers.length === 0 ? (

레이어 데이터가 없습니다.

) : ( setLayerColors(prev => ({ ...prev, [id]: color }))} /> )} {/* 레이어 스타일 조절 */}
레이어 스타일
투명도 onLayerOpacityChange(Number(e.target.value))} /> {layerOpacity}%
밝기 onLayerBrightnessChange(Number(e.target.value))} /> {layerBrightness}%
)}
{/* Oil Boom Placement Guide Section */}
toggleSection('oilBoom')} className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]" >

🛡 오일펜스 배치 가이드

{expandedSections.oilBoom ? '▼' : '▶'}
{expandedSections.oilBoom && (
{/* Tab Buttons + Reset */}
{[ { id: 'ai' as const, label: 'AI 자동 추천' }, { id: 'manual' as const, label: '수동 배치' }, { id: 'simulation' as const, label: '시뮬레이션' } ].map(tab => ( ))}
{/* Key Metrics (동적) */}
{[ { 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) => (
{metric.value}
{metric.label}
))}
{/* ===== AI 자동 추천 탭 ===== */} {boomPlacementTab === 'ai' && ( <>
0 ? 'var(--green)' : 'var(--t3)' }} /> 0 ? 'var(--green)' : 'var(--t3)', fontFamily: 'var(--fK)' }}> {oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}

확산 예측 기반 최적 배치안

{oilTrajectory.length > 0 ? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.' : '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.' }

{/* 알고리즘 설정 */}

📊 배치 알고리즘 설정

{[ { 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) => (
● {setting.label}
{ const val = parseFloat(e.target.value) || 0 onAlgorithmSettingsChange({ ...algorithmSettings, [setting.key]: val }) }} className="boom-setting-input" step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1} /> {setting.unit}
))}
)} {/* ===== 수동 배치 탭 ===== */} {boomPlacementTab === 'manual' && ( <> {/* 드로잉 컨트롤 */}
{!isDrawingBoom ? ( ) : ( <> )}
{/* 드로잉 실시간 정보 */} {isDrawingBoom && drawingPoints.length > 0 && (
포인트: {drawingPoints.length} 길이: {computePolylineLength(drawingPoints).toFixed(0)}m {drawingPoints.length >= 2 && ( 방위각: {computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}° )}
)} {/* 배치된 라인 목록 */} {boomLines.length === 0 ? (

배치된 오일펜스 라인이 없습니다.

) : ( boomLines.map((line, idx) => (
{ 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' }} />
길이
{line.length.toFixed(0)}m
각도
{line.angle.toFixed(0)}°
우선순위
)) )} )} {/* ===== 시뮬레이션 탭 ===== */} {boomPlacementTab === 'simulation' && ( <> {/* 전제조건 체크 */}
0 ? 'var(--green)' : 'var(--red)' }} /> 0 ? 'var(--green)' : 'var(--t3)' }}> 확산 궤적 데이터 {oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
0 ? 'var(--green)' : 'var(--red)' }} /> 0 ? 'var(--green)' : 'var(--t3)' }}> 오일펜스 라인 {boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'}
{/* 실행 버튼 */} {/* 시뮬레이션 결과 */} {containmentResult && containmentResult.totalParticles > 0 && (
{/* 전체 효율 */}
{containmentResult.overallEfficiency}%
전체 차단 효율
{/* 차단/통과 카운트 */}
{containmentResult.blockedParticles}
차단 입자
{containmentResult.passedParticles}
통과 입자
{/* 효율 바 */}
= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)' }} />
{/* 라인별 분석 */}

라인별 차단 분석

{containmentResult.perLineResults.map((r) => (
{r.boomLineName} = 50 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fM)', marginLeft: '8px' }}> {r.blocked}차단 / {r.efficiency}%
))}
)} )} {/* 배치된 방어선 카드 (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 (
🛡 {idx + 1}차 방어선 ({line.type}) {priorityLabel}
길이
{line.length.toFixed(0)}m
각도
{line.angle.toFixed(0)}°
= 80 ? 'var(--green)' : 'var(--orange)' }} /> = 80 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fK)' }}> 차단 효율 {line.efficiency}%
) })} )}
)}
) }