wing-ops/frontend/src/tabs/prediction/components/PredictionInputSection.tsx
htlee b00bb56af3 refactor(css): Phase 3 인라인 스타일 → Tailwind 대규모 변환 (486건)
대형 파일 집중 변환:
- 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>
2026-03-01 12:06:15 +09:00

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