Spaces:
Sleeping
Sleeping
| 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 ( | |
| <main className="app-shell"> | |
| <aside className="sidebar"> | |
| <div className="brand-block"> | |
| <p className="eyebrow">Google Drive</p> | |
| <h1>OBJ Drive Viewer</h1> | |
| <p className="subtle">originalデータを患者IDと日付で確認します。</p> | |
| </div> | |
| <div className="auth-actions"> | |
| {!isAuthenticated && ( | |
| <button className="primary-button" onClick={signIn} disabled={!authReady || !canUseDrive}> | |
| Googleで認証 | |
| </button> | |
| )} | |
| {isAuthenticated && ( | |
| <> | |
| <button className="primary-button" onClick={loadFiles} disabled={isListing}> | |
| {isListing ? '更新中...' : '一覧を更新'} | |
| </button> | |
| <button className="ghost-button" onClick={signOut}> | |
| サインアウト | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| {user?.email && ( | |
| <div className="signed-in-user"> | |
| <span>Signed in</span> | |
| <strong>{user.email}</strong> | |
| </div> | |
| )} | |
| {!canUseDrive && ( | |
| <div className="notice"> | |
| <strong>OAuth Client IDが未設定です</strong> | |
| <span>GOOGLE_CLIENT_ID を設定してください。</span> | |
| </div> | |
| )} | |
| {authError && ( | |
| <div className="notice error"> | |
| <strong>認証エラー</strong> | |
| <span>{authError}</span> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="notice error"> | |
| <strong>読み込みエラー</strong> | |
| <span>{error}</span> | |
| </div> | |
| )} | |
| <div className="selector-grid"> | |
| <div className="file-section"> | |
| <div className="section-heading"> | |
| <h2>Patient ID</h2> | |
| <span>{isAuthenticated ? `${patients.length} patients` : 'sign in required'}</span> | |
| </div> | |
| <input | |
| className="search-input" | |
| inputMode="numeric" | |
| maxLength={5} | |
| placeholder="IDで検索" | |
| value={patientSearch} | |
| onChange={(event) => setPatientSearch(event.target.value.replace(/\D/g, '').slice(0, 5))} | |
| disabled={!isAuthenticated || isListing} | |
| /> | |
| <div className="file-list patient-list"> | |
| {isListing && <div className="list-row muted">Driveを確認しています...</div>} | |
| {!isListing && isAuthenticated && patients.length === 0 && ( | |
| <div className="list-row muted">original OBJが見つかりません。</div> | |
| )} | |
| {!isAuthenticated && <div className="list-row muted">認証後に表示します。</div>} | |
| {visiblePatients.map((patient) => ( | |
| <button | |
| key={patient.patientId} | |
| className={`file-row compact ${patient.patientId === selectedPatientId ? 'active' : ''}`} | |
| onClick={() => handleSelectPatient(patient.patientId)} | |
| disabled={isLoadingObj || isPreparingAnimation} | |
| > | |
| <span className="file-name">{patient.patientId}</span> | |
| <span className="file-meta">{patient.files.length} dates</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="file-section"> | |
| <div className="section-heading"> | |
| <h2>Date</h2> | |
| <span>{selectedPatient ? `${selectedPatient.files.length} originals` : '-'}</span> | |
| </div> | |
| <div className="file-list date-list"> | |
| {!selectedPatient && <div className="list-row muted">患者を選択してください。</div>} | |
| {selectedPatient?.files.map((file) => ( | |
| <button | |
| key={file.id} | |
| className={`file-row ${selectedFile?.id === file.id ? 'active' : ''}`} | |
| onClick={() => handleSelectFile(file)} | |
| disabled={isLoadingObj || isPreparingAnimation} | |
| > | |
| <span className="file-name">{file.studyDateLabel}</span> | |
| <span className="file-meta"> | |
| {file.label} · {formatBytes(file.size)} | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="animation-panel"> | |
| <div className="section-heading"> | |
| <h2>Morph</h2> | |
| <span>{selectedPatient?.files.length > 1 ? 'available' : 'single date'}</span> | |
| </div> | |
| <div className="auth-actions"> | |
| <button className="ghost-button" onClick={handlePlayAnimation} disabled={animationDisabled}> | |
| 日付順に再生 | |
| </button> | |
| <button className="ghost-button" onClick={handleDownloadMp4} disabled={animationDisabled}> | |
| MP4を保存 | |
| </button> | |
| </div> | |
| </div> | |
| </aside> | |
| <ObjViewer ref={viewerRef} objText={objText} fileName={selectedFile?.name} /> | |
| {(isLoadingObj || isPreparingAnimation) && ( | |
| <div className="loading-overlay"> | |
| {isPreparingAnimation ? 'アニメーションを準備中...' : 'OBJを読み込み中...'} | |
| </div> | |
| )} | |
| </main> | |
| ); | |
| } | |