/** * StateManager - Singleton pattern for global application state management * Manages AppState, provides getter/setter methods, and subscription system * Requirements: 2.6, 11.4 */ const StateManager = (function () { // ─── Private State ──────────────────────────────────────────────────────── /** @type {AppState} */ const _state = { // Models currentModel: null, modelList: [], // UI State selectedNodeId: null, zoomLevel: 1, viewMode: 'single', // 'single' | 'comparison' comparisonModel: null, // Search/Filter searchQuery: '', filteredModelList: [], // Error State error: null, // Loading State isLoading: false, loadingProgress: 0, }; /** @type {Map>} */ const _subscribers = new Map(); // ─── Private Helpers ────────────────────────────────────────────────────── /** * Notify all subscribers for a given key. * @param {string} key * @param {*} newValue * @param {*} oldValue */ function _notify(key, newValue, oldValue) { if (_subscribers.has(key)) { _subscribers.get(key).forEach((cb) => { try { cb(newValue, oldValue, key); } catch (err) { console.error(`[StateManager] Subscriber error for key "${key}":`, err); } }); } // Also notify wildcard '*' subscribers if (_subscribers.has('*')) { _subscribers.get('*').forEach((cb) => { try { cb(newValue, oldValue, key); } catch (err) { console.error('[StateManager] Wildcard subscriber error:', err); } }); } } /** * Generic setter that updates state and notifies subscribers. * @param {string} key * @param {*} value */ function _set(key, value) { if (!(key in _state)) { console.warn(`[StateManager] Unknown state key: "${key}"`); } const old = _state[key]; _state[key] = value; _notify(key, value, old); } // ─── Persistence Helpers ────────────────────────────────────────────────── function _persistSelectedModel(modelId) { try { if (modelId !== null && modelId !== undefined) { localStorage.setItem(CONFIG.STORAGE.SELECTED_MODEL, modelId); } else { localStorage.removeItem(CONFIG.STORAGE.SELECTED_MODEL); } } catch (err) { console.warn('[StateManager] localStorage write failed:', err); } } function _loadPersistedSelectedModel() { try { return localStorage.getItem(CONFIG.STORAGE.SELECTED_MODEL) || null; } catch (err) { console.warn('[StateManager] localStorage read failed:', err); return null; } } // ─── Public API ─────────────────────────────────────────────────────────── return { // ── Getters ────────────────────────────────────────────────────────────── /** @returns {AppState} Shallow copy of the current state */ getState() { return Object.assign({}, _state); }, /** @returns {ParsedModel|null} */ getCurrentModel() { return _state.currentModel; }, /** @returns {Array} */ getModelList() { return _state.modelList; }, /** @returns {string|null} */ getSelectedNodeId() { return _state.selectedNodeId; }, /** @returns {number} */ getZoomLevel() { return _state.zoomLevel; }, /** @returns {'single'|'comparison'} */ getViewMode() { return _state.viewMode; }, /** @returns {ParsedModel|null} */ getComparisonModel() { return _state.comparisonModel; }, /** @returns {string} */ getSearchQuery() { return _state.searchQuery; }, /** @returns {Array} */ getFilteredModelList() { return _state.filteredModelList; }, /** @returns {{message:string, type:string, timestamp:number}|null} */ getError() { return _state.error; }, /** @returns {boolean} */ isLoading() { return _state.isLoading; }, /** @returns {number} */ getLoadingProgress() { return _state.loadingProgress; }, // ── Setters ────────────────────────────────────────────────────────────── /** * Set the current model and persist its id to localStorage. * @param {ParsedModel|null} model */ setCurrentModel(model) { _set('currentModel', model); const modelId = model && model.metadata ? model.metadata.fileName : null; _persistSelectedModel(modelId); }, /** @param {Array} list */ setModelList(list) { _set('modelList', list); }, /** @param {string|null} nodeId */ setSelectedNodeId(nodeId) { _set('selectedNodeId', nodeId); }, /** @param {number} level */ setZoomLevel(level) { _set('zoomLevel', level); }, /** @param {'single'|'comparison'} mode */ setViewMode(mode) { _set('viewMode', mode); }, /** @param {ParsedModel|null} model */ setComparisonModel(model) { _set('comparisonModel', model); }, /** @param {string} query */ setSearchQuery(query) { _set('searchQuery', query); }, /** @param {Array} list */ setFilteredModelList(list) { _set('filteredModelList', list); }, /** * Set an error in state. * @param {string} message * @param {'error'|'warning'|'info'} [type='error'] */ setError(message, type = 'error') { _set('error', { message, type, timestamp: Date.now() }); }, /** Clear the current error */ clearError() { _set('error', null); }, /** @param {boolean} loading */ setLoading(loading) { _set('isLoading', loading); }, /** @param {number} progress 0-100 */ setLoadingProgress(progress) { _set('loadingProgress', progress); }, // ── Subscription System ─────────────────────────────────────────────────── /** * Subscribe to changes on a specific state key (or '*' for all changes). * @param {string} key - State key to watch, or '*' for all changes * @param {Function} callback - Called with (newValue, oldValue, key) * @returns {Function} Unsubscribe function */ subscribe(key, callback) { if (typeof callback !== 'function') { throw new TypeError('[StateManager] subscribe() requires a function callback'); } if (!_subscribers.has(key)) { _subscribers.set(key, new Set()); } _subscribers.get(key).add(callback); // Return unsubscribe function return function unsubscribe() { const set = _subscribers.get(key); if (set) { set.delete(callback); if (set.size === 0) { _subscribers.delete(key); } } }; }, /** * Unsubscribe a specific callback from a key. * @param {string} key * @param {Function} callback */ unsubscribe(key, callback) { const set = _subscribers.get(key); if (set) { set.delete(callback); if (set.size === 0) { _subscribers.delete(key); } } }, // ── Persistence ─────────────────────────────────────────────────────────── /** * Restore persisted selected model id from localStorage. * @returns {string|null} The persisted model id, or null */ getPersistedSelectedModelId() { return _loadPersistedSelectedModel(); }, // ── Reset ───────────────────────────────────────────────────────────────── /** * Reset all state to initial values (useful for testing). */ reset() { const keys = Object.keys(_state); const initial = { currentModel: null, modelList: [], selectedNodeId: null, zoomLevel: 1, viewMode: 'single', comparisonModel: null, searchQuery: '', filteredModelList: [], error: null, isLoading: false, loadingProgress: 0, }; keys.forEach((key) => _set(key, initial[key])); }, }; })(); // Export for global access in vanilla JS context window.StateManager = StateManager;