import { useState, useRef, useEffect } from 'react'; import { ComboBox } from '@common/components/ui/ComboBox'; import type { PredictionModel } from './OilSpillView'; import { analyzeImage } from '../services/predictionApi'; import type { ImageAnalyzeResult } from '../services/predictionApi'; interface PredictionInputSectionProps { expanded: boolean; onToggle: () => void; accidentTime: string; onAccidentTimeChange: (time: string) => void; incidentCoord: { lon: number; lat: number } | null; onCoordChange: (coord: { lon: number; lat: number }) => void; isSelectingLocation: boolean; onMapSelectClick: () => void; onRunSimulation: () => void; isRunningSimulation: boolean; selectedModels: Set; onModelsChange: (models: Set) => void; visibleModels?: Set; onVisibleModelsChange?: (models: Set) => void; hasResults?: boolean; predictionTime: number; onPredictionTimeChange: (time: number) => void; spillType: string; onSpillTypeChange: (type: string) => void; oilType: string; onOilTypeChange: (type: string) => void; spillAmount: number; onSpillAmountChange: (amount: number) => void; incidentName: string; onIncidentNameChange: (name: string) => void; spillUnit: string; onSpillUnitChange: (unit: string) => void; onImageAnalysisResult?: (result: ImageAnalyzeResult) => void; validationErrors?: Set; } const PredictionInputSection = ({ expanded, onToggle, accidentTime, onAccidentTimeChange, incidentCoord, onCoordChange, isSelectingLocation, onMapSelectClick, onRunSimulation, isRunningSimulation, selectedModels, onModelsChange, onVisibleModelsChange, hasResults, predictionTime, onPredictionTimeChange, spillType, onSpillTypeChange, oilType, onOilTypeChange, spillAmount, onSpillAmountChange, incidentName, onIncidentNameChange, spillUnit, onSpillUnitChange, onImageAnalysisResult, validationErrors, }: PredictionInputSectionProps) => { const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct'); const [uploadedFile, setUploadedFile] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); const [analyzeResult, setAnalyzeResult] = useState(null); const fileInputRef = useRef(null); const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] ?? null; setUploadedFile(file); setAnalyzeError(null); setAnalyzeResult(null); }; const handleRemoveFile = () => { setUploadedFile(null); setAnalyzeError(null); setAnalyzeResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; }; const handleAnalyze = async () => { if (!uploadedFile) return; setIsAnalyzing(true); setAnalyzeError(null); try { const result = await analyzeImage(uploadedFile, incidentName); setAnalyzeResult(result); onImageAnalysisResult?.(result); } catch (err: unknown) { if (err && typeof err === 'object' && 'response' in err) { const res = (err as { response?: { data?: { error?: string } } }).response; if (res?.data?.error === 'GPS_NOT_FOUND') { setAnalyzeError('GPS 정보가 없는 이미지입니다'); return; } if (res?.data?.error === 'TIMEOUT') { setAnalyzeError('분석 서버 응답 없음 (시간 초과)'); return; } } setAnalyzeError('이미지 분석 중 오류가 발생했습니다'); } finally { setIsAnalyzing(false); } }; return (

예측정보 입력

{expanded ? '▼' : '▶'}
{expanded && (
{/* Input Mode Selection */}
{/* 사고명 입력 (직접입력 / 이미지업로드 공통) */} onIncidentNameChange(e.target.value)} style={ validationErrors?.has('incidentName') ? { borderColor: 'var(--color-danger)' } : undefined } /> {/* Image Upload Mode */} {inputMode === 'upload' && ( <> {/* 파일 선택 영역 */} {!uploadedFile ? ( ) : (
📄 {uploadedFile.name}
)} {/* 분석 실행 버튼 */} {/* 에러 메시지 */} {analyzeError && (
⚠ {analyzeError}
)} {/* 분석 완료 메시지 */} {analyzeResult && (
✓ 분석 완료
위도 {analyzeResult.lat.toFixed(4)} / 경도 {analyzeResult.lon.toFixed(4)}
유종: {analyzeResult.oilType} / 면적: {analyzeResult.area.toFixed(1)} m²
)} )} {/* 사고 발생 시각 */}
{/* Coordinates (DMS) + Map Button */}
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })} error={validationErrors?.has('coord')} /> onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })} error={validationErrors?.has('coord')} />
{/* Oil Type + Oil Kind */}
{/* Volume + Unit + Duration */}
onSpillAmountChange(parseFloat(e.target.value) || 0)} /> onPredictionTimeChange(parseInt(v))} options={[ { value: '6', label: '6시간' }, { value: '12', label: '12시간' }, { value: '24', label: '24시간' }, { value: '48', label: '48시간' }, { value: '72', label: '72시간' }, ]} />
{/* Divider */}
{/* Model Selection (다중 선택) */} {/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
{( [ { id: 'KOSPS' as PredictionModel, color: 'var(--color-accent)', ready: false }, { id: 'POSEIDON' as PredictionModel, color: 'var(--color-danger)', ready: true }, { id: 'OpenDrift' as PredictionModel, color: 'var(--color-info)', ready: true }, ] as const ).map((m) => (
{ if (!m.ready) { alert(`${m.id} 모델은 현재 준비중입니다.`); return; } const next = new Set(selectedModels); if (next.has(m.id)) { next.delete(m.id); } else { next.add(m.id); } onModelsChange(next); if (hasResults && onVisibleModelsChange) { onVisibleModelsChange(new Set(next)); } }} > {/* */} {m.id}
))} {/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
{ alert('앙상블 모델은 현재 준비중입니다.') }} > 앙상블
*/}
{/* 모델 미선택 경고 (실행 시 검증) */} {validationErrors?.has('models') && (

⚠ 예측 모델을 하나 이상 선택하세요.

)} {/* Run Button */}
)}
); }; // ── 커스텀 날짜/시간 선택 컴포넌트 ───────────────────── function DateTimeInput({ value, onChange, error, }: { value: string; onChange: (v: string) => void; error?: boolean; }) { const [showCal, setShowCal] = useState(false); const ref = useRef(null); const datePart = value ? value.split('T')[0] : ''; const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'; const timeParts = timePart.split(':').map(Number); const hh = isNaN(timeParts[0]) ? 0 : timeParts[0]; const mm = timeParts[1] === undefined || isNaN(timeParts[1]) ? 0 : timeParts[1]; const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date(); const [viewYear, setViewYear] = useState(parsed.getFullYear()); const [viewMonth, setViewMonth] = useState(parsed.getMonth()); const selY = datePart ? parsed.getFullYear() : -1; const selM = datePart ? parsed.getMonth() : -1; const selD = datePart ? parsed.getDate() : -1; useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const firstDay = new Date(viewYear, viewMonth, 1).getDay(); const days: (number | null)[] = []; for (let i = 0; i < firstDay; i++) days.push(null); for (let i = 1; i <= daysInMonth; i++) days.push(i); const pickDate = (day: number) => { const m = String(viewMonth + 1).padStart(2, '0'); const d = String(day).padStart(2, '0'); onChange(`${viewYear}-${m}-${d}T${timePart}`); setShowCal(false); }; const updateTime = (newHH: number, newMM: number) => { const date = datePart || new Date().toISOString().split('T')[0]; onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`); }; const prevMonth = () => { if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11); } else setViewMonth(viewMonth - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0); } else setViewMonth(viewMonth + 1); }; const displayDate = datePart ? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}` : '날짜 선택'; const today = new Date(); const todayY = today.getFullYear(); const todayM = today.getMonth(); const todayD = today.getDate(); return (
{/* 날짜 버튼 */} {/* 시 */} updateTime(v, mm)} /> : {/* 분 */} updateTime(hh, v)} /> {/* 캘린더 팝업 */} {showCal && (
{/* 헤더 */}
{viewYear}년 {viewMonth + 1}월
{/* 요일 */}
{['일', '월', '화', '수', '목', '금', '토'].map((d) => ( {d} ))}
{/* 날짜 */}
{days.map((day, i) => { if (day === null) return ; const isSelected = viewYear === selY && viewMonth === selM && day === selD; const isToday = viewYear === todayY && viewMonth === todayM && day === todayD; return ( ); })}
{/* 오늘 버튼 */}
)}
); } // ── 커스텀 시간 드롭다운 (다크 테마) ─────────────────── function TimeDropdown({ value, max, onChange, }: { value: number; max: number; onChange: (v: number) => void; }) { const [open, setOpen] = useState(false); const dropRef = useRef(null); const listRef = useRef(null); useEffect(() => { const handler = (e: MouseEvent) => { if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); useEffect(() => { if (open && listRef.current) { const activeEl = listRef.current.querySelector('[data-active="true"]'); if (activeEl) activeEl.scrollIntoView({ block: 'center' }); } }, [open]); return (
{open && (
{Array.from({ length: max }, (_, i) => ( ))}
)}
); } // ── 도분초 좌표 입력 컴포넌트 ────────────────────────── function DmsCoordInput({ label, isLatitude, decimal, onChange, error, }: { label: string; isLatitude: boolean; decimal: number; onChange: (val: number) => void; error?: boolean; }) { const abs = Math.abs(decimal); const d = Math.floor(abs); const mDec = (abs - d) * 60; const m = Math.floor(mDec); const s = parseFloat(((mDec - m) * 60).toFixed(2)); const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : decimal >= 0 ? 'E' : 'W'; const update = (deg: number, min: number, sec: number, direction: string) => { let val = deg + min / 60 + sec / 3600; if (direction === 'S' || direction === 'W') val = -val; onChange(val); }; const fieldStyle = { padding: '5px 2px', fontSize: 'var(--font-size-label-2)', minWidth: 0 }; return (
{label}
update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} /> ° update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} /> ' update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} /> "
); } export default PredictionInputSection;