wing-ops/frontend/src/tabs/prediction/components/PredictionInputSection.tsx
jeonghyo.k 3946ff6a25 feat(prediction): 이미지 분석 서버 Docker 패키징 + DB 코드 제거
- prediction/image/ FastAPI 서버 Docker 환경 구성
  - Dockerfile: PyTorch 2.1 + CUDA 12.1 기반 GPU 이미지
  - docker-compose.yml: GPU 할당 + 데이터 볼륨 마운트
  - requirements.txt: 서버 의존성 목록
  - .env.example: 환경변수 템플릿
  - DOCKER_USAGE.md: 빌드/실행/API 사용법 문서
  - Dockerfile에 .dockerignore 제외 폴더 mkdir -p 추가
- .gitignore: prediction/image 결과물 및 모델 가중치(.pth) 제외 추가
- dbInsert_csv.py, dbInsert_shp.py 삭제 (미사용 DB 로직)
- api.py: dbInsert import 및 주석 처리된 DB 호출 코드 제거
- aerialRouter.ts: req.params 타입 오류 수정
2026-03-10 18:37:36 +09:00

436 lines
16 KiB
TypeScript

import { useState, useRef } 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'
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<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
incidentName: string
onIncidentNameChange: (name: string) => void
spillUnit: string
onSpillUnitChange: (unit: string) => void
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
}
const PredictionInputSection = ({
expanded,
onToggle,
accidentTime,
onAccidentTimeChange,
incidentCoord,
onCoordChange,
isSelectingLocation,
onMapSelectClick,
onRunSimulation,
isRunningSimulation,
selectedModels,
onModelsChange,
predictionTime,
onPredictionTimeChange,
spillType,
onSpillTypeChange,
oilType,
onOilTypeChange,
spillAmount,
onSpillAmountChange,
incidentName,
onIncidentNameChange,
spillUnit,
onSpillUnitChange,
onImageAnalysisResult,
}: PredictionInputSectionProps) => {
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analyzeError, setAnalyzeError] = useState<string | null>(null)
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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)
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 (
<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="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
/>
</label>
<label className="flex items-center gap-[3px] cursor-pointer">
<input
type="radio"
name="prdType"
checked={inputMode === 'upload'}
onChange={() => setInputMode('upload')}
className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
/>
</label>
</div>
{/* Direct Input Mode */}
{inputMode === 'direct' && (
<>
<input
className="prd-i"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
</>
)}
{/* Image Upload Mode */}
{inputMode === 'upload' && (
<>
{/* 파일 선택 영역 */}
{!uploadedFile ? (
<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
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
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">📄 {uploadedFile.name}</span>
<button
onClick={handleRemoveFile}
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>
)}
{/* 분석 실행 버튼 */}
<button
className="prd-btn pri"
style={{ padding: '7px', fontSize: '11px' }}
onClick={handleAnalyze}
disabled={!uploadedFile || isAnalyzing}
>
{isAnalyzing ? '⏳ 분석 중...' : '🔍 이미지 분석 실행'}
</button>
{/* 에러 메시지 */}
{analyzeError && (
<div
className="text-[10px] font-semibold"
style={{
padding: '6px 8px',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--rS)',
color: 'var(--red)',
}}
>
{analyzeError}
</div>
)}
{/* 분석 완료 메시지 */}
{analyzeResult && (
<div
className="text-[10px] font-semibold"
style={{
padding: '6px 8px',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.3)',
borderRadius: 'var(--rS)',
color: '#22c55e',
lineHeight: 1.6,
}}
>
<br />
<span className="font-normal text-text-3">
{analyzeResult.lat.toFixed(4)} / {analyzeResult.lon.toFixed(4)}<br />
: {analyzeResult.oilType} / : {analyzeResult.area.toFixed(1)} m²
</span>
</div>
)}
</>
)}
{/* 사고 발생 시각 */}
<div className="flex flex-col gap-0.5">
<label className="text-[9px] text-text-3 font-korean"> (KST)</label>
<input
className="prd-i"
type="datetime-local"
value={accidentTime}
onChange={(e) => onAccidentTimeChange(e.target.value)}
style={{ colorScheme: 'dark' }}
/>
</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({ lon: incidentCoord?.lon ?? 0, 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({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value })
}}
placeholder="경도°"
/>
<button
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
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={spillUnit}
onChange={onSpillUnitChange}
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>
{/* 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