/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/services/LayoutStateService.js
818 строк
25 KB
Starolat Sergei
fix: корректная загрузка активного пресета для нового пользователя
03 июл 2026, 11:58
03 июл 2026, 11:58
2974148
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview LayoutStateService — управление макетом рабочего пространства (SYS-009) * @version 1.2.0 * @module LayoutStateService * * Singleton-сервис для сохранения, восстановления, экспорта/импорта макета приложения. * Поддерживает заводской макет (assets/layouts/), пользовательский макет (UserSettingsService), * версионирование схемы, graceful degradation и авто-сохранение. * * Stage 7 (SYS-013): чтение/запись макета через UserSettingsService при включённом флаге, * с сохранением localStorage как fallback при отсутствии USS. * * FEAT-004 (v1.2.0): backward compatibility для множественного выбора Activity Code. * - Schema version поднята до 1.2. * - Фильтры сохраняются/восстанавливаются через codeValueIds/codeValues. * - Legacy codeValueId мигрируется в codeValueIds при загрузке и через _migrateSchema. * * @fires layout-restored — макет восстановлен { source } */ /** * @typedef {import('./UserSettingsService.js').UserSettingsService} UserSettingsService */ import { serializeLayoutState, applyLayoutSnapshot } from "../ui.js"; import filterState from "./FilterStateManager.js"; import defaultFilterReadiness from "./DefaultFilterReadiness.js"; import dataService from "./DataService.js"; import { LayoutPresetService } from "./LayoutPresetService.js"; import { UserSettingsService } from "./UserSettingsService.js"; import { config } from "../config.js"; const STORAGE_KEY = "deepdive-layout-state-v1"; const CURRENT_SCHEMA_VERSION = "1.2"; const DEFAULT_LAYOUT_PATH = "assets/layouts/default.json"; const MINIMAL_LAYOUT_PATH = "assets/layouts/minimal.json"; /** * Inline fallback layout — используется если все внешние источники недоступны. * @type {import('../types.js').WorkspaceLayout} */ const INLINE_MINIMAL_LAYOUT = { schemaVersion: CURRENT_SCHEMA_VERSION, appVersion: "2.2.0", name: "Inline Minimal", description: "Emergency fallback layout embedded in JS", exportedAt: new Date().toISOString(), layout: { gridColumns: "0px 0px 1fr 0px 0px", leftSidebarVisible: true, zenMode: true, modules: [ { id: "wbs-filter-module", container: "left-sidebar", nextSiblingId: null, dockedOut: false, collapsed: false, xOffset: 0, yOffset: 0, width: "", height: "", flex: "", }, { id: "data-module", container: "main-content", nextSiblingId: "timeline-module", dockedOut: false, collapsed: false, xOffset: 0, yOffset: 0, width: "", height: "", flex: "", }, { id: "timeline-module", container: "main-content", nextSiblingId: null, dockedOut: false, collapsed: false, xOffset: 0, yOffset: 0, width: "", height: "", flex: "", }, { id: "filter-resource-tabs-module", container: "right-sidebar", nextSiblingId: "grouping-module", dockedOut: false, collapsed: false, xOffset: 0, yOffset: 0, width: "", height: "", flex: "", }, { id: "grouping-module", container: "right-sidebar", nextSiblingId: null, dockedOut: false, collapsed: false, xOffset: 0, yOffset: 0, width: "", height: "", flex: "", }, ], }, filters: { codeFilters: [], wbsFilterIds: [], showCurrent: true, showTarget: false, weekRange: { startIdx: null, endIdx: null }, weekRangeEnabled: true, groupings: [], }, timeline: { currentWeekIndex: 0, selectedYear: null, }, mainContent: { activeTab: "summary", }, activePresetId: "builtin-default-analytics", }; class LayoutStateService { constructor() { /** @type {boolean} */ this._initialized = false; /** @type {number|null} */ this._autoSaveTimeout = null; /** @type {boolean} */ this._listenersBound = false; /** @type {MutationObserver|null} */ this._mutationObserver = null; /** @type {boolean} */ this._useUserSettings = false; /** @type {UserSettingsService|null} */ this._userSettingsService = null; } /** * @returns {LayoutStateService} */ static getInstance() { if (!LayoutStateService._instance) { LayoutStateService._instance = new LayoutStateService(); } return LayoutStateService._instance; } /** * Инициализировать сервис (подписка на авто-сохранение). * * Stage 7 (SYS-013): при включённом feature flag и инициализированном * UserSettingsService макет читается/пишется через него. * * @param {{ useUserSettings?: boolean }} [options] */ init(options = {}) { if (this._initialized) return; this._useUserSettings = options.useUserSettings !== undefined ? !!options.useUserSettings : !!config.FEATURE_USER_SETTINGS_SERVICE; if (this._useUserSettings) { const uss = UserSettingsService.getInstance(); if (uss._initialized) { this._userSettingsService = uss; } else { console.warn( "[LayoutStateService] UserSettingsService not initialized, falling back to localStorage", ); this._useUserSettings = false; this._userSettingsService = null; } } this._bindAutoSaveListeners(); this._initialized = true; console.log( "[LayoutStateService] Initialized, useUserSettings:", this._useUserSettings, ); } // ==================== Public API ==================== /** * Восстановить макет по приоритету. * * Stage 7 (SYS-013): * - При включённом UserSettingsService: * USS layoutState → localStorage (legacy fallback) → default.json → minimal.json → inline * - При выключенном UserSettingsService: * localStorage → default.json → minimal.json → inline * * Макет, загруженный из файлов или inline fallback, сохраняется в USS, * чтобы следующий запуск брал его оттуда. */ async restoreLayout() { let layout = this._loadFromUserSettings(); let source = layout ? "user-settings" : null; if (!layout) { layout = this._loadFromLocalStorage(); source = layout ? "localStorage" : null; } if (layout) { console.log(`[LayoutStateService] Restoring from ${source}`); await this._applyLayout(layout); // Если макет был в localStorage, а USS активен — мигрируем его в USS. if (source === "localStorage" && this._useUserSettings) { this._persistLayoutToUserSettings(layout).catch((e) => { console.warn( "[LayoutStateService] Failed to migrate localStorage layout to UserSettingsService:", e, ); }); } return; } layout = await this._fetchLayout(DEFAULT_LAYOUT_PATH); if (layout) { console.log("[LayoutStateService] Restoring from default.json"); await this._applyLayout(layout); this._persistLayoutToUserSettings(layout).catch((e) => { console.warn( "[LayoutStateService] Failed to persist default layout to UserSettingsService:", e, ); }); return; } layout = await this._fetchLayout(MINIMAL_LAYOUT_PATH); if (layout) { console.log("[LayoutStateService] Restoring from minimal.json"); await this._applyLayout(layout); this._persistLayoutToUserSettings(layout).catch((e) => { console.warn( "[LayoutStateService] Failed to persist minimal layout to UserSettingsService:", e, ); }); return; } console.log("[LayoutStateService] Restoring from inline minimal fallback"); await this._applyLayout(INLINE_MINIMAL_LAYOUT); this._persistLayoutToUserSettings(INLINE_MINIMAL_LAYOUT).catch((e) => { console.warn( "[LayoutStateService] Failed to persist inline fallback to UserSettingsService:", e, ); }); } /** * Сохранить текущий макет. * * Stage 7 (SYS-013): при включённом UserSettingsService сохраняет в * `deepDive.data.layoutState` через сервис; иначе — в localStorage. */ saveLayout() { const snapshot = this.getCurrentSnapshot(); if (this._useUserSettings && this._userSettingsService) { this._userSettingsService.set("layoutState", snapshot).catch((e) => { console.error( "[LayoutStateService] Failed to save layout to UserSettingsService:", e, ); }); return; } this._saveToLocalStorage(snapshot); } /** * Собрать полный snapshot текущего состояния workspace. * @returns {import('../types.js').WorkspaceLayout} */ getCurrentSnapshot() { const layout = serializeLayoutState(); const filters = filterState ? filterState.getState() : null; const timelinePanel = document.getElementById("timeline-module"); const mainContentTabs = document.getElementById("data-module"); /** @type {string|null} */ let activePresetId = null; try { const lps = LayoutPresetService.getInstance(); const active = lps.getActivePreset ? lps.getActivePreset() : null; activePresetId = active ? active.presetId : null; } catch (e) { // LayoutPresetService may not be initialized yet } return { schemaVersion: CURRENT_SCHEMA_VERSION, appVersion: "2.2.0", name: "User Layout", description: "Автосохранённый макет пользователя", exportedAt: new Date().toISOString(), layout, filters, timeline: { currentWeekIndex: timelinePanel && typeof timelinePanel.currentWeekIndex !== "undefined" ? timelinePanel.currentWeekIndex : 0, selectedYear: timelinePanel && timelinePanel._selectedYear ? timelinePanel._selectedYear : null, }, mainContent: { activeTab: mainContentTabs ? mainContentTabs.getAttribute("active-tab") || "summary" : "summary", }, activePresetId, }; } /** * Экспортировать текущий макет в JSON-строку. * @returns {string} */ exportToJSON() { const snapshot = this.getCurrentSnapshot(); snapshot.name = snapshot.name || "Exported Layout"; snapshot.description = snapshot.description || "Экспортированный макет DeepDive Analytics"; return JSON.stringify(snapshot, null, 2); } /** * Импортировать и применить макет из JSON-строки. * @param {string} jsonString * @throws {Error} */ async importFromJSON(jsonString) { let data; try { data = JSON.parse(jsonString); } catch (e) { throw new Error("Невалидный JSON"); } const validation = this._validateLayout(data); if (!validation.valid) { throw new Error(`Ошибка валидации: ${validation.errors.join(", ")}`); } if (data.schemaVersion !== CURRENT_SCHEMA_VERSION) { data = this._migrateSchema( data, data.schemaVersion, CURRENT_SCHEMA_VERSION, ); } await this._applyLayout(data); this.saveLayout(); } /** * Сбросить к заводским настройкам и перезагрузить страницу. * * Stage 7 (SYS-013): очищает макет и из UserSettingsService, и из localStorage. */ resetToFactoryDefault() { if (this._useUserSettings && this._userSettingsService) { this._userSettingsService.set("layoutState", null).catch((e) => { console.warn( "[LayoutStateService] Failed to clear layout in UserSettingsService:", e, ); }); } localStorage.removeItem(STORAGE_KEY); window.location.reload(); } // ==================== Private ==================== /** * Загрузить макет из UserSettingsService. * @returns {import('../types.js').WorkspaceLayout|null} * @private */ _loadFromUserSettings() { if (!this._useUserSettings || !this._userSettingsService) return null; try { const layout = this._userSettingsService.get("layoutState", null); if (!layout) return null; const validation = this._validateLayout(layout); if (!validation.valid) { console.warn( "[LayoutStateService] Invalid layout in UserSettingsService, ignoring:", validation.errors, ); return null; } return layout; } catch (e) { console.warn( "[LayoutStateService] Failed to load layout from UserSettingsService:", e, ); return null; } } /** * Сохранить загруженный макет в UserSettingsService. * @param {import('../types.js').WorkspaceLayout} layout * @returns {Promise<void>} * @private */ _persistLayoutToUserSettings(layout) { if (!this._useUserSettings || !this._userSettingsService) return Promise.resolve(); return this._userSettingsService.set("layoutState", layout); } /** * @returns {import('../types.js').WorkspaceLayout|null} * @private */ _loadFromLocalStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); const validation = this._validateLayout(data); if (!validation.valid) { console.warn( "[LayoutStateService] Invalid stored layout, ignoring:", validation.errors, ); return null; } return data; } catch (e) { console.warn("[LayoutStateService] Failed to load from localStorage:", e); return null; } } /** * @param {import('../types.js').WorkspaceLayout} snapshot * @private */ _saveToLocalStorage(snapshot) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch (e) { console.error("[LayoutStateService] Failed to save layout:", e); } } /** * @param {string} url * @returns {Promise<import('../types.js').WorkspaceLayout|null>} * @private */ async _fetchLayout(url) { try { const response = await fetch(url); if (!response.ok) { console.warn( `[LayoutStateService] Failed to fetch ${url}: ${response.status}`, ); return null; } const data = await response.json(); const validation = this._validateLayout(data); if (!validation.valid) { console.warn( `[LayoutStateService] Invalid layout at ${url}:`, validation.errors, ); return null; } return data; } catch (e) { console.warn(`[LayoutStateService] Error fetching ${url}:`, e); return null; } } /** * @param {any} data * @returns {{valid: boolean, errors: string[]}} * @private */ _validateLayout(data) { const errors = []; if (!data || typeof data !== "object") { errors.push("Data is not an object"); return { valid: false, errors }; } if (!data.schemaVersion) errors.push("Missing schemaVersion"); if (!data.layout || typeof data.layout !== "object") { errors.push("Missing layout object"); } else if (!Array.isArray(data.layout.modules)) { errors.push("Missing layout.modules array"); } if (data.schemaVersion && typeof data.schemaVersion === "string") { const major = parseInt(String(data.schemaVersion).split(".")[0], 10); const currentMajor = parseInt(CURRENT_SCHEMA_VERSION.split(".")[0], 10); if (!Number.isNaN(major) && major > currentMajor) { errors.push( `Schema ${data.schemaVersion} newer than supported ${CURRENT_SCHEMA_VERSION}`, ); } } return { valid: errors.length === 0, errors }; } /** * @param {import('../types.js').WorkspaceLayout} data * @param {string} fromVersion * @param {string} toVersion * @returns {import('../types.js').WorkspaceLayout} * @private */ _migrateSchema(data, fromVersion, toVersion) { if (fromVersion === toVersion) return data; console.log( `[LayoutStateService] Migrating layout schema ${fromVersion} → ${toVersion}`, ); const migrated = JSON.parse(JSON.stringify(data)); if (fromVersion === "1.0" && toVersion === "1.1") { // v1.1 — FilterPanel и ResourceFilter объединены в <filter-resource-tabs>. // Заменяем filter-module на filter-resource-tabs-module, // удаляем resource-filter-module, корректируем nextSiblingId. const modules = migrated.layout?.modules || []; const migratedModules = []; for (const m of modules) { if (m.id === "resource-filter-module") { continue; } const copy = { ...m }; if (copy.id === "filter-module") { copy.id = "filter-resource-tabs-module"; } if (copy.nextSiblingId === "filter-module") { copy.nextSiblingId = "filter-resource-tabs-module"; } else if (copy.nextSiblingId === "resource-filter-module") { copy.nextSiblingId = null; } migratedModules.push(copy); } migrated.layout.modules = migratedModules; } if (fromVersion === "1.1" && toVersion === "1.2") { // FEAT-004: фильтры по Activity Code перешли с codeValueId на codeValueIds. // Мигрируем legacy-записи, сохраняя backward compatibility. const codeFilters = migrated.filters?.codeFilters || []; for (const f of codeFilters) { if (Array.isArray(f.codeValueIds) && f.codeValueIds.length > 0) { continue; } const legacyId = f.codeValueId != null ? Number(f.codeValueId) : Number.NaN; const valueIds = Number.isFinite(legacyId) ? [legacyId] : []; f.codeValueIds = valueIds; f.codeValues = valueIds.map((id, idx) => ({ valueId: id, valueName: idx === 0 ? f.codeValueName || "" : "", color: idx === 0 ? f.color || undefined : undefined, })); } } migrated.schemaVersion = toVersion; return migrated; } /** * @param {import('../types.js').WorkspaceLayout} data * @private */ async _applyLayout(data) { // Sync zen mode before applying layout so sidebars are in correct state if (data.layout && data.layout.zenMode !== undefined) { const app = typeof window !== "undefined" ? window.app : null; if ( app && typeof app.isZenMode === "boolean" && typeof app.toggleZenMode === "function" ) { if (data.layout.zenMode !== app.isZenMode) { app.toggleZenMode(); } } } if (data.layout) { // Force all dockable widgets to be docked on app startup const layoutSnapshot = JSON.parse(JSON.stringify(data.layout)); if (layoutSnapshot.modules) { layoutSnapshot.modules.forEach((m) => { m.dockedOut = false; m.xOffset = 0; m.yOffset = 0; m.width = ""; m.height = ""; }); } applyLayoutSnapshot(layoutSnapshot); } if (data.filters && filterState) { this._restoreFilterState(data.filters); } if (data.mainContent && data.mainContent.activeTab) { const tabs = document.getElementById("data-module"); if (tabs) { tabs.setAttribute("active-tab", data.mainContent.activeTab); } } if (data.timeline && data.timeline.currentWeekIndex !== undefined) { const timelinePanel = document.getElementById("timeline-module"); if (timelinePanel && typeof timelinePanel.setWeekIndex === "function") { timelinePanel.setWeekIndex(data.timeline.currentWeekIndex); } } if (data.activePresetId) { try { const lps = LayoutPresetService.getInstance(); if (lps.setActivePreset) { const preset = lps._cache.get(data.activePresetId); const activePreset = lps.getActivePreset(); if (!preset) { console.warn( `[LayoutStateService] activePresetId "${data.activePresetId}" not found in preset cache, skipping`, ); } else if (activePreset?.presetId === data.activePresetId) { // Already active; avoid redundant preset-loaded dispatch. } else { await lps.setActivePreset(data.activePresetId); } } } catch (e) { console.warn( "[LayoutStateService] Failed to restore active preset:", e, ); } } document.dispatchEvent( new CustomEvent("layout-restored", { detail: { source: data.name || "unknown" }, }), ); } /** * @param {import('../types.js').FilterState} filters * @private */ _restoreFilterState(filters) { if (!filterState) return; // Code filters (FEAT-004) // Используем replaceCodeFilters, чтобы сохранить множественные codeValueIds // и корректно мигрировать legacy codeValueId через _normalizeCodeFilter. if (filters.codeFilters && Array.isArray(filters.codeFilters)) { // Restore with UI sync but skip widget reload: widgets will load once via // markReady below, not twice from the replace event + readiness callback. filterState.replaceCodeFilters(filters.codeFilters, { skipWidgetReload: true, }); const dbId = dataService?.getActiveDatabaseId?.(); if (dbId && filters.codeFilters.length > 0) { console.log( `[LayoutStateService] Restored ${filters.codeFilters.length} code filters; marking default-filter readiness`, ); defaultFilterReadiness.markReady(dbId); } } else { filterState.clearCodeFilters(); } // WBS if (filters.wbsFilterIds && filters.wbsFilterIds.length > 0) { filterState.setWbsFilter(filters.wbsFilterIds, filters.wbsFilterIds); } else { filterState.clearWbsFilter(); } // Schedule mode filterState.setScheduleMode({ showCurrent: filters.showCurrent !== undefined ? filters.showCurrent : true, showTarget: filters.showTarget !== undefined ? filters.showTarget : false, }); // Groupings filterState.setGroupings(filters.groupings || []); // Week range if (filters.weekRange) { const weekRangeEnabled = typeof filters.weekRangeEnabled === "boolean" ? filters.weekRangeEnabled : true; filterState.setWeekRange( filters.weekRange.startIdx, filters.weekRange.endIdx, weekRangeEnabled, ); } else { filterState.clearWeekRange(); } // Resource filters const effectiveResourceIds = filters.resourceFilterIds || []; const disabledResourceIds = filters.disabledResourceFilterIds || []; const allSelected = [ ...new Set([...effectiveResourceIds, ...disabledResourceIds]), ]; if (typeof filterState.setResourceFilterState === "function") { filterState.setResourceFilterState(allSelected, disabledResourceIds); } else { filterState.setResourceFilter(allSelected); } } /** * @private */ _bindAutoSaveListeners() { if (this._listenersBound) return; this._listenersBound = true; const trigger = () => this._debouncedAutoSave(); // Listen to explicit layout-change events from LeftSidebar and others document.addEventListener("layout-change", trigger); document.addEventListener("preset-loaded", trigger); // Watch for DOM mutations on .module elements (docked-out, collapsed, style changes) this._mutationObserver = new MutationObserver((mutations) => { let shouldSave = false; for (const m of mutations) { if ( m.type === "attributes" && (m.attributeName === "class" || m.attributeName === "style") ) { const el = /** @type {HTMLElement} */ (m.target); if (el.classList && el.classList.contains("module")) { shouldSave = true; break; } } } if (shouldSave) trigger(); }); this._mutationObserver.observe(document.body, { attributes: true, attributeFilter: ["class", "style", "data-x-offset", "data-y-offset"], subtree: true, }); } /** * @private */ _debouncedAutoSave() { if (this._autoSaveTimeout) { clearTimeout(this._autoSaveTimeout); } this._autoSaveTimeout = setTimeout(() => { this.saveLayout(); console.log("[LayoutStateService] Auto-saved layout"); }, 500); } } /** @type {LayoutStateService|null} */ LayoutStateService._instance = null; export { LayoutStateService };