wing-ops/frontend/src/tabs/prediction/components/PredictionInputSection.tsx

851 lines
29 KiB
TypeScript

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<PredictionModel>;
onModelsChange: (models: Set<PredictionModel>) => void;
visibleModels?: Set<PredictionModel>;
onVisibleModelsChange?: (models: Set<PredictionModel>) => 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<string>;
}
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<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, 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 (
<div className="border-b border-stroke">
<div
onClick={onToggle}
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-default font-korean"> </h3>
<span className="text-label-2 text-fg-default">{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-label-2">
<label className="flex items-center gap-[3px] cursor-pointer">
<input
type="radio"
name="prdType"
checked={inputMode === 'direct'}
onChange={() => setInputMode('direct')}
className="accent-[var(--color-accent)] 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(--color-accent)] m-0 w-[11px] h-[11px]"
/>
</label>
</div>
{/* 사고명 입력 (직접입력 / 이미지업로드 공통) */}
<input
className="prd-i"
placeholder="사고명 직접 입력"
value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)}
style={
validationErrors?.has('incidentName')
? { borderColor: 'var(--color-danger)' }
: undefined
}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
{/* Image Upload Mode */}
{inputMode === 'upload' && (
<>
{/* 파일 선택 영역 */}
{!uploadedFile ? (
<label
className="flex items-center justify-center text-label-2 text-fg-default cursor-pointer"
style={{
padding: '20px',
background: 'var(--bg-base)',
border: '2px dashed var(--stroke-default)',
borderRadius: 'var(--radius-sm)',
transition: '0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent)';
e.currentTarget.style.background = 'rgba(6,182,212,0.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--stroke-default)';
e.currentTarget.style.background = 'var(--bg-base)';
}}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
</label>
) : (
<div
className="flex items-center justify-between font-mono text-label-2 bg-bg-base border border-stroke"
style={{ padding: '8px 10px', borderRadius: 'var(--radius-sm)' }}
>
<span className="text-fg-sub">📄 {uploadedFile.name}</span>
<button
onClick={handleRemoveFile}
className="text-label-2 text-fg-default bg-transparent border-none cursor-pointer"
style={{ padding: '2px 6px', transition: '0.15s' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--color-danger)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--fg-disabled)';
}}
>
</button>
</div>
)}
{/* 분석 실행 버튼 */}
<button
className="prd-btn pri"
style={{ padding: '7px', fontSize: 'var(--font-size-label-2)' }}
onClick={handleAnalyze}
disabled={!uploadedFile || isAnalyzing}
>
{isAnalyzing ? '⏳ 분석 중...' : '이미지 분석 실행'}
</button>
{/* 에러 메시지 */}
{analyzeError && (
<div
className="text-label-2 font-medium"
style={{
padding: '6px 8px',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-danger)',
}}
>
{analyzeError}
</div>
)}
{/* 분석 완료 메시지 */}
{analyzeResult && (
<div
className="text-label-2 font-medium"
style={{
padding: '6px 8px',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.3)',
borderRadius: 'var(--radius-sm)',
color: '#22c55e',
lineHeight: 1.6,
}}
>
<br />
<span className="font-normal text-fg-default">
{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-label-2 text-fg-default font-korean"> (KST)</label>
<DateTimeInput
value={accidentTime}
onChange={onAccidentTimeChange}
error={validationErrors?.has('accidentTime')}
/>
</div>
{/* Coordinates (DMS) + Map Button */}
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[1fr_auto] gap-x-1 gap-y-1">
<DmsCoordInput
label="위도"
isLatitude={true}
decimal={incidentCoord?.lat ?? 0}
onChange={(val) => onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })}
error={validationErrors?.has('coord')}
/>
<button
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
onClick={onMapSelectClick}
style={{
gridRow: '1 / 3',
gridColumn: 2,
whiteSpace: 'nowrap',
alignSelf: 'stretch',
minWidth: 48,
padding: '0 10px',
}}
>
<br />
</button>
<DmsCoordInput
label="경도"
isLatitude={false}
decimal={incidentCoord?.lon ?? 0}
onChange={(val) => onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })}
error={validationErrors?.has('coord')}
/>
</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="0"
step="any"
value={spillAmount}
onChange={(e) => onSpillAmountChange(parseFloat(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 (다중 선택) */}
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
<div className="grid grid-cols-3 gap-[3px]">
{(
[
{ 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) => (
<div
key={m.id}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer text-center`}
onClick={() => {
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));
}
}}
>
{/* <span className="prd-md" style={{ background: m.color }} /> */}
{m.id}
</div>
))}
{/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
onClick={() => {
alert('앙상블 모델은 현재 준비중입니다.')
}}
>
<span className="prd-md" style={{ background: 'var(--color-tertiary)' }} />
앙상블
</div>
*/}
</div>
{/* 모델 미선택 경고 (실행 시 검증) */}
{validationErrors?.has('models') && (
<p className="text-label-2 text-color-danger font-korean">
.
</p>
)}
{/* Run Button */}
<button
className="prd-btn pri mt-0.5"
style={{ padding: '7px', fontSize: 'var(--font-size-label-2)' }}
onClick={onRunSimulation}
disabled={isRunningSimulation}
>
{isRunningSimulation ? '⏳ 실행 중...' : '확산예측 실행'}
</button>
</div>
)}
</div>
);
};
// ── 커스텀 날짜/시간 선택 컴포넌트 ─────────────────────
function DateTimeInput({
value,
onChange,
error,
}: {
value: string;
onChange: (v: string) => void;
error?: boolean;
}) {
const [showCal, setShowCal] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className="flex items-center gap-1 relative"
style={
error ? { border: '1px solid var(--color-danger)', borderRadius: 6, padding: 2 } : undefined
}
>
{/* 날짜 버튼 */}
<button
type="button"
onClick={() => setShowCal(!showCal)}
className="prd-i flex-1 flex items-center justify-between cursor-pointer"
style={{ padding: '5px 8px', fontSize: 'var(--font-size-label-2)' }}
>
<span
className="font-mono"
style={{ color: datePart ? 'var(--fg-default)' : 'var(--fg-disabled)' }}
>
{displayDate}
</span>
<span className="text-label-2 opacity-60">📅</span>
</button>
{/* 시 */}
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
<span className="text-label-2 text-fg-default font-bold">:</span>
{/* 분 */}
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
{/* 캘린더 팝업 */}
{showCal && (
<div
className="absolute z-[9999] rounded-md overflow-hidden"
style={{
top: '100%',
left: 0,
marginTop: 4,
width: 200,
background: 'var(--bg-card)',
border: '1px solid var(--stroke-default)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
}}
>
{/* 헤더 */}
<div
className="flex items-center justify-between"
style={{ padding: '6px 8px', borderBottom: '1px solid var(--stroke-default)' }}
>
<button
type="button"
onClick={prevMonth}
className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
>
</button>
<span className="text-label-2 font-bold text-fg font-korean">
{viewYear} {viewMonth + 1}
</span>
<button
type="button"
onClick={nextMonth}
className="text-label-2 text-fg-default cursor-pointer px-1 hover:text-fg"
>
</button>
</div>
{/* 요일 */}
<div className="grid grid-cols-7 text-center" style={{ padding: '3px 4px 0' }}>
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
<span
key={d}
className="text-label-2 text-fg-default font-korean"
style={{ padding: '2px 0' }}
>
{d}
</span>
))}
</div>
{/* 날짜 */}
<div className="grid grid-cols-7 text-center" style={{ padding: '2px 4px 6px' }}>
{days.map((day, i) => {
if (day === null) return <span key={`e-${i}`} />;
const isSelected = viewYear === selY && viewMonth === selM && day === selD;
const isToday = viewYear === todayY && viewMonth === todayM && day === todayD;
return (
<button
key={day}
type="button"
onClick={() => pickDate(day)}
className="cursor-pointer rounded-sm"
style={{
padding: '3px 0',
fontSize: 'var(--font-size-label-2)',
fontFamily: 'var(--font-mono)',
fontWeight: isSelected ? 700 : 400,
color: isSelected ? '#fff' : isToday ? 'var(--color-accent)' : 'var(--fg-sub)',
background: isSelected ? 'var(--color-accent)' : 'transparent',
border: 'none',
}}
>
{day}
</button>
);
})}
</div>
{/* 오늘 버튼 */}
<div style={{ padding: '0 8px 6px' }}>
<button
type="button"
onClick={() => {
const now = new Date();
setViewYear(now.getFullYear());
setViewMonth(now.getMonth());
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
onChange(`${now.getFullYear()}-${m}-${d}T${hh}:${mm}`);
setShowCal(false);
}}
className="w-full text-label-2 font-korean font-medium cursor-pointer rounded-sm"
style={{
padding: '3px 0',
background: 'rgba(6,182,212,0.08)',
border: '1px solid rgba(6,182,212,0.2)',
color: 'var(--color-accent)',
}}
>
</button>
</div>
</div>
)}
</div>
);
}
// ── 커스텀 시간 드롭다운 (다크 테마) ───────────────────
function TimeDropdown({
value,
max,
onChange,
}: {
value: number;
max: number;
onChange: (v: number) => void;
}) {
const [open, setOpen] = useState(false);
const dropRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(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 (
<div ref={dropRef} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="prd-i text-center font-mono cursor-pointer"
style={{ width: 38, padding: '5px 2px', fontSize: 'var(--font-size-caption)' }}
>
{String(value).padStart(2, '0')}
</button>
{open && (
<div
ref={listRef}
className="absolute z-[9999] overflow-y-auto rounded-md"
style={{
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: 2,
width: 42,
maxHeight: 160,
background: 'var(--bg-card)',
border: '1px solid var(--stroke-default)',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
{Array.from({ length: max }, (_, i) => (
<button
key={i}
type="button"
data-active={i === value}
onClick={() => {
onChange(i);
setOpen(false);
}}
className="w-full text-center font-mono cursor-pointer"
style={{
padding: '4px 0',
fontSize: 'var(--font-size-caption)',
color: i === value ? 'var(--color-accent)' : 'var(--fg-sub)',
background: i === value ? 'rgba(6,182,212,0.15)' : 'transparent',
fontWeight: i === value ? 700 : 400,
border: 'none',
}}
>
{String(i).padStart(2, '0')}
</button>
))}
</div>
)}
</div>
);
}
// ── 도분초 좌표 입력 컴포넌트 ──────────────────────────
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 (
<div className="flex flex-col gap-0.5">
<span className="text-caption text-fg-default font-korean">{label}</span>
<div
className="flex items-center gap-0.5"
style={
error
? { border: '1px solid var(--color-danger)', borderRadius: 6, padding: 2 }
: undefined
}
>
<select
className="prd-i text-center"
value={dir}
onChange={(e) => update(d, m, s, e.target.value)}
style={{
width: 32,
padding: '5px 1px',
fontSize: 'var(--font-size-label-2)',
appearance: 'none',
WebkitAppearance: 'none',
backgroundImage: 'none',
}}
>
{isLatitude ? (
<>
<option value="N">N</option>
<option value="S">S</option>
</>
) : (
<>
<option value="E">E</option>
<option value="W">W</option>
</>
)}
</select>
<input
className="prd-i text-center flex-1"
type="number"
min={0}
max={isLatitude ? 90 : 180}
value={d}
onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)}
style={fieldStyle}
/>
<span className="text-caption text-fg-default">°</span>
<input
className="prd-i text-center flex-1"
type="number"
min={0}
max={59}
value={m}
onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)}
style={fieldStyle}
/>
<span className="text-caption text-fg-default">'</span>
<input
className="prd-i text-center flex-1"
type="number"
min={0}
max={59.99}
step={0.01}
value={s}
onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)}
style={fieldStyle}
/>
<span className="text-caption text-fg-default">"</span>
</div>
</div>
);
}
export default PredictionInputSection;