857 lines
30 KiB
TypeScript
857 lines
30 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);
|
|
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-sub font-korean">예측정보 입력</h3>
|
|
<span className="text-label-2 text-fg-disabled">{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>
|
|
|
|
{/* Direct Input Mode */}
|
|
{inputMode === 'direct' && (
|
|
<>
|
|
<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-disabled 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-disabled 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-disabled">
|
|
위도 {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-disabled 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="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 (다중 선택) */}
|
|
{/* 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled 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-disabled">°</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-disabled">'</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-disabled">"</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PredictionInputSection;
|