import { useState, useRef, useEffect, useCallback } from 'react';
import * as exifr from 'exifr';
import { stitchImages } from '../services/aerialApi';
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
import { navigateToTab } from '@common/hooks/useSubMenu';
import { decimalToDMS } from '@common/utils/coordinates';
const MAX_IMAGES = 6;
interface ImageExif {
lat: number | null;
lon: number | null;
altitude: number | null;
make: string | null;
model: string | null;
dateTime: Date | string | null;
exposureTime: number | null;
fNumber: number | null;
iso: number | null;
focalLength: number | null;
imageWidth: number | null;
imageHeight: number | null;
}
function formatFileSize(bytes?: number): string | null {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDateTime(dt: Date | string | null): string | null {
if (!dt) return null;
if (dt instanceof Date) {
return dt.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
return String(dt);
}
interface MetaRowProps {
label: string;
value: string | null | undefined;
}
function MetaRow({ label, value }: MetaRowProps) {
if (value == null) return null;
return (
{label}
{value}
);
}
export function OilAreaAnalysis() {
const [selectedFiles, setSelectedFiles] = useState([]);
const [previewUrls, setPreviewUrls] = useState([]);
const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(null);
const [stitchedBlob, setStitchedBlob] = useState(null);
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState(null);
const [isStitching, setIsStitching] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
const processedFilesRef = useRef>(new Set());
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
useEffect(() => {
return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url));
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출
useEffect(() => {
selectedFiles.forEach((file, i) => {
if (processedFilesRef.current.has(file)) return;
processedFilesRef.current.add(file);
exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false })
.then(exif => {
const info: ImageExif = {
lat: exif?.latitude ?? null,
lon: exif?.longitude ?? null,
altitude: exif?.GPSAltitude ?? null,
make: exif?.Make ?? null,
model: exif?.Model ?? null,
dateTime: exif?.DateTimeOriginal ?? null,
exposureTime: exif?.ExposureTime ?? null,
fNumber: exif?.FNumber ?? null,
iso: exif?.ISO ?? null,
focalLength: exif?.FocalLength ?? null,
imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null,
imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null,
};
setImageExifs(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = info;
return updated;
});
})
.catch(() => {
setImageExifs(prev => {
const updated = [...prev];
while (updated.length <= i) updated.push(undefined);
updated[i] = {
lat: null, lon: null, altitude: null,
make: null, model: null, dateTime: null,
exposureTime: null, fNumber: null, iso: null,
focalLength: null, imageWidth: null, imageHeight: null,
};
return updated;
});
});
});
}, [selectedFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent) => {
setError(null);
const incoming = Array.from(e.target.files ?? []);
if (incoming.length === 0) return;
setSelectedFiles(prev => {
const merged = [...prev, ...incoming].slice(0, MAX_IMAGES);
if (prev.length + incoming.length > MAX_IMAGES) {
setError(`최대 ${MAX_IMAGES}장까지 선택할 수 있습니다.`);
}
return merged;
});
// setSelectedFiles updater 밖에서 독립 호출 — updater 내부 side effect는
// React Strict Mode의 이중 호출로 인해 URL이 중복 생성되는 버그를 유발함
setPreviewUrls(prev => {
const available = MAX_IMAGES - prev.length;
const toAdd = incoming.slice(0, available);
return [...prev, ...toAdd.map(f => URL.createObjectURL(f))];
});
// input 초기화 (동일 파일 재선택 허용)
e.target.value = '';
}, []);
const handleRemoveFile = useCallback((idx: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
setPreviewUrls(prev => {
URL.revokeObjectURL(prev[idx]);
return prev.filter((_, i) => i !== idx);
});
setImageExifs(prev => prev.filter((_, i) => i !== idx));
setSelectedImageIndex(prev => {
if (prev === null) return null;
if (prev === idx) return null;
if (prev > idx) return prev - 1;
return prev;
});
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
setStitchedBlob(null);
if (stitchedPreviewUrl) {
URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedPreviewUrl(null);
}
setError(null);
}, [stitchedPreviewUrl]);
const handleStitch = async () => {
if (selectedFiles.length < 2) {
setError('이미지를 2장 이상 선택해주세요.');
return;
}
setError(null);
setIsStitching(true);
try {
const blob = await stitchImages(selectedFiles);
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
setStitchedBlob(blob);
setStitchedPreviewUrl(URL.createObjectURL(blob));
} catch (err) {
const msg =
err instanceof Error
? err.message
: (err as { message?: string }).message ?? '이미지 합성에 실패했습니다.';
const status = err instanceof Error ? 0 : (err as { status?: number }).status ?? 0;
setError(status === 504 ? '이미지 합성 서버 응답 시간이 초과되었습니다.' : msg);
} finally {
setIsStitching(false);
}
};
const handleAnalyze = async () => {
if (!stitchedBlob) return;
setError(null);
setIsAnalyzing(true);
try {
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, { type: 'image/jpeg' });
const result = await analyzeImage(stitchedFile);
setPendingImageAnalysis({ ...result, autoRun: true });
navigateToTab('prediction', 'analysis');
} catch (err) {
const msg = err instanceof Error ? err.message : '분석에 실패했습니다.';
setError(msg.includes('GPS') ? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.' : msg);
setIsAnalyzing(false);
}
};
const canStitch = selectedFiles.length >= 2 && !isStitching && !isAnalyzing;
const canAnalyze = stitchedBlob !== null && !isStitching && !isAnalyzing;
return (
{/* ── Left Panel ── */}
🧩 영상사진합성
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
{/* 이미지 선택 버튼 */}
{/* 선택된 이미지 목록 */}
{selectedFiles.length > 0 && (
<>
선택된 이미지
{selectedFiles.map((file, i) => (
setSelectedImageIndex(i)}
>
📷
{file.name}
{selectedImageIndex === i && imageExifs[i] !== undefined && (
)}
))}
>
)}
{/* 에러 메시지 */}
{error && (
{error}
)}
{/* 이미지 합성 버튼 */}
{/* 분석 시작 버튼 */}
{/* ── Right Panel ── */}
{/* 3×2 이미지 그리드 */}
선택된 이미지 미리보기
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
{ if (previewUrls[i]) setSelectedImageIndex(i); }}
>
{previewUrls[i] ? (
<>
{selectedFiles[i]?.name}
{imageExifs[i] === undefined ? (
GPS 읽는 중...
) : imageExifs[i]?.lat !== null ? (
{decimalToDMS(imageExifs[i]!.lat!, true)}
{decimalToDMS(imageExifs[i]!.lon!, false)}
) : (
GPS 정보 없음
)}
>
) : (
{i + 1}
)}
))}
{/* 합성 결과 */}
합성 결과
{stitchedPreviewUrl ? (

) : (
{isStitching
? '⏳ 이미지를 합성하고 있습니다...'
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
)}
);
}