wing-ops/frontend/src/tabs/prediction/components/LeftPanel.tsx

332 lines
15 KiB
TypeScript
Executable File

import { useState } from 'react'
import type { LeftPanelProps, ExpandedSections } from './leftPanelTypes'
interface CategoryMeta {
icon: string;
bg: string;
}
const CATEGORY_ICON_MAP: Record<string, CategoryMeta> = {
// 수산자원 / 양식장 (green)
'어장정보': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'양식어업': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'어류양식장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'패류양식장': { icon: '🦪', bg: 'rgba(34,197,94,0.15)' },
'해조류양식장': { icon: '🌿', bg: 'rgba(34,197,94,0.15)' },
'가두리양식장': { icon: '🔲', bg: 'rgba(34,197,94,0.15)' },
'갑각류양식장': { icon: '🦐', bg: 'rgba(34,197,94,0.15)' },
'기타양식장': { icon: '📦', bg: 'rgba(34,197,94,0.15)' },
'영세어업': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
'유어장': { icon: '🎣', bg: 'rgba(34,197,94,0.15)' },
'수산시장': { icon: '🐟', bg: 'rgba(34,197,94,0.15)' },
'인공어초': { icon: '🪸', bg: 'rgba(34,197,94,0.15)' },
'암초': { icon: '🪨', bg: 'rgba(34,197,94,0.15)' },
'침선': { icon: '🚢', bg: 'rgba(34,197,94,0.15)' },
// 관광자원 / 낚시 (yellow)
'해수욕장': { icon: '🏖', bg: 'rgba(250,204,21,0.15)' },
'갯바위낚시': { icon: '🪨', bg: 'rgba(250,204,21,0.15)' },
'선상낚시': { icon: '🚤', bg: 'rgba(250,204,21,0.15)' },
'마리나항': { icon: '⛵', bg: 'rgba(250,204,21,0.15)' },
// 항만 / 산업시설 (blue)
'무역항': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
'연안항': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
'국가어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'지방어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'어항': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'항만구역': { icon: '⚓', bg: 'rgba(99,179,237,0.15)' },
'항로': { icon: '🚢', bg: 'rgba(99,179,237,0.15)' },
'정박지': { icon: '⛵', bg: 'rgba(99,179,237,0.15)' },
'항로표지': { icon: '🔴', bg: 'rgba(99,179,237,0.15)' },
'해수취수시설': { icon: '💧', bg: 'rgba(99,179,237,0.15)' },
'취수구·배수구': { icon: '🚰', bg: 'rgba(99,179,237,0.15)' },
'LNG': { icon: '⚡', bg: 'rgba(99,179,237,0.15)' },
'발전소': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
'발전소·산단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
'임해공단': { icon: '🏭', bg: 'rgba(99,179,237,0.15)' },
'저유시설': { icon: '🛢', bg: 'rgba(99,179,237,0.15)' },
'해저케이블·배관': { icon: '🔌', bg: 'rgba(99,179,237,0.15)' },
// 환경 / 생태 (lime)
'갯벌': { icon: '🪨', bg: 'rgba(163,230,53,0.12)' },
'해안선_ESI': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
'보호지역': { icon: '🛡', bg: 'rgba(163,230,53,0.12)' },
'해양보호구역': { icon: '🌿', bg: 'rgba(163,230,53,0.12)' },
'철새도래지': { icon: '🐦', bg: 'rgba(163,230,53,0.12)' },
'습지보호구역': { icon: '🏖', bg: 'rgba(163,230,53,0.12)' },
'보호종서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
'보호종 서식지': { icon: '🐢', bg: 'rgba(163,230,53,0.12)' },
};
const FALLBACK_META: CategoryMeta = { icon: '🌊', bg: 'rgba(148,163,184,0.15)' };
import PredictionInputSection from './PredictionInputSection'
import InfoLayerSection from './InfoLayerSection'
import OilBoomSection from './OilBoomSection'
export type { LeftPanelProps }
export function LeftPanel({
selectedAnalysis,
enabledLayers,
onToggleLayer,
accidentTime,
onAccidentTimeChange,
incidentCoord,
onCoordChange,
isSelectingLocation,
onMapSelectClick,
onRunSimulation,
isRunningSimulation,
selectedModels,
onModelsChange,
visibleModels,
onVisibleModelsChange,
hasResults,
predictionTime,
onPredictionTimeChange,
spillType,
onSpillTypeChange,
oilType,
onOilTypeChange,
spillAmount,
onSpillAmountChange,
incidentName,
onIncidentNameChange,
spillUnit,
onSpillUnitChange,
boomLines,
onBoomLinesChange,
oilTrajectory,
algorithmSettings,
onAlgorithmSettingsChange,
isDrawingBoom,
onDrawingBoomChange,
drawingPoints,
onDrawingPointsChange,
containmentResult,
onContainmentResultChange,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
sensitiveResources = [],
onImageAnalysisResult,
validationErrors,
}: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
predictionInput: true,
incident: false,
impactResources: false,
infoLayer: false,
oilBoom: false,
})
const toggleSection = (section: keyof ExpandedSections) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}))
}
return (
<div className="w-80 min-w-[320px] bg-bg-surface border-r border-stroke flex flex-col">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent" style={{ scrollbarGutter: 'stable' }}>
{/* Prediction Input Section */}
<PredictionInputSection
expanded={expandedSections.predictionInput}
onToggle={() => toggleSection('predictionInput')}
accidentTime={accidentTime}
onAccidentTimeChange={onAccidentTimeChange}
incidentCoord={incidentCoord}
onCoordChange={onCoordChange}
isSelectingLocation={isSelectingLocation}
onMapSelectClick={onMapSelectClick}
onRunSimulation={onRunSimulation}
isRunningSimulation={isRunningSimulation}
selectedModels={selectedModels}
onModelsChange={onModelsChange}
visibleModels={visibleModels}
onVisibleModelsChange={onVisibleModelsChange}
hasResults={hasResults}
predictionTime={predictionTime}
onPredictionTimeChange={onPredictionTimeChange}
spillType={spillType}
onSpillTypeChange={onSpillTypeChange}
oilType={oilType}
onOilTypeChange={onOilTypeChange}
spillAmount={spillAmount}
onSpillAmountChange={onSpillAmountChange}
incidentName={incidentName}
onIncidentNameChange={onIncidentNameChange}
spillUnit={spillUnit}
onSpillUnitChange={onSpillUnitChange}
onImageAnalysisResult={onImageAnalysisResult}
validationErrors={validationErrors}
/>
{/* Incident Section */}
<div className="border-b border-stroke">
<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-fg-sub font-korean">
</h3>
<span className="text-[10px] text-fg-disabled">
{expandedSections.incident ? '▼' : '▶'}
</span>
</div>
{expandedSections.incident && (
selectedAnalysis ? (
<div className="px-4 pb-4 space-y-3">
{/* Status Badge */}
{(() => {
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
ACTIVE: {
label: '진행중',
style: 'bg-[rgba(239,68,68,0.15)] text-color-danger border border-[rgba(239,68,68,0.3)]',
dot: 'bg-color-danger animate-pulse',
},
INVESTIGATING: {
label: '조사중',
style: 'bg-[rgba(249,115,22,0.15)] text-color-warning border border-[rgba(249,115,22,0.3)]',
dot: 'bg-color-warning animate-pulse',
},
CLOSED: {
label: '종료',
style: 'bg-[rgba(100,116,139,0.15)] text-fg-disabled border border-[rgba(100,116,139,0.3)]',
dot: 'bg-fg-disabled',
},
}
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
return (
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
{s.label}
</div>
)
})()}
{/* Info Grid */}
<div className="grid gap-1">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.acdntSn}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16).replace(' ', 'T') : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-fg font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-fg-disabled min-w-[52px] font-korean"></span>
<span className="text-[11px] text-color-warning font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
<p className="text-[12px] text-fg-disabled font-korean text-center py-2"> .</p>
</div>
)
)}
</div>
{/* Impact Resources Section */}
<div className="border-b border-stroke">
<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-fg-sub font-korean">
</h3>
<span className="text-[10px] text-fg-disabled">
{expandedSections.impactResources ? '▼' : '▶'}
</span>
</div>
{expandedSections.impactResources && (
<div className="px-4 pb-4">
{sensitiveResources.length === 0 ? (
<p className="text-[12px] text-fg-disabled text-center font-korean"> </p>
) : (
<div className="space-y-1.5">
{sensitiveResources.map(({ category, count, totalArea }) => {
const meta = CATEGORY_ICON_MAP[category] ?? FALLBACK_META;
return (
<div key={category} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="inline-flex items-center justify-center w-5 h-5 rounded text-[11px] shrink-0"
style={{ background: meta.bg }}
>
{meta.icon}
</span>
<span className="text-[11px] text-fg-sub font-korean">{category}</span>
</div>
<span className="text-[11px] text-primary font-bold font-mono">
{totalArea != null
? `${totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 2 })} ha`
: `${count}`}
</span>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{/* Info Layer Section */}
<InfoLayerSection
expanded={expandedSections.infoLayer}
onToggle={() => toggleSection('infoLayer')}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerOpacity={layerOpacity}
onLayerOpacityChange={onLayerOpacityChange}
layerBrightness={layerBrightness}
onLayerBrightnessChange={onLayerBrightnessChange}
/>
{/* Oil Boom Placement Guide Section */}
<OilBoomSection
expanded={expandedSections.oilBoom}
onToggle={() => toggleSection('oilBoom')}
boomLines={boomLines}
onBoomLinesChange={onBoomLinesChange}
oilTrajectory={oilTrajectory}
incidentCoord={incidentCoord ?? { lat: 0, lon: 0 }}
algorithmSettings={algorithmSettings}
onAlgorithmSettingsChange={onAlgorithmSettingsChange}
isDrawingBoom={isDrawingBoom}
onDrawingBoomChange={onDrawingBoomChange}
drawingPoints={drawingPoints}
onDrawingPointsChange={onDrawingPointsChange}
containmentResult={containmentResult}
onContainmentResultChange={onContainmentResultChange}
/>
</div>
</div>
)
}