대형 파일 집중 변환: - SatelliteRequest: 134→66 (hex 색상 일괄 변환) - IncidentsView: 141→90, MediaModal: 97→38 - HNSScenarioView: 78→38, HNSView: 49→31 - LoginPage, MapView, PredictionInputSection 등 중소 파일 8개 변환 패턴: hex 색상→text-[#hex], CSS 변수→Tailwind 유틸리티, flex/grid/padding/fontSize/fontWeight/overflow 등 정적 속성 className 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
import { useState } from 'react'
|
|
import { decimalToDMS } from '@common/utils/coordinates'
|
|
import { ComboBox } from '@common/components/ui/ComboBox'
|
|
import { ALL_MODELS } from './OilSpillView'
|
|
import type { PredictionModel } from './OilSpillView'
|
|
|
|
interface PredictionInputSectionProps {
|
|
expanded: boolean
|
|
onToggle: () => 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
|
|
}
|
|
|
|
const PredictionInputSection = ({
|
|
expanded,
|
|
onToggle,
|
|
incidentCoord,
|
|
onCoordChange,
|
|
onMapSelectClick,
|
|
onRunSimulation,
|
|
isRunningSimulation,
|
|
selectedModels,
|
|
onModelsChange,
|
|
predictionTime,
|
|
onPredictionTimeChange,
|
|
spillType,
|
|
onSpillTypeChange,
|
|
oilType,
|
|
onOilTypeChange,
|
|
spillAmount,
|
|
onSpillAmountChange,
|
|
}: PredictionInputSectionProps) => {
|
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
|
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
|
const [uploadedFileName, setUploadedFileName] = useState<string>('')
|
|
|
|
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="border-b border-border">
|
|
<div
|
|
onClick={onToggle}
|
|
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">
|
|
{expanded ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="px-4 pb-4 flex flex-col gap-[6px]">
|
|
{/* Input Mode Selection */}
|
|
<div className="flex items-center gap-[10px] text-[11px]">
|
|
<label className="flex items-center gap-[3px] cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'direct'}
|
|
onChange={() => setInputMode('direct')}
|
|
className="m-0 w-[11px] h-[11px]"
|
|
className="accent-[var(--cyan)]"
|
|
/>
|
|
직접 입력
|
|
</label>
|
|
<label className="flex items-center gap-[3px] cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'upload'}
|
|
onChange={() => setInputMode('upload')}
|
|
className="m-0 w-[11px] h-[11px]"
|
|
className="accent-[var(--cyan)]"
|
|
/>
|
|
이미지 업로드
|
|
</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 className="flex items-center gap-[6px] text-[10px] font-semibold text-[#22c55e] rounded"
|
|
style={{
|
|
padding: '6px 8px',
|
|
background: 'rgba(34,197,94,0.1)',
|
|
border: '1px solid rgba(34,197,94,0.3)',
|
|
borderRadius: 'var(--rS)',
|
|
}}>
|
|
<span className="text-[12px]">✓</span>
|
|
내 이미지가 업로드됨
|
|
</div>
|
|
)}
|
|
|
|
{/* File Upload Area */}
|
|
{!uploadedImage ? (
|
|
<label className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
|
|
style={{
|
|
padding: '20px',
|
|
background: 'var(--bg0)',
|
|
border: '2px dashed var(--bd)',
|
|
borderRadius: 'var(--rS)',
|
|
transition: '0.15s',
|
|
}}
|
|
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}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
) : (
|
|
<div className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
|
|
style={{
|
|
padding: '8px 10px',
|
|
borderRadius: 'var(--rS)',
|
|
}}>
|
|
<span className="text-text-2">📄 {uploadedFileName || 'example_plot_0.gif'}</span>
|
|
<button
|
|
onClick={removeUploadedImage}
|
|
className="text-[10px] text-text-3 bg-transparent border-none cursor-pointer"
|
|
style={{ padding: '2px 6px', transition: '0.15s' }}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = 'var(--red)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = 'var(--t3)'
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dropdowns */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<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 className="flex flex-col gap-1">
|
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
|
<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 className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
|
style={{
|
|
padding: '4px 8px',
|
|
borderRadius: 'var(--rS)',
|
|
}}>
|
|
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Oil Type + Oil Kind */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<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 className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
|
<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 className="text-[9px] text-text-3 leading-[1.4]"
|
|
style={{
|
|
padding: '8px',
|
|
background: 'rgba(59,130,246,0.08)',
|
|
border: '1px solid rgba(59,130,246,0.2)',
|
|
borderRadius: 'var(--rS)',
|
|
}}>
|
|
📊 이미지 내 확산경로를 분석하였습니다. 각 방제요소 가이드 참고하세요.
|
|
</div>
|
|
)}
|
|
|
|
{/* Divider */}
|
|
<div className="h-px bg-border my-0.5" />
|
|
|
|
{/* Model Selection (다중 선택) */}
|
|
<div className="flex flex-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' : ''} cursor-pointer`}
|
|
onClick={() => {
|
|
const next = new Set(selectedModels)
|
|
if (next.has(m.id)) {
|
|
next.delete(m.id)
|
|
} else {
|
|
next.add(m.id)
|
|
}
|
|
onModelsChange(next)
|
|
}}
|
|
>
|
|
<span className="prd-md" style={{ background: m.color }} />
|
|
{m.id}
|
|
</div>
|
|
))}
|
|
<div
|
|
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
|
onClick={() => {
|
|
if (selectedModels.size === ALL_MODELS.length) {
|
|
onModelsChange(new Set(['KOSPS']))
|
|
} else {
|
|
onModelsChange(new Set(ALL_MODELS))
|
|
}
|
|
}}
|
|
>
|
|
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
|
앙상블
|
|
</div>
|
|
</div>
|
|
|
|
{/* Run Button */}
|
|
<button
|
|
className="prd-btn pri mt-0.5"
|
|
style={{ padding: '7px', fontSize: '11px' }}
|
|
onClick={onRunSimulation}
|
|
disabled={isRunningSimulation}
|
|
>
|
|
{isRunningSimulation ? '⏳ 실행 중...' : '🔬 확산예측 실행'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PredictionInputSection
|