diff --git a/src/App.tsx b/src/App.tsx index 5ef027692..6b6129c1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { ConfigProvider } from './config/ConfigService'; import { PythonConverterService } from './PythonConverter/PythonConverterService'; import { Auth } from './services/AuthService'; import { DialogProvider } from './services/DialogService'; +import { Geant4DatasetContextProvider } from './services/Geant4DatasetContextProvider'; import { Geant4LocalWorkerSimulationContextProvider } from './services/Geant4LocalWorkerSimulationContextProvider'; import { KeycloakAuth } from './services/KeycloakAuthService'; import { Loader } from './services/LoaderService'; @@ -102,6 +103,7 @@ function App() { , , , + , , , , diff --git a/src/Geant4Worker/Geant4DatasetCacheService.ts b/src/Geant4Worker/Geant4DatasetCacheService.ts new file mode 100644 index 000000000..1bf1769b2 --- /dev/null +++ b/src/Geant4Worker/Geant4DatasetCacheService.ts @@ -0,0 +1,277 @@ +/** + * Service to detect and manage Geant4 dataset cache status in IndexedDB. + * The Emscripten preload scripts store datasets in IndexedDB with: + * - Database name: EM_PRELOAD_CACHE + * - Store: METADATA (contains package UUIDs and chunk counts) + * - Store: PACKAGES (contains the actual data chunks) + * + * Cache keys are constructed as: metadata/ + * where PACKAGE_PATH is URL-encoded window.location.pathname + '/' + * and PACKAGE_NAME comes from the preload script (e.g., '/memfs/.../G4EMLOW8.6.1.data') + * + * We detect cached datasets by matching the .data file suffix in IndexedDB keys. + */ + +export const GEANT4_DATASETS = [ + { name: 'G4EMLOW8.6.1', dataFile: 'G4EMLOW8.6.1.data', approximateSizeMB: 649.079 }, + { name: 'G4ENSDFSTATE3.0', dataFile: 'G4ENSDFSTATE3.0.data', approximateSizeMB: 1.874 }, + { name: 'G4NDL4.7.1', dataFile: 'G4NDL4.7.1.data', approximateSizeMB: 1082.079 }, + { name: 'G4PARTICLEXS4.1', dataFile: 'G4PARTICLEXS4.1.data', approximateSizeMB: 23.533 }, + { name: 'G4SAIDDATA2.0', dataFile: 'G4SAIDDATA2.0.data', approximateSizeMB: 0.106 }, + { + name: 'PhotonEvaporation6.1', + dataFile: 'PhotonEvaporation6.1.data', + approximateSizeMB: 39.609 + } +] as const; + +export const TOTAL_DATASET_SIZE_MB = GEANT4_DATASETS.reduce( + (sum, ds) => sum + ds.approximateSizeMB, + 0 +); + +const DB_NAME = 'EM_PRELOAD_CACHE'; +const DB_VERSION = 1; +const METADATA_STORE_NAME = 'METADATA'; +const PACKAGES_STORE_NAME = 'PACKAGES'; + +export interface DatasetCacheStatus { + name: string; + isCached: boolean; + approximateSizeMB: number; +} + +export interface CacheStatusResult { + datasets: Record; + cachedCount: number; + totalCount: number; + estimatedCachedSizeMB: number; + estimatedTotalSizeMB: number; +} + +export interface StorageEstimate { + usedMB: number; + quotaMB: number; + percentUsed: number; +} + +/** + * Opens the IndexedDB database used by Emscripten for caching + */ +async function openDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject('IndexedDB is not supported in this environment'); + + return; + } + + const openRequest = indexedDB.open(DB_NAME, DB_VERSION); + + openRequest.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + // Create stores if they don't exist (they won't have data anyway) + + if (!db.objectStoreNames.contains(PACKAGES_STORE_NAME)) { + db.createObjectStore(PACKAGES_STORE_NAME); + } + + if (!db.objectStoreNames.contains(METADATA_STORE_NAME)) { + db.createObjectStore(METADATA_STORE_NAME); + } + }; + + openRequest.onsuccess = event => { + const db = (event.target as IDBOpenDBRequest).result; + resolve(db); + }; + + openRequest.onerror = () => { + reject('Failed to open IndexedDB database'); + }; + }); +} + +/** + * Checks if a specific dataset is cached in IndexedDB by looking for any key ending with the dataFile + * The cache keys end with the .data filename (e.g., 'G4EMLOW8.6.1.data') + */ +async function checkDatasetCachedByDataFile( + db: IDBDatabase, + dataFile: string, + allMetadataKeys: string[] +): Promise { + // Find any key that ends with our dataFile (after removing 'metadata/' prefix) + const matchingKey = allMetadataKeys.find(key => { + // Keys are stored as 'metadata/' + // where PACKAGE_NAME ends with the dataFile (e.g., '/memfs/.../G4EMLOW8.6.1.data') + const keyWithoutPrefix = key.replace(/^metadata\//, ''); + // The keyWithoutPrefix might be URL-encoded, so try both + const decoded = decodeURIComponent(keyWithoutPrefix); + + return keyWithoutPrefix.endsWith(dataFile) || decoded.endsWith(dataFile); + }); + + if (matchingKey) { + const result = await getMetadataValue(db, matchingKey); + + if (result && result.uuid) { + return true; + } + } + + return false; +} + +/** + * Get the metadata value for a specific key + */ +async function getMetadataValue( + db: IDBDatabase, + key: string +): Promise<{ uuid: string; chunkCount: number } | null> { + return new Promise(resolve => { + try { + const transaction = db.transaction([METADATA_STORE_NAME], 'readonly'); + const metadata = transaction.objectStore(METADATA_STORE_NAME); + const getRequest = metadata.get(key); + + getRequest.onsuccess = () => { + resolve(getRequest.result || null); + }; + + getRequest.onerror = () => { + resolve(null); + }; + } catch { + resolve(null); + } + }); +} + +/** + * Lists all keys in the METADATA store for debugging + */ +async function listAllMetadataKeys(db: IDBDatabase): Promise { + return new Promise(resolve => { + try { + const transaction = db.transaction([METADATA_STORE_NAME], 'readonly'); + const metadata = transaction.objectStore(METADATA_STORE_NAME); + const getAllKeysRequest = metadata.getAllKeys(); + + getAllKeysRequest.onsuccess = () => { + const keys = getAllKeysRequest.result as string[]; + resolve(keys); + }; + + getAllKeysRequest.onerror = () => { + resolve([]); + }; + } catch (error) { + resolve([]); + } + }); +} + +/** + * Checks the cache status of all Geant4 datasets + */ +export async function checkAllDatasetsCacheStatus(): Promise { + const db = await openDatabase(); + + if (!db) { + return { + datasets: Object.fromEntries( + GEANT4_DATASETS.map(ds => [ + ds.name, + { + name: ds.name, + isCached: false, + approximateSizeMB: ds.approximateSizeMB + } + ]) + ), + cachedCount: 0, + totalCount: GEANT4_DATASETS.length, + estimatedCachedSizeMB: 0, + estimatedTotalSizeMB: TOTAL_DATASET_SIZE_MB + }; + } + + try { + // First, list all metadata keys for debugging + const allKeys = await listAllMetadataKeys(db); + + const datasetStatuses: Record = await Promise.all( + GEANT4_DATASETS.map(async ds => { + const isCached = await checkDatasetCachedByDataFile(db, ds.dataFile, allKeys); + + return [ + ds.name, + { + name: ds.name, + isCached, + approximateSizeMB: ds.approximateSizeMB + } + ] as const; + }) + ).then(entries => Object.fromEntries(entries)); + + const cachedDatasets = Object.values(datasetStatuses).filter(ds => ds.isCached); + + const result: CacheStatusResult = { + datasets: datasetStatuses, + cachedCount: cachedDatasets.length, + totalCount: GEANT4_DATASETS.length, + estimatedCachedSizeMB: cachedDatasets.reduce( + (sum, ds) => sum + ds.approximateSizeMB, + 0 + ), + estimatedTotalSizeMB: TOTAL_DATASET_SIZE_MB + }; + + return result; + } finally { + db.close(); + } +} + +/** + * Gets browser storage estimate using the Storage API + */ +export async function getStorageEstimate(): Promise { + if (!navigator.storage || !navigator.storage.estimate) { + return null; + } + + try { + const estimate = await navigator.storage.estimate(); + const usedMB = (estimate.usage ?? 0) / (1024 * 1024); + const quotaMB = (estimate.quota ?? 0) / (1024 * 1024); + + return { + usedMB, + quotaMB, + percentUsed: quotaMB > 0 ? (usedMB / quotaMB) * 100 : 0 + }; + } catch { + return null; + } +} + +/** + * Clears all cached Geant4 datasets from IndexedDB + */ +export async function clearDatasetCache(): Promise { + return new Promise(resolve => { + if (typeof indexedDB === 'undefined') { + return resolve(false); + } + + const deleteRequest = indexedDB.deleteDatabase(DB_NAME); + + deleteRequest.onsuccess = () => resolve(true); + deleteRequest.onerror = () => resolve(false); + deleteRequest.onblocked = () => resolve(false); + }); +} diff --git a/src/Geant4Worker/Geant4DatasetDownloadManager.ts b/src/Geant4Worker/Geant4DatasetDownloadManager.ts deleted file mode 100644 index f8992242f..000000000 --- a/src/Geant4Worker/Geant4DatasetDownloadManager.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Additional credits: -// - @kmichalik - -import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; - -import Geant4Worker from './Geant4Worker'; - -export enum DownloadManagerStatus { - IDLE, - WORKING, - FINISHED, - ERROR -} - -export enum DatasetDownloadStatus { - IDLE, - DOWNLOADING, - PROCESSING, - DONE -} - -const statusTypeMap: Record = { - downloading: DatasetDownloadStatus.DOWNLOADING, - preparing: DatasetDownloadStatus.PROCESSING, - done: DatasetDownloadStatus.DONE -}; - -export interface DatasetStatus { - name: string; - status: DatasetDownloadStatus; - done?: number; - total?: number; -} - -async function fetchProgress( - worker: Geant4Worker, - setDatasetStates: Dispatch>> -) { - if (!worker.getIsInitialized()) return; - - const progress = await worker.pollDatasetProgress(); - - if (progress) { - const newDatasetStates: Record = {}; - - for (const [datasetName, datasetProgress] of Object.entries(progress)) { - let status = statusTypeMap[datasetProgress.stage] ?? DatasetDownloadStatus.IDLE; - - newDatasetStates[datasetName] = { - name: datasetName, - status, - done: Math.floor(datasetProgress.progress * 100), - total: 100 - }; - } - - setDatasetStates(prev => ({ ...prev, ...newDatasetStates })); - } -} - -type StartDownloadArgs = { - worker: Geant4Worker; - managerState: DownloadManagerStatus; - setManagerState: Dispatch>; - setDatasetStates: Dispatch>>; - setIdle: Dispatch>; -}; - -function startDownload({ - worker, - managerState, - setManagerState, - setDatasetStates, - setIdle -}: StartDownloadArgs) { - if (managerState !== DownloadManagerStatus.IDLE || !worker.getIsInitialized()) { - return; - } - - const loadDepsPromise = worker.loadDeps(); - - const interval = setInterval(async () => { - await fetchProgress(worker, setDatasetStates); - }, 500); - - loadDepsPromise - .then(async () => { - clearInterval(interval); - - await fetchProgress(worker, setDatasetStates); - - setManagerState(DownloadManagerStatus.FINISHED); - worker.destroy(); - }) - .catch(error => { - console.error('Dataset download error:', error); - setManagerState(DownloadManagerStatus.ERROR); - clearInterval(interval); - }); - setManagerState(DownloadManagerStatus.WORKING); - setIdle(false); -} - -export function useDatasetDownloadManager() { - const [managerState, setManagerState] = useState( - DownloadManagerStatus.IDLE - ); - const [datasetStates, setDatasetStates] = useState>({}); - const [idle, setIdle] = useState(false); - const [worker] = useState(new Geant4Worker()); - const initCalledRef = useRef(false); - - useEffect(() => { - if (initCalledRef.current) return; - worker - .init() - .then(() => setIdle(true)) - .catch(error => { - setManagerState(DownloadManagerStatus.ERROR); - console.error('Failed to initialize Geant4 worker for dataset download:', error); - }); - initCalledRef.current = true; - }, [worker]); - - const startDownloadSimple = useCallback(() => { - if (!idle) return; - - startDownload({ worker, managerState, setManagerState, setDatasetStates, setIdle }); - }, [worker, idle, managerState]); - - return { - managerState, - datasetStates: Object.values(datasetStates), - startDownload: startDownloadSimple - }; -} diff --git a/src/Geant4Worker/Geant4DatasetManager.ts b/src/Geant4Worker/Geant4DatasetManager.ts new file mode 100644 index 000000000..7f4be446b --- /dev/null +++ b/src/Geant4Worker/Geant4DatasetManager.ts @@ -0,0 +1,243 @@ +// Additional credits: +// - @kmichalik + +import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; + +import { + checkAllDatasetsCacheStatus, + clearDatasetCache, + GEANT4_DATASETS, + getStorageEstimate, + StorageEstimate, + TOTAL_DATASET_SIZE_MB +} from './Geant4DatasetCacheService'; +import Geant4Worker from './Geant4Worker'; + +export enum DownloadManagerStatus { + IDLE, + WORKING, + FINISHED, + ERROR +} + +export enum DatasetDownloadStatus { + IDLE, + DOWNLOADING, + PROCESSING, + DONE +} + +const statusTypeMap: Record = { + downloading: DatasetDownloadStatus.DOWNLOADING, + preparing: DatasetDownloadStatus.PROCESSING, + done: DatasetDownloadStatus.DONE +}; + +export interface DatasetStatus { + name: string; + status: DatasetDownloadStatus; + done?: number; + total?: number; + totalSizeMB?: number; + cached?: boolean; +} + +async function fetchProgress( + worker: Geant4Worker, + setDatasetStates: Dispatch>> +) { + if (!worker.getIsInitialized()) return; + + const progress = await worker.pollDatasetProgress(); + + if (progress) { + setDatasetStates(prev => { + const newStates: Record = { ...prev }; + + for (const [datasetName, datasetProgress] of Object.entries(progress)) { + let status = statusTypeMap[datasetProgress.stage] ?? DatasetDownloadStatus.IDLE; + + newStates[datasetName] = { + ...newStates[datasetName], + name: datasetName, + status, + done: Math.floor(datasetProgress.progress * 100), + total: 100 + }; + } + + return newStates; + }); + } +} + +type StartDownloadArgs = { + worker: Geant4Worker; + managerState: DownloadManagerStatus; + setManagerState: Dispatch>; + setDatasetStates: Dispatch>>; + setIdle: Dispatch>; +}; + +function startDownload({ + worker, + managerState, + setManagerState, + setDatasetStates, + setIdle +}: StartDownloadArgs) { + if (managerState !== DownloadManagerStatus.IDLE || !worker.getIsInitialized()) { + return; + } + + const loadDepsPromise = worker.loadDeps(); + + const interval = setInterval(async () => { + await fetchProgress(worker, setDatasetStates); + }, 500); + + loadDepsPromise + .then(async () => { + clearInterval(interval); + + await fetchProgress(worker, setDatasetStates); + + setManagerState(DownloadManagerStatus.FINISHED); + worker.destroy(); + }) + .catch(error => { + console.error('Dataset download error:', error); + setManagerState(DownloadManagerStatus.ERROR); + clearInterval(interval); + }); + setManagerState(DownloadManagerStatus.WORKING); + setIdle(false); +} + +export interface UseDatasetManagerResult { + managerState: DownloadManagerStatus; + datasetStatus: Record; + storageEstimate: StorageEstimate | null; + isLoading: boolean; + cachedCount: number; + totalCount: number; + downloadSizeNeededMB: number; + startDownload: () => void; + refresh: () => Promise; + clearCache: () => Promise; +} + +export function useDatasetManager(): UseDatasetManagerResult { + const [managerState, setManagerState] = useState( + DownloadManagerStatus.IDLE + ); + const [datasetStates, setDatasetStates] = useState>({}); + const [idle, setIdle] = useState(false); + const [worker] = useState(new Geant4Worker()); + const initCalledRef = useRef(false); + + const [storageEstimate, setStorageEstimate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [cachedCount, setCachedCount] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [downloadSizeNeededMB, setDownloadSizeNeededMB] = useState(0); + + const refresh = useCallback(async () => { + setIsLoading(true); + + try { + const [status, storage] = await Promise.all([ + checkAllDatasetsCacheStatus(), + getStorageEstimate() + ]); + + const cachedCount = status?.cachedCount ?? 0; + const totalCount = status?.totalCount ?? 0; + const downloadSizeNeededMB = + cachedCount === totalCount + ? 0 + : TOTAL_DATASET_SIZE_MB - (status?.estimatedCachedSizeMB ?? 0); + + setCachedCount(cachedCount); + setTotalCount(totalCount); + setDownloadSizeNeededMB(downloadSizeNeededMB); + setStorageEstimate(storage); + setDatasetStates(prev => { + const newStates: Record = { ...prev }; + + for (const ds of GEANT4_DATASETS) { + newStates[ds.name] = { + ...newStates[ds.name], + + name: ds.name, + status: newStates[ds.name]?.status ?? DatasetDownloadStatus.IDLE, + totalSizeMB: ds.approximateSizeMB, + cached: status.datasets[ds.name]?.isCached ?? false + }; + } + + return newStates; + }); + } catch (error) { + console.error('Failed to check dataset cache status:', error); + } finally { + setIsLoading(false); + } + }, []); + + const clearCache = useCallback(async () => { + const success = await clearDatasetCache(); + + if (success) { + if (!worker.getIsInitialized()) { + await worker.init(); + } + + setIdle(true); + setManagerState(DownloadManagerStatus.IDLE); + setDatasetStates({}); + + await refresh(); + } + + return success; + }, [refresh, worker]); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => { + if (initCalledRef.current) return; + worker + .init() + .then(() => { + setIdle(true); + }) + .catch(error => { + setManagerState(DownloadManagerStatus.ERROR); + console.error('Failed to initialize Geant4 worker for dataset download:', error); + }); + initCalledRef.current = true; + }, [worker]); + + const startDownloadSimple = useCallback(() => { + if (!idle) return; + if (!worker.getIsInitialized()) return; + + startDownload({ worker, managerState, setManagerState, setDatasetStates, setIdle }); + }, [worker, idle, managerState]); + + return { + managerState, + datasetStatus: datasetStates, + storageEstimate, + isLoading, + cachedCount, + totalCount, + downloadSizeNeededMB, + refresh, + clearCache, + startDownload: startDownloadSimple + }; +} diff --git a/src/Geant4Worker/Geant4Worker.ts b/src/Geant4Worker/Geant4Worker.ts index 2ebe3b1fa..1961241ab 100644 --- a/src/Geant4Worker/Geant4Worker.ts +++ b/src/Geant4Worker/Geant4Worker.ts @@ -157,6 +157,8 @@ export default class Geant4Worker { destroy() { this.worker?.terminate(); this.worker = undefined; + this.isInitialized = false; + this.depsLoaded = false; } async loadDeps() { diff --git a/src/Geant4Worker/Geant4WorkerCallbacks.ts b/src/Geant4Worker/Geant4WorkerCallbacks.ts index b6c18bba8..9ba694733 100644 --- a/src/Geant4Worker/Geant4WorkerCallbacks.ts +++ b/src/Geant4Worker/Geant4WorkerCallbacks.ts @@ -47,17 +47,17 @@ const initDatasets: Geant4WorkerCallbacksType = async args => { // This, in theory, could enable the app to run offline. try { downloadTracker.reset(); - downloadTracker.setCurrentDataset('G4ENSDFSTATE'); + downloadTracker.setCurrentDataset('G4ENSDFSTATE3.0'); await initG4ENSDFSTATE(wasmModule); - downloadTracker.setCurrentDataset('G4EMLOW'); + downloadTracker.setCurrentDataset('G4EMLOW8.6.1'); await initG4EMLOW(wasmModule); - downloadTracker.setCurrentDataset('G4NDL'); + downloadTracker.setCurrentDataset('G4NDL4.7.1'); await initG4NDL(wasmModule); - downloadTracker.setCurrentDataset('G4PARTICLEXS'); + downloadTracker.setCurrentDataset('G4PARTICLEXS4.1'); await initG4PARTICLEXS(wasmModule); - downloadTracker.setCurrentDataset('G4SAIDDATA'); + downloadTracker.setCurrentDataset('G4SAIDDATA2.0'); await initG4SAIDDATA(wasmModule); - downloadTracker.setCurrentDataset('PhotonEvaporation'); + downloadTracker.setCurrentDataset('PhotonEvaporation6.1'); await initPhotoEvaporation(wasmModule); } catch (error: unknown) { console.error('Error initializing lazy files:', (error as Error).message); diff --git a/src/WrapperApp/WrapperApp.tsx b/src/WrapperApp/WrapperApp.tsx index fba847f76..b49bbbdc1 100644 --- a/src/WrapperApp/WrapperApp.tsx +++ b/src/WrapperApp/WrapperApp.tsx @@ -3,7 +3,6 @@ import { styled } from '@mui/material/styles'; import { SyntheticEvent, useEffect, useState } from 'react'; import { useConfig } from '../config/ConfigService'; -import { useDatasetDownloadManager } from '../Geant4Worker/Geant4DatasetDownloadManager'; import { useAuth } from '../services/AuthService'; import { useStore } from '../services/StoreService'; import { EditorToolbar } from '../ThreeEditor/components/Editor/EditorToolbar'; @@ -66,12 +65,6 @@ function WrapperApp() { const [providedInputFiles, setProvidedInputFiles] = useState(); const [highlightRunForm, setHighLightRunForm] = useState(false); - const { - managerState: geant4DownloadManagerState, - datasetStates: geant4DatasetStates, - startDownload: geant4DatasetDownload - } = useDatasetDownloadManager(); - useEffect(() => { if (Object.keys(providedInputFiles ?? {}).length > 0) { setHighLightRunForm(true); @@ -240,9 +233,6 @@ function WrapperApp() { clearInputFiles={() => setProvidedInputFiles(undefined)} runSimulation={runSimulation} setSource={setSimulationPanelPresentedSimulationsSource} - geant4DownloadManagerState={geant4DownloadManagerState} - geant4DatasetDownloadStart={geant4DatasetDownload} - geant4DatasetStates={geant4DatasetStates} geant4DatasetType={geant4DatasetType} setGeant4DatasetType={setGeant4DatasetType} /> diff --git a/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx b/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx index 51f10f585..4027aa479 100644 --- a/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx +++ b/src/WrapperApp/components/Simulation/Geant4DatasetDownload.tsx @@ -1,25 +1,34 @@ // Full credits to @kmichalik +import CachedIcon from '@mui/icons-material/Cached'; import CheckIcon from '@mui/icons-material/Check'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import StorageIcon from '@mui/icons-material/Storage'; import { AccordionDetails, AccordionSummary, Box, Button, + Chip, + CircularProgress, LinearProgress, ToggleButton, + Tooltip, Typography, useTheme } from '@mui/material'; -import { useState } from 'react'; +import { JSX, useEffect, useState } from 'react'; import { DatasetDownloadStatus, DatasetStatus, DownloadManagerStatus -} from '../../../Geant4Worker/Geant4DatasetDownloadManager'; +} from '../../../Geant4Worker/Geant4DatasetManager'; import { useDialog } from '../../../services/DialogService'; +import { useSharedDatasetManager } from '../../../services/Geant4DatasetContextProvider'; import StyledAccordion from '../../../shared/components/StyledAccordion'; import { StyledExclusiveToggleButtonGroup } from '../../../shared/components/StyledExclusiveToggleButtonGroup'; @@ -28,45 +37,178 @@ export enum Geant4DatasetsType { FULL } -export interface Geant4DatasetsProps { - geant4DownloadManagerState: DownloadManagerStatus; - geant4DatasetStates: DatasetStatus[]; - geant4DatasetDownloadStart: () => void; - geant4DatasetType: Geant4DatasetsType; - setGeant4DatasetType: (type: Geant4DatasetsType) => void; -} - function DatasetCurrentStatus(props: { status: DatasetStatus }) { const { status } = props; + const idleIcon = status.cached ? ( + + ) : ( + + ); + + const datasetStatusIcon: Map = new Map([ + [DatasetDownloadStatus.IDLE, idleIcon], + [DatasetDownloadStatus.DOWNLOADING, ], + [DatasetDownloadStatus.PROCESSING, ], + [DatasetDownloadStatus.DONE, ] + ]); + + const datasetProgressBar: Map = new Map([ + [ + DatasetDownloadStatus.DOWNLOADING, + + ], + [ + DatasetDownloadStatus.PROCESSING, + + ] + ]); + return ( {status.name} - {status.status === DatasetDownloadStatus.DONE && } + {datasetStatusIcon.get(status.status)} - - {status.status === DatasetDownloadStatus.DOWNLOADING && ( - - )} - {status.status === DatasetDownloadStatus.PROCESSING && ( - - )} - {status.status === DatasetDownloadStatus.IDLE && ( - {datasetProgressBar.get(status.status)} + + ); +} + +enum DatasetChipType { + CACHED, + PARTIALLY_CACHED, + NOT_CACHED +} + +type DatasetCachedChipInfo = { + title: string; + chip: { + icon: JSX.Element; + label: string; + color: 'primary' | 'warning' | 'error'; + }; +}; + +type DatasetCachedChipProps = { + cachedCount: number; + totalCount: number; + downloadSizeNeededMB: number; + type: DatasetChipType; +}; + +function DatasetCachedChip(props: DatasetCachedChipProps) { + const { cachedCount, totalCount, downloadSizeNeededMB, type } = props; + + const chipPropsPerStatus: Record = { + [DatasetChipType.CACHED]: { + title: 'All datasets are cached in your browser. Loading will be quick!', + chip: { + icon: , + label: 'Cached', + color: 'primary' + } + }, + [DatasetChipType.PARTIALLY_CACHED]: { + title: `${cachedCount} of ${totalCount} datasets cached. ~${downloadSizeNeededMB.toFixed(0)} MB needs to be downloaded.`, + chip: { + icon: , + label: `Partially cached (${cachedCount}/${totalCount})`, + color: 'warning' + } + }, + [DatasetChipType.NOT_CACHED]: { + title: `No datasets cached. ~${downloadSizeNeededMB.toFixed(0)} MB will be downloaded from S3.`, + chip: { + icon: , + label: 'Not cached', + color: 'error' + } + } + }; + + return ( + + + + ); +} + +function CacheStatusIndicator() { + const { isLoading, cachedCount, totalCount, downloadSizeNeededMB, storageEstimate, refresh } = + useSharedDatasetManager(); + const allCached = cachedCount === totalCount; + + if (isLoading) { + return ( + + + + Checking cache status... + + + ); + } + + return ( + + + 0 + ? DatasetChipType.PARTIALLY_CACHED + : DatasetChipType.NOT_CACHED + } + /> + + + } + label='Refresh' + size='small' + variant='outlined' + onClick={refresh} + sx={{ cursor: 'pointer', px: 1 }} /> - )} + + {storageEstimate && ( + + + {cachedCount > 0 && `${storageEstimate.usedMB.toFixed(0)} MB used`} + {!allCached && + (cachedCount > 0 + ? `, ~${downloadSizeNeededMB.toFixed(0)} MB remaining to download` + : `~${downloadSizeNeededMB.toFixed(0)} MB download required (may take several minutes)`)} + + + )} ); } @@ -135,10 +277,44 @@ export function Geant4DatasetDownloadSelector(props: { ); } -export function Geant4Datasets(props: Geant4DatasetsProps) { +export function Geant4Datasets() { const theme = useTheme(); - const { geant4DownloadManagerState, geant4DatasetStates, geant4DatasetDownloadStart } = props; - const [open, setOpen] = useState(true); + const { + managerState: geant4DownloadManagerState, + datasetStatus, + startDownload: geant4DatasetDownloadStart, + cachedCount, + totalCount, + refresh, + clearCache + } = useSharedDatasetManager(); + const [open, setOpen] = useState(geant4DownloadManagerState !== DownloadManagerStatus.FINISHED); + + const allCached = cachedCount === totalCount; + + // Refresh cache status when download finishes + useEffect(() => { + if (geant4DownloadManagerState === DownloadManagerStatus.FINISHED) { + // Add a small delay to ensure IndexedDB is updated + setOpen(false); + setTimeout(() => { + refresh(); + }, 1000); + } + }, [geant4DownloadManagerState, refresh]); + + const buttonText = allCached ? 'Load from cache' : 'Start download'; + const buttonIcon = allCached ? : ; + + const enableDownloadButton = geant4DownloadManagerState === DownloadManagerStatus.IDLE; + const enableClearButton = + geant4DownloadManagerState === DownloadManagerStatus.IDLE || + geant4DownloadManagerState === DownloadManagerStatus.FINISHED; + + const showDownloadProgress = + geant4DownloadManagerState === DownloadManagerStatus.WORKING || + geant4DownloadManagerState === DownloadManagerStatus.FINISHED || + cachedCount > 0; return ( - {geant4DownloadManagerState === DownloadManagerStatus.IDLE && ( + + + {showDownloadProgress && + Object.values(datasetStatus).map(status => ( + + ))} + + - )} - {geant4DatasetStates.map(status => ( - - ))} + {cachedCount > 0 && ( + + )} + + {geant4DownloadManagerState === DownloadManagerStatus.ERROR && ( Failed to download datasets. Please check your connection and try again. diff --git a/src/WrapperApp/components/Simulation/Modal/DatasetsFullInfoModal.tsx b/src/WrapperApp/components/Simulation/Modal/DatasetsFullInfoModal.tsx index 3da546abc..14af44aaa 100644 --- a/src/WrapperApp/components/Simulation/Modal/DatasetsFullInfoModal.tsx +++ b/src/WrapperApp/components/Simulation/Modal/DatasetsFullInfoModal.tsx @@ -9,7 +9,9 @@ import { TableHead, TableRow } from '@mui/material'; +import { useEffect } from 'react'; +import { useSharedDatasetManager } from '../../../../services/Geant4DatasetContextProvider'; import { ConcreteDialogProps, CustomDialog @@ -42,13 +44,19 @@ const datasetSummaries = [ 'Data evaluated from the SAID database for proton, neutron, pion inelastic, elastic, and charge exchange cross sections of nucleons below 3 GeV' }, { - name: 'G4PhotonEvaporation6.1', + name: 'PhotonEvaporation6.1', description: 'Data for photon emission and internal conversion following nuclear de-excitation, used by the photon evaporation model in radioactive decay and photo-nuclear processes.' } ]; export function DatasetsFullInfoDialog({ onClose }: ConcreteDialogProps) { + const { datasetStatus, refresh } = useSharedDatasetManager(); + + useEffect(() => { + refresh(); + }, [refresh]); + return ( - Currently there is no indicator showing if you have already downloaded the - datasets. Each time you start a new simulation with FULL datasets option, - you have to press 'Start Download' button. If the datasets are already in - your browser storage, the datasets will be loaded from cache. + sx={{ mt: 1 }}> + Simulation input data and results may be removed if you close the browser + tab or restart your computer. In contrast, Geant4 datasets stored in your + browser's cache (IndexedDB) are saved on your drive and persist across + sessions. - Total size of downloaded datasets is about 2GB. Each time you clear your - browser data, you will need to re-download these datasets for optimal - simulation performance. + sx={{ mt: 1 }}> + If you clear your browser data (for example, using antivirus software or PC + cleanup tools), you'll need to download these datasets again to ensure + optimal simulation performance. + sx={{ mt: 1 }}> Name + Cached Description @@ -97,6 +105,9 @@ export function DatasetsFullInfoDialog({ onClose }: ConcreteDialogProps) { 'verticalAlign': 'top' }}> {ds.name} + + {datasetStatus[ds.name]?.cached ? 'Yes' : 'No'} + {ds.description} ))} diff --git a/src/WrapperApp/components/Simulation/RunSimulationForm.tsx b/src/WrapperApp/components/Simulation/RunSimulationForm.tsx index 6587bbaa0..da01d4cf2 100644 --- a/src/WrapperApp/components/Simulation/RunSimulationForm.tsx +++ b/src/WrapperApp/components/Simulation/RunSimulationForm.tsx @@ -21,8 +21,9 @@ import { import Typography from '@mui/material/Typography'; import { MouseEvent, SyntheticEvent, useEffect, useState } from 'react'; -import { DownloadManagerStatus } from '../../../Geant4Worker/Geant4DatasetDownloadManager'; +import { DownloadManagerStatus } from '../../../Geant4Worker/Geant4DatasetManager'; import { usePythonConverter } from '../../../PythonConverter/PythonConverterService'; +import { useSharedDatasetManager } from '../../../services/Geant4DatasetContextProvider'; import { useStore } from '../../../services/StoreService'; import StyledAccordion from '../../../shared/components/StyledAccordion'; import { StyledExclusiveToggleButtonGroup } from '../../../shared/components/StyledExclusiveToggleButtonGroup'; @@ -76,7 +77,6 @@ export type RunSimulationFormProps = { clearInputFiles?: () => void; runSimulation?: RunSimulationFunctionType; setSource?: (source: SimulationFetchSource) => void; - geant4DownloadManagerState: DownloadManagerStatus; geant4DatasetType: Geant4DatasetsType; setGeant4DatasetType: (type: Geant4DatasetsType) => void; }; @@ -89,7 +89,6 @@ export function RunSimulationForm({ clearInputFiles = () => {}, runSimulation = () => {}, setSource = () => {}, - geant4DownloadManagerState, geant4DatasetType, setGeant4DatasetType }: RunSimulationFormProps) { @@ -193,6 +192,7 @@ export function RunSimulationForm({ const [selectedScriptParamsTab, setSelectedScriptParamsTab] = useState(0); const { isConverterReady } = usePythonConverter(); const [runPreconditionsMet, setRunPreconditionsMet] = useState(false); + const { managerState: geant4DownloadManagerState } = useSharedDatasetManager(); useEffect(() => { const runFormIsValid = !isNaN(nTasks) && !isNaN(overridePrimariesCount); diff --git a/src/WrapperApp/components/Simulation/RunSimulationPanel.tsx b/src/WrapperApp/components/Simulation/RunSimulationPanel.tsx index 8833d0e13..fa6d56b28 100644 --- a/src/WrapperApp/components/Simulation/RunSimulationPanel.tsx +++ b/src/WrapperApp/components/Simulation/RunSimulationPanel.tsx @@ -7,11 +7,11 @@ import { useAuth } from '../../../services/AuthService'; import { useStore } from '../../../services/StoreService'; import StyledAccordion from '../../../shared/components/StyledAccordion'; import { SimulatorNames, SimulatorType } from '../../../types/RequestTypes'; -import { Geant4Datasets, Geant4DatasetsProps, Geant4DatasetsType } from './Geant4DatasetDownload'; +import { Geant4Datasets, Geant4DatasetsType } from './Geant4DatasetDownload'; import RecentSimulations from './RecentSimulations'; import { RunSimulationForm, RunSimulationFormProps } from './RunSimulationForm'; -export type RunSimulationPanelProps = RunSimulationFormProps & Geant4DatasetsProps; +export type RunSimulationPanelProps = RunSimulationFormProps; export default function RunSimulationPanel(props: RunSimulationPanelProps) { const theme = useTheme(); @@ -27,9 +27,7 @@ export default function RunSimulationPanel(props: RunSimulationPanelProps) { <> {yaptideEditor?.contextManager.currentSimulator === SimulatorType.GEANT4 && - props.geant4DatasetType === Geant4DatasetsType.FULL && ( - - )} + props.geant4DatasetType === Geant4DatasetsType.FULL && } ) : ( diff --git a/src/services/Geant4DatasetContextProvider.tsx b/src/services/Geant4DatasetContextProvider.tsx new file mode 100644 index 000000000..da1a125d1 --- /dev/null +++ b/src/services/Geant4DatasetContextProvider.tsx @@ -0,0 +1,17 @@ +import { useDatasetManager, UseDatasetManagerResult } from '../Geant4Worker/Geant4DatasetManager'; +import { createGenericContext, GenericContextProviderProps } from './GenericContext'; + +const [useSharedDatasetManager, InnerDatasetManagerContextProvider] = + createGenericContext(); + +function Geant4DatasetContextProvider({ children }: GenericContextProviderProps) { + const datasetManager = useDatasetManager(); + + return ( + + {children} + + ); +} + +export { Geant4DatasetContextProvider, useSharedDatasetManager };