/** * Module Loader System * * Handles loading and initializing modules in the correct order, * with dependency management and progress reporting. */ import { moduleRegistry } from './module-registry.js'; import { ModuleEvent } from './base-module.js'; // Ensure moduleRegistry is available globally before anything else runs window.moduleRegistry = moduleRegistry; console.log('Module registry initialized and assigned to window.moduleRegistry'); /** * Module States */ const ModuleState = { PENDING: 'PENDING', LOADING: 'LOADING', FETCHING: 'FETCHING', // Added new state for fetching resources WAITING: 'WAITING', INITIALIZING: 'INITIALIZING', FINISHED: 'FINISHED', ERROR: 'ERROR' }; const MODULE_CACHE_BUSTER = '20260610-book-timeline-a'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** * Module Loader - Manages the loading of all modules */ const ModuleLoader = (function() { // Private variables let loadingOverlay = null; let modulesList = null; let progressIndicator = null; let progressText = null; let statusText = null; let isLoadingComplete = false; let moduleWeights = {}; let moduleProgress = {}; let createdModules = new Set(); // Track which modules we've created UI elements for let gameLoopModule = null; // Add variable to hold game loop instance let moduleTimings = {}; // Track timing data for modules let finalizationTimer = null; let moduleExitAnimations = new Map(); /** * Initialize the loader */ function init() { // Prevent duplicate initialization if (createdModules.size > 0) { console.warn('Module Loader already initialized'); return; } console.log('Module Loader: Initialization started'); // Create the loading overlay createLoadingOverlay(); // Setup event listeners setupEventListeners(); // Load available module scripts loadModuleScripts().then(() => { // Once scripts are loaded, initialize modules initializeModules().catch(error => { console.error('Module Loader: Initialization failed:', error); finalizeLoading(); }); }); } /** * Setup event listeners for module communication */ function setupEventListeners() { // Listen for module state change events document.addEventListener('module:stateChange', handleModuleStateChange); // Listen for module status message events document.addEventListener('module:message', handleModuleMessage); // Listen for module progress events document.addEventListener('module:progress', handleModuleProgress); } /** * Load all module scripts * @returns {Promise} - Resolves when all module scripts are loaded */ async function loadModuleScripts() { // Define dependency scripts that need to be loaded first but aren't modules themselves const dependenciesToLoad = [ { script: '/js/api-tts-module-base.js' }, // Abstract base class, not a module { script: '/js/tts-handler-module.js' } // Abstract base class for TTS handlers, not a module ]; // Define modules with their weights let modulesToLoad = [ // Core functionality modules { id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 }, { id: 'localization', script: '/js/localization-module.js', weight: 12 }, { id: 'story-history', script: '/js/story-history-module.js', weight: 8 }, { id: 'game-config', script: '/js/game-config-module.js', weight: 8 }, { id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 }, { id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 }, { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 }, { id: 'webgl-page-cache', script: '/js/webgl-page-cache-module.js', weight: 5 }, { id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 }, { id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS { id: 'book-playback-timeline', script: '/js/book-playback-timeline-module.js', weight: 8 }, // Audio and TTS modules { id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 }, { id: 'kokoro-tts', script: '/js/kokoro-tts-module.js', weight: 50 }, { id: 'browser-tts', script: '/js/browser-tts-module.js', weight: 12 }, { id: 'elevenlabs-tts', script: '/js/elevenlabs-tts-module.js', weight: 12 }, { id: 'openai-tts', script: '/js/openai-tts-module.js', weight: 12 }, { id: 'local-openai-tts', script: '/js/local-openai-tts-module.js', weight: 12 }, { id: 'tts-factory', script: '/js/tts-factory-module.js', weight: 13 }, // TTSFactory must be loaded before TTSPlayer // UI and interaction modules { id: 'text-buffer', script: '/js/text-buffer-module.js', weight: 12 }, { id: 'ui-effects', script: '/js/ui-effects-module.js', weight: 12 }, // Add UI Effects module { id: 'ui-input-handler', script: '/js/ui-input-handler-module.js', weight: 27 }, // Add UI Input Handler module { id: 'ui-display-handler', script: '/js/ui-display-handler-module.js', weight: 27 }, // Add UI Display Handler module { id: 'choice-display', script: '/js/choice-display-module.js', weight: 8 }, { id: 'ui-controller', script: '/js/ui-controller-module.js', weight: 27 }, { id: 'options-ui', script: '/js/options-ui-module.js', weight: 13 }, { id: 'socket-client', script: '/js/socket-client-module.js', weight: 17 }, // Main game module - should be last to load { id: 'game-loop', script: '/js/game-loop-module.js', weight: 27 } ]; // Store module weights for progress calculation modulesToLoad.forEach(module => { moduleWeights[module.id] = module.weight; moduleProgress[module.id] = 0; }); // Create a module list entry for each module modulesToLoad.forEach(module => { createModuleItem(module.id, getModuleNameFromId(module.id)); }); // Load dependencies first const loadDependencies = dependenciesToLoad.map(dependency => loadScript(dependency.script)); await Promise.all(loadDependencies); // Load each module script const loadPromises = modulesToLoad.map(module => loadScript(module.script)); const loadResult = await Promise.all(loadPromises); // Wait briefly for modules to register await new Promise(resolve => setTimeout(resolve, 100)); // Analyze dependencies and detect circular references analyzeModuleDependencies(); return loadResult; } /** * Analyze module dependencies to detect circular references and print detailed diagnostics */ function analyzeModuleDependencies() { const registry = window.moduleRegistry; if (!registry || !registry.modules) { console.error("Module Registry not available for dependency analysis"); return; } // Build dependency graph const graph = {}; // Initialize the graph with all modules Object.keys(registry.modules).forEach(moduleId => { graph[moduleId] = []; }); // Add dependencies to graph Object.entries(registry.modules).forEach(([moduleId, module]) => { if (module.dependencies && Array.isArray(module.dependencies)) { module.dependencies.forEach(depId => { // Check if dependency exists if (!registry.modules[depId]) { console.warn(`Module ${moduleId} depends on missing module ${depId}`); } else { graph[moduleId].push(depId); } }); } }); // Detect circular dependencies using DFS const detectCycles = () => { const visited = {}; const recStack = {}; const cycles = []; const pathStack = []; const dfs = (node, path = []) => { // Node is already in recursion stack - we found a cycle if (recStack[node]) { const cycleStart = path.indexOf(node); if (cycleStart !== -1) { const cycle = path.slice(cycleStart).concat(node); cycles.push(cycle); return true; } } // If already visited and not in recursion, no cycle through this node if (visited[node]) { return false; } // Mark node as visited and add to recursion stack visited[node] = true; recStack[node] = true; pathStack.push(node); // Visit all neighbors const hasCycle = graph[node].some(neighbor => { return dfs(neighbor, [...pathStack]); }); // Remove from recursion stack and path recStack[node] = false; pathStack.pop(); return hasCycle; }; // Start DFS from each node Object.keys(graph).forEach(node => { if (!visited[node]) { dfs(node); } }); return cycles; }; // Find all circular dependencies const cycles = detectCycles(); // Display detailed information about circular dependencies if (cycles.length > 0) { console.group("%cCircular Dependencies Detected", "color: red; font-weight: bold"); cycles.forEach((cycle, index) => { console.log(`%cCircular Dependency Chain ${index + 1}:`, "font-weight: bold"); // Print the cycle with dependency details cycle.forEach((moduleId, i) => { const module = registry.modules[moduleId] || { name: 'Unknown' }; const nextModuleId = cycle[(i + 1) % cycle.length]; const nextModule = registry.modules[nextModuleId] || { name: 'Unknown' }; console.log( `%c${moduleId}%c (${module.name}) depends on %c${nextModuleId}%c (${nextModule.name})`, "color: blue; font-weight: bold", "color: black", "color: blue; font-weight: bold", "color: black" ); // Print the actual dependencies declared by this module if (module.dependencies && Array.isArray(module.dependencies)) { console.log(` Dependencies declared by ${moduleId}:`, module.dependencies); } }); // Suggest potential solutions console.log('%cPossible solutions:', 'font-weight: bold'); console.log('1. Remove one of the dependencies from the chain'); console.log('2. Use dynamic dependency resolution instead of static dependencies'); console.log('3. Create an interface module that both modules depend on'); console.log('4. Refactor module responsibilities to eliminate circular needs'); }); console.groupEnd(); } else { console.log("%cNo circular dependencies detected", "color: green; font-weight: bold"); } // Calculate an optimized loading order using topological sort const calculateOptimalLoadOrder = () => { const result = []; const visited = {}; const temp = {}; // Temporary marks for detecting cycles const visit = (node) => { // Node is already in result, skip if (visited[node]) return; // If temp is true, we have a cycle if (temp[node]) { console.warn(`Skipping cycle involving ${node} during topological sort`); return; } // Mark node as being processed temp[node] = true; // Process all dependencies first if (graph[node]) { graph[node].forEach(dep => visit(dep)); } // Mark as visited and add to result temp[node] = false; visited[node] = true; result.push(node); }; // Visit all nodes Object.keys(graph).forEach(node => { if (!visited[node]) { visit(node); } }); return result; // Dependencies are pushed before dependents. }; const optimalOrder = calculateOptimalLoadOrder(); // Print the optimal loading order console.group("%cOptimal Module Loading Order", "color: green; font-weight: bold"); optimalOrder.forEach((moduleId, index) => { const module = registry.modules[moduleId] || { name: 'Unknown' }; console.log(`${index + 1}. %c${moduleId}%c (${module.name})`, "font-weight: bold", "font-weight: normal"); }); console.groupEnd(); // Compare with actual loading order and suggest improvements console.log("%cRecommended changes to module loading order in loader.js:", "font-weight: bold"); if (optimalOrder.length > 0) { console.log("const modulesToLoad = ["); optimalOrder.forEach((moduleId, index) => { // Generate a reasonable path based on the moduleId const scriptPath = `/js/${moduleId}.js`; console.log(` { id: '${moduleId}', script: '${scriptPath}', weight: ${index * 5 + 5} },`); }); console.log("];"); } } /** * Sort modules by their dependencies to create an optimal loading order * This function can be used before initialization to ensure modules are loaded in the correct order * @param {Array} modules - Array of module objects with id and dependencies * @returns {Array} - Sorted array of modules */ function sortModulesByDependencies(modules) { // Build a dependency graph const graph = {}; // Initialize the graph with all modules modules.forEach(module => { graph[module.id] = { module, dependencies: [] }; }); // Add dependencies to the graph // We need to do this in a second pass because some modules might reference others that come later in the array modules.forEach(module => { if (module.dependencies && Array.isArray(module.dependencies)) { module.dependencies.forEach(depId => { if (graph[depId]) { graph[module.id].dependencies.push(depId); } else { console.warn(`Module ${module.id} depends on unknown module ${depId}`); } }); } }); // Perform a topological sort const result = []; const visited = {}; const temp = {}; // For cycle detection function visit(nodeId) { // Node is already in result, skip if (visited[nodeId]) return; // If temp is true, we have a cycle if (temp[nodeId]) { console.warn(`Circular dependency detected involving ${nodeId}. Skipping.`); return; } // Mark node as being processed temp[nodeId] = true; // Process all dependencies first if (graph[nodeId] && graph[nodeId].dependencies) { graph[nodeId].dependencies.forEach(depId => { visit(depId); }); } // Mark as visited and add to result temp[nodeId] = false; visited[nodeId] = true; result.push(graph[nodeId].module); } // Visit all nodes Object.keys(graph).forEach(nodeId => { if (!visited[nodeId]) { visit(nodeId); } }); return result; // Dependencies are pushed before dependents. } /** * Load a script dynamically * @param {string} src - Script source URL * @returns {Promise} - Resolves when script is loaded */ function loadScript(src) { // Extract module ID from src path const moduleId = src.split('/').pop().replace('.js', ''); // Update state to LOADING if this is a module if (moduleId && moduleWeights[moduleId]) { // Ensure module item exists in UI const moduleItem = document.getElementById(`module-${moduleId}`); if (!moduleItem) { createModuleItem(moduleId, getModuleNameFromId(moduleId)); } // Set initial progress to 0% handleModuleProgress({ detail: { moduleId, progress: 0 } }); // Set state to loading updateModuleState(moduleId, ModuleState.LOADING); // Record start time for this module (for timing data) if (!moduleTimings[moduleId]) { moduleTimings[moduleId] = {}; } moduleTimings[moduleId].startTime = performance.now(); } return new Promise((resolve, reject) => { const script = document.createElement('script'); script.type = 'module'; const separator = src.includes('?') ? '&' : '?'; script.src = `${src}${separator}v=${MODULE_CACHE_BUSTER}`; // Monitor loading progress using a fake progress indicator (0-10%) if (moduleId && moduleWeights[moduleId]) { let loadProgress = 0; const progressInterval = setInterval(() => { loadProgress = Math.min(loadProgress + 1, 9); // Max 9% until actual load completes handleModuleProgress({ detail: { moduleId, progress: loadProgress } }); }, 100); script.onload = () => { clearInterval(progressInterval); // Final progress at 10% when script is loaded handleModuleProgress({ detail: { moduleId, progress: 10 } }); // Record script load complete time if (moduleTimings[moduleId]) { moduleTimings[moduleId].scriptLoadTime = performance.now(); } resolve(); }; script.onerror = (error) => { clearInterval(progressInterval); updateModuleState(moduleId, ModuleState.ERROR); // Record error time if (moduleTimings[moduleId]) { moduleTimings[moduleId].errorTime = performance.now(); } reject(new Error(`Failed to load script: ${src}`)); }; } else { script.onload = () => resolve(); script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); } document.head.appendChild(script); }); } /** * Initialize all registered modules */ async function initializeModules() { const modules = moduleRegistry.getAllModules(); // Find the game loop module instance gameLoopModule = moduleRegistry.getModule('game-loop'); // Log dependency information for debugging console.log('Module dependencies:'); Object.keys(modules).forEach(moduleId => { const dependencies = moduleRegistry.getDependencies(moduleId); console.log(`${moduleId} depends on: ${dependencies.length ? dependencies.join(', ') : 'none'}`); }); const moduleList = Object.values(modules); const initializationOrder = sortModulesByDependencies(moduleList); console.group('%cActual Module Initialization Order', 'color: green; font-weight: bold'); initializationOrder.forEach((module, index) => { console.log(`${index + 1}. ${module.id} (${module.name})`); }); console.groupEnd(); // Initialize in dependency order. This makes the loader guarantee that // by the time the overlay disappears, every module has run its own // initialize() after its declared dependencies are available. for (const module of initializationOrder) { try { // Create a progress callback for this module const progressCallback = (percent, message) => { handleModuleProgress({ detail: { moduleId: module.id, progress: percent } }); if (message) { handleModuleMessage({ detail: { moduleId: module.id, message } }); } }; // Log start of initialization console.log(`Starting initialization of module: ${module.id}`); // Initialize the module with progress callback await module.initializeInterface(progressCallback); // Log completion of initialization console.log(`Completed initialization of module: ${module.id}`); } catch (error) { console.error(`Error initializing module ${module.id}:`, error); } if (module.id === 'game-loop') { gameLoopModule = module; } } checkAllFinished(); } /** * Get a human-readable module name from its ID * @param {string} id - Module ID * @returns {string} - User-friendly module name */ function getModuleNameFromId(id) { // Convert kebab-case to Title Case return id .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Create the loading overlay */ function createLoadingOverlay() { // Find existing elements or create minimal ones for progress reporting loadingOverlay = document.querySelector('.loading-overlay'); if (!loadingOverlay) { // If no overlay exists in the HTML, create a minimal one loadingOverlay = document.createElement('div'); loadingOverlay.className = 'loading-overlay'; loadingOverlay.style.backgroundColor = '#000'; loadingOverlay.style.transition = 'opacity 0.5s ease-out'; document.body.appendChild(loadingOverlay); const content = document.createElement('div'); content.className = 'loading-content'; const title = document.createElement('h2'); title.textContent = 'Loading Interface'; content.appendChild(title); const progressBar = document.createElement('div'); progressBar.className = 'loading-bar'; progressIndicator = document.createElement('div'); progressIndicator.className = 'loading-progress'; progressBar.appendChild(progressIndicator); progressText = document.createElement('div'); progressText.className = 'loading-text'; progressText.textContent = '0%'; progressBar.appendChild(progressText); statusText = document.createElement('div'); statusText.className = 'loading-status'; statusText.textContent = 'Loading modules...'; modulesList = document.createElement('ul'); modulesList.id = 'modules-list'; content.appendChild(progressBar); content.appendChild(statusText); content.appendChild(modulesList); loadingOverlay.appendChild(content); } else { // If overlay exists, find the progress elements progressIndicator = loadingOverlay.querySelector('.loading-progress'); progressText = loadingOverlay.querySelector('.loading-text'); statusText = loadingOverlay.querySelector('.loading-status'); modulesList = loadingOverlay.querySelector('#modules-list'); // Ensure transition is set loadingOverlay.style.backgroundColor = '#000'; loadingOverlay.style.transition = 'opacity 0.5s ease-out'; } } /** * Create a module list item * @param {string} id - Module ID * @param {string} name - Module name * @returns {HTMLLIElement} List item element */ function createModuleItem(id, name) { if (!modulesList || createdModules.has(id)) return null; createdModules.add(id); // Create elements dynamically const li = document.createElement('li'); li.id = `module-${id}`; li.className = 'module-item'; // Set initial progress to 0 using CSS variable li.style.setProperty('--progress-width', '0%'); // Create module name element const moduleName = document.createElement('div'); moduleName.className = 'module-name'; moduleName.textContent = name; // Create module status element const moduleStatus = document.createElement('div'); moduleStatus.className = 'module-status status-pending'; moduleStatus.textContent = 'Pending'; // Create module status details element const moduleDetails = document.createElement('div'); moduleDetails.className = 'module-status-detail'; moduleDetails.textContent = ''; // Append all elements to the list item li.appendChild(moduleName); li.appendChild(moduleStatus); li.appendChild(moduleDetails); // Force a reflow to ensure animation works void li.offsetWidth; // Add to modules list modulesList.appendChild(li); return li; } /** * Handle module progress events */ function handleModuleProgress(event) { const { moduleId, progress } = event.detail; const numericProgress = Math.min(100, Math.max(0, Number(progress) || 0)); const previousProgress = Number(moduleProgress[moduleId] || 0); const nextProgress = Math.max(previousProgress, numericProgress); moduleProgress[moduleId] = nextProgress; // Get the module element const moduleItem = document.querySelector(`#module-${moduleId}`); if (moduleItem) { // Update module item's before pseudo-element width using CSS variable moduleItem.style.setProperty('--progress-width', `${nextProgress}%`); // Also set a data attribute for browsers that don't support CSS variables moduleItem.setAttribute('data-progress', nextProgress); } updateOverallProgress(); } /** * Handle module state change events */ function handleModuleStateChange(event) { const { moduleId, state } = event.detail; // Update UI with the new state updateModuleState(moduleId, state); // If module is finished, update overall completion if (state === ModuleState.FINISHED) { moduleProgress[moduleId] = 100; // This triggers only when ALL modules are complete, so modules would be removed too quickly // if (areAllModulesComplete()) { // hideLoadingOverlay(); // } animateModuleItemExit(moduleId); } else if (state === ModuleState.ERROR) { moduleProgress[moduleId] = 100; } updateOverallProgress(); // Record timing data if (moduleTimings[moduleId]) { moduleTimings[moduleId][state] = performance.now(); // If the module is finished or has an error, calculate total time if (state === ModuleState.FINISHED || state === ModuleState.ERROR) { const startTime = moduleTimings[moduleId].startTime || 0; moduleTimings[moduleId].totalTime = performance.now() - startTime; } } // Check if all modules are finished after each state change checkAllFinished(); } /** * Handle module status message events */ function handleModuleMessage(event) { const { moduleId, message } = event.detail; updateModuleStatusText(moduleId, message); } /** * Check if all modules are finished loading */ function checkAllFinished() { const modules = moduleRegistry.getAllModules(); // Add detailed logging of all module states console.log('Module states:', Object.entries(modules).map(([id, module]) => { return `${id}: ${module.getState()}`; })); // First determine which modules are pending const pendingModules = Object.values(modules).filter(module => { const state = module.getState(); return state !== ModuleState.FINISHED && state !== ModuleState.ERROR; }); // Log pending modules (if any) if (pendingModules.length > 0) { console.log('Modules still pending:', pendingModules.map(m => `${m.id} (${m.getState()})`)); } else { console.log('No modules pending - all modules are in FINISHED or ERROR state'); } // Determine if all modules are finished based on pendingModules const allFinished = pendingModules.length === 0; if (allFinished && !isLoadingComplete) { console.log('All modules finished loading. Proceeding to finalization...'); if (!finalizationTimer) { finalizationTimer = setTimeout(() => { finalizationTimer = null; finalizeLoading(); }, 900); } } else if (allFinished && isLoadingComplete) { console.log('All modules are finished but isLoadingComplete is already true'); } } /** * Finalize the loading process */ async function finalizeLoading() { console.log('Loading completed. Finalizing...'); try { // Display timing data displayModuleTimings(); await completeFinalization(); } catch (error) { console.error('Error during finalization:', error); await hideOverlay(); } } /** * Complete the finalization process */ async function completeFinalization() { isLoadingComplete = true; // Call the start method on the game loop module directly // Ensure the game loop module was found during initialization if (gameLoopModule && typeof gameLoopModule.start === 'function') { // Hide the overlay first, then start the game loop await hideOverlay(); console.log("Loader: Overlay hidden, starting Game Loop."); try { gameLoopModule.start(); } catch (error) { console.error("Error starting Game Loop:", error); } } else { console.error("Loader: Game Loop module not found or start method missing."); // Hide overlay anyway, but log error await hideOverlay(); } } /** * Display module timing data to help with weight optimization */ function displayModuleTimings() { console.group('Module Loading Performance Data'); console.log('This data can be used to optimize module weights:'); // Format timing data as tuples [moduleId, totalTime, weight] const timingData = Object.entries(moduleTimings) .filter(([moduleId, timing]) => timing.totalTime !== undefined) .map(([moduleId, timing]) => { return [moduleId, Math.round(timing.totalTime), moduleWeights[moduleId] || 1]; }) .sort((a, b) => b[1] - a[1]); // Sort by total time (descending) // Create a table for easy reading console.table(timingData.map(([moduleId, time, weight]) => { return { moduleId, 'totalTime (ms)': time, 'current weight': weight, 'suggested weight': Math.max(1, Math.round(time / 50)) // Simple heuristic based on time }; })); console.log('Raw timing data:'); console.table(moduleTimings); console.groupEnd(); } /** * Hide the loading overlay with a fade out animation * Then completely remove it from the DOM * @param {Function} [callback] - Optional callback to execute after fade completes */ async function hideOverlay(callback) { // Added callback parameter if (!loadingOverlay) { if (callback) callback(); // Call callback immediately if no overlay return; } await Promise.race([ waitForProgressIndicatorsToExit(), new Promise(resolve => setTimeout(resolve, 700)) ]); // Set opacity to 0 to trigger the fade-out transition loadingOverlay.style.opacity = '0'; await Promise.race([ waitForTransition(loadingOverlay, 'opacity'), new Promise(resolve => setTimeout(resolve, 700)) ]); console.log('Module Loader: Removing overlay from DOM'); // Remove from DOM completely if (loadingOverlay.parentNode) { loadingOverlay.parentNode.removeChild(loadingOverlay); } // Set to null to allow garbage collection loadingOverlay = null; // Execute the callback if provided if (callback) callback(); } /** * Animate one module progress row out and resolve only after its own * fade/collapse animation has finished. * @param {string} moduleId - Module ID * @returns {Promise} */ function animateModuleItemExit(moduleId) { if (moduleExitAnimations.has(moduleId)) { return moduleExitAnimations.get(moduleId); } const moduleItem = document.getElementById(`module-${moduleId}`); if (!moduleItem) { return Promise.resolve(); } const exitPromise = new Promise(resolve => { let settled = false; let timeoutId = null; const finish = () => { if (settled) return; settled = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } moduleItem.removeEventListener('animationend', handleAnimationEnd); if (moduleItem.parentNode) { moduleItem.parentNode.removeChild(moduleItem); } moduleExitAnimations.delete(moduleId); resolve(); }; const handleAnimationEnd = event => { if (event.target === moduleItem && event.animationName === 'fadeOutModule') { finish(); } }; // Let the finished status paint briefly before the row collapses. setTimeout(() => { if (!moduleItem.isConnected) { finish(); return; } moduleItem.addEventListener('animationend', handleAnimationEnd); moduleItem.classList.add('module-finished'); const animationTime = getLongestCssTime(moduleItem, 'animation'); timeoutId = setTimeout(finish, Math.max(animationTime + 80, 80)); }, 120); }); moduleExitAnimations.set(moduleId, exitPromise); return exitPromise; } /** * Make every remaining progress row leave, then wait for all of them. * This keeps the overlay fade from racing the final row animations. */ async function waitForProgressIndicatorsToExit() { if (modulesList) { modulesList.querySelectorAll('.module-item').forEach(moduleItem => { const moduleId = moduleItem.id.replace(/^module-/, ''); animateModuleItemExit(moduleId); }); } if (moduleExitAnimations.size > 0) { await Promise.allSettled([...moduleExitAnimations.values()]); } } /** * Wait for a CSS transition on an element. The timeout is derived from * computed CSS duration/delay so non-animated cases resolve immediately. * @param {Element} element - Element that is transitioning * @param {string} propertyName - CSS property to wait for * @returns {Promise} */ function waitForTransition(element, propertyName) { const transitionTime = getLongestCssTime(element, 'transition'); if (transitionTime <= 0) { return Promise.resolve(); } return new Promise(resolve => { let settled = false; const finish = () => { if (settled) return; settled = true; clearTimeout(timeoutId); element.removeEventListener('transitionend', handleTransitionEnd); resolve(); }; const handleTransitionEnd = event => { if (event.target === element && event.propertyName === propertyName) { finish(); } }; const timeoutId = setTimeout(finish, transitionTime + 80); element.addEventListener('transitionend', handleTransitionEnd); }); } /** * Read the longest duration+delay pair from computed transition/animation CSS. * @param {Element} element - Element to inspect * @param {'transition'|'animation'} kind - CSS timing group * @returns {number} milliseconds */ function getLongestCssTime(element, kind) { const style = window.getComputedStyle(element); const durations = parseCssTimeList(style[`${kind}Duration`]); const delays = parseCssTimeList(style[`${kind}Delay`]); const count = Math.max(durations.length, delays.length); let longest = 0; for (let i = 0; i < count; i++) { const duration = durations[i % durations.length] || 0; const delay = delays[i % delays.length] || 0; longest = Math.max(longest, duration + delay); } return longest; } /** * Parse a comma separated CSS time list into milliseconds. * @param {string} value - CSS time list * @returns {number[]} */ function parseCssTimeList(value) { return String(value || '0s').split(',').map(part => { const text = part.trim(); const amount = Number.parseFloat(text); if (!Number.isFinite(amount)) return 0; return text.endsWith('ms') ? amount : amount * 1000; }); } /** * Update the state of a module * @param {string} id - Module ID * @param {string} state - New state */ function updateModuleState(id, state) { // Update UI const moduleItem = document.getElementById(`module-${id}`); if (!moduleItem) return; const statusElement = moduleItem.querySelector('.module-status'); if (!statusElement) return; // Remove all status classes statusElement.classList.remove( 'status-pending', 'status-loading', 'status-waiting', 'status-initializing', 'status-finished', 'status-error', 'status-fetching' ); // Set the new status let statusText = 'Unknown'; switch (state) { case ModuleState.PENDING: statusElement.classList.add('status-pending'); statusText = 'Pending'; break; case ModuleState.LOADING: statusElement.classList.add('status-loading'); statusText = 'Loading'; break; case ModuleState.FETCHING: statusElement.classList.add('status-fetching'); statusText = 'Fetching'; break; case ModuleState.WAITING: statusElement.classList.add('status-waiting'); statusText = 'Waiting'; break; case ModuleState.INITIALIZING: statusElement.classList.add('status-initializing'); statusText = 'Initializing'; break; case ModuleState.FINISHED: statusElement.classList.add('status-finished'); statusText = 'Finished'; break; case ModuleState.ERROR: statusElement.classList.add('status-error'); statusText = 'Error'; break; } statusElement.textContent = statusText; } /** * Update the status text of a module in the UI * @param {string} id - Module ID * @param {string} text - Status text to display */ function updateModuleStatusText(id, text) { const moduleItem = document.getElementById(`module-${id}`); if (!moduleItem) return; let statusDetailElement = moduleItem.querySelector('.module-status-detail'); if (!statusDetailElement) { statusDetailElement = document.createElement('span'); statusDetailElement.className = 'module-status-detail'; moduleItem.appendChild(statusDetailElement); } statusDetailElement.textContent = text; } /** * Update overall progress based on module weights and progress */ function updateOverallProgress() { const moduleIds = Object.keys(moduleWeights); // Calculate total weight const totalWeight = moduleIds.reduce((sum, id) => { return sum + (moduleWeights[id] || 1); }, 0); if (totalWeight <= 0) return; // Calculate weighted progress let overallProgress = moduleIds.reduce((sum, id) => { const weight = moduleWeights[id] || 1; return sum + ((moduleProgress[id] || 0) * weight / totalWeight); }, 0); overallProgress = Math.min(Math.round(overallProgress), 100); // Update progress bar if (progressIndicator) { progressIndicator.style.width = `${overallProgress}%`; } if (progressText) { progressText.textContent = `${overallProgress}%`; } // Update status text based on progress if (statusText) { if (overallProgress >= 100 && !isLoadingComplete) { statusText.textContent = 'Finalizing...'; } else if (isLoadingComplete) { statusText.textContent = 'Complete!'; } } } // Public API return { init, ModuleState }; })(); // Now that ModuleLoader is defined, add the event listener document.addEventListener('DOMContentLoaded', () => { // Start the loading process when the DOM is loaded ModuleLoader.init(); });