import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ObjViewer from './ObjViewer.jsx'; import { GOOGLE_CLIENT_ID } from './config.js'; import { downloadObjText, listObjFiles } from './driveApi.js'; import { useGoogleDriveAuth } from './useGoogleDriveAuth.js'; const ORIGINAL_FILE_PATTERN = /^ldsm_fit_original_(\d{5})_([^_]+)_(\d{8})\.obj$/i; function parseOriginalFile(file) { const match = file.name.match(ORIGINAL_FILE_PATTERN); if (!match) return null; const [, patientId, label, date] = match; return { ...file, patientId, label, studyDate: date, studyDateLabel: `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`, }; } function formatBytes(value) { if (!value) return 'size unknown'; const number = Number(value); if (!Number.isFinite(number)) return 'size unknown'; const units = ['B', 'KB', 'MB', 'GB']; let size = number; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } export default function App() { const { idToken, accessToken, user, authReady, authError, signIn, signOut, } = useGoogleDriveAuth(); const viewerRef = useRef(null); const [files, setFiles] = useState([]); const [selectedPatientId, setSelectedPatientId] = useState(''); const [patientSearch, setPatientSearch] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [objText, setObjText] = useState(''); const [isListing, setIsListing] = useState(false); const [isLoadingObj, setIsLoadingObj] = useState(false); const [isPreparingAnimation, setIsPreparingAnimation] = useState(false); const [error, setError] = useState(''); const canUseDrive = Boolean(GOOGLE_CLIENT_ID); const isAuthenticated = Boolean(idToken && accessToken); const tokens = useMemo(() => ({ accessToken, idToken }), [accessToken, idToken]); const loadFiles = useCallback(async () => { if (!isAuthenticated) return; setIsListing(true); setError(''); try { const result = await listObjFiles(tokens); setFiles(result); } catch (loadError) { setError(loadError.message || 'OBJ一覧の取得に失敗しました。'); } finally { setIsListing(false); } }, [isAuthenticated, tokens]); useEffect(() => { loadFiles(); }, [loadFiles]); const originalFiles = useMemo( () => files .map(parseOriginalFile) .filter(Boolean) .sort((a, b) => a.patientId.localeCompare(b.patientId) || a.studyDate.localeCompare(b.studyDate)), [files], ); const patients = useMemo(() => { const groups = new Map(); for (const file of originalFiles) { if (!groups.has(file.patientId)) { groups.set(file.patientId, []); } groups.get(file.patientId).push(file); } return [...groups.entries()].map(([patientId, patientFiles]) => ({ patientId, files: patientFiles.sort((a, b) => a.studyDate.localeCompare(b.studyDate)), })); }, [originalFiles]); const selectedPatient = useMemo( () => patients.find((patient) => patient.patientId === selectedPatientId) || null, [patients, selectedPatientId], ); const visiblePatients = useMemo(() => { const query = patientSearch.trim(); if (!query) return patients; return patients.filter((patient) => patient.patientId.includes(query)); }, [patientSearch, patients]); useEffect(() => { if (!patients.length) { setSelectedPatientId(''); setSelectedFile(null); setObjText(''); return; } const currentPatient = patients.find((patient) => patient.patientId === selectedPatientId); if (!currentPatient) { setSelectedPatientId(patients[0].patientId); setSelectedFile(patients[0].files[0]); return; } if (!selectedFile || selectedFile.patientId !== currentPatient.patientId) { setSelectedFile(currentPatient.files[0]); } }, [patients, selectedFile, selectedPatientId]); const handleSelectPatient = useCallback( (patientId) => { const patient = patients.find((item) => item.patientId === patientId); setSelectedPatientId(patientId); setSelectedFile(patient?.files[0] || null); setObjText(''); setError(''); }, [patients], ); const handleSelectFile = useCallback( async (file) => { setSelectedFile(file); setObjText(''); setIsLoadingObj(true); setError(''); try { const text = await downloadObjText(file.id, tokens); setObjText(text); } catch (loadError) { setError(loadError.message || 'OBJファイルの読み込みに失敗しました。'); } finally { setIsLoadingObj(false); } }, [tokens], ); useEffect(() => { if (selectedFile && isAuthenticated) { handleSelectFile(selectedFile); } }, [handleSelectFile, isAuthenticated, selectedFile?.id]); const loadPatientFrames = useCallback(async () => { if (!selectedPatient || selectedPatient.files.length < 2) return []; setIsPreparingAnimation(true); setError(''); try { return await Promise.all( selectedPatient.files.map(async (file) => ({ file, text: await downloadObjText(file.id, tokens), })), ); } catch (loadError) { setError(loadError.message || 'アニメーション用OBJの取得に失敗しました。'); return []; } finally { setIsPreparingAnimation(false); } }, [selectedPatient, tokens]); const handlePlayAnimation = useCallback(async () => { const frames = await loadPatientFrames(); if (frames.length < 2) return; viewerRef.current?.playMorph(frames); }, [loadPatientFrames]); const handleDownloadMp4 = useCallback(async () => { const frames = await loadPatientFrames(); if (frames.length < 2) return; const filename = `patient_${selectedPatient.patientId}_original_morph.mp4`; await viewerRef.current?.downloadMorphMp4(frames, filename); }, [loadPatientFrames, selectedPatient]); const animationDisabled = !selectedPatient || selectedPatient.files.length < 2 || isLoadingObj || isPreparingAnimation; return (
{(isLoadingObj || isPreparingAnimation) && (
{isPreparingAnimation ? 'アニメーションを準備中...' : 'OBJを読み込み中...'}
)}
); }