Files
ai.interactive.fiction/public/js/loader.js
T

1196 lines
45 KiB
JavaScript

/**
* 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<void>}
*/
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<void>}
*/
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();
});