375 lines
15 KiB
TypeScript
Executable File
375 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,
|
|
layerColors,
|
|
onLayerColorChange,
|
|
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-full min-w-0 h-full bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
|
{/* 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-title-4 font-bold text-fg-sub font-korean">사고정보</h3>
|
|
<span className="text-label-2 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-caption font-medium ${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-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
사고코드
|
|
</span>
|
|
<span className="text-label-2 text-fg font-medium font-mono">
|
|
{selectedAnalysis.acdntSn}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
사고명
|
|
</span>
|
|
<span className="text-label-2 text-fg font-medium font-korean">
|
|
{selectedAnalysis.acdntNm || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
사고일시
|
|
</span>
|
|
<span className="text-label-2 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-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
유종
|
|
</span>
|
|
<span className="text-label-2 text-fg font-medium font-korean">
|
|
{selectedAnalysis.oilType || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
유출량
|
|
</span>
|
|
<span className="text-label-2 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-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
담당자
|
|
</span>
|
|
<span className="text-label-2 text-fg font-medium font-korean">
|
|
{selectedAnalysis.analyst || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-label-2 text-fg-disabled min-w-[52px] font-korean">
|
|
위치
|
|
</span>
|
|
<span className="text-label-2 text-color-warning font-medium font-korean">
|
|
{selectedAnalysis.location || '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="px-4 pb-4">
|
|
<p className="text-label-1 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-title-4 font-bold text-fg-sub font-korean">영향 민감자원</h3>
|
|
<span className="text-label-2 text-fg-disabled">
|
|
{expandedSections.impactResources ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expandedSections.impactResources && (
|
|
<div className="px-4 pb-4">
|
|
{sensitiveResources.length === 0 ? (
|
|
<p className="text-label-1 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-label-2 shrink-0"
|
|
style={{ background: meta.bg }}
|
|
>
|
|
{meta.icon}
|
|
</span>
|
|
<span className="text-label-2 text-fg font-korean">{category}</span>
|
|
</div>
|
|
<span className="text-label-2 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}
|
|
layerColors={layerColors}
|
|
onLayerColorChange={onLayerColorChange}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|