/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/LevelLayoutPanel.js
1 295 строк
42 KB
Starolat Sergei
feat: переключатель типа диаграммы (S-кривая/понедельная) в SummaryProgressWidget с сохранением в пресет
30 июл 2026, 10:03
30 июл 2026, 10:03
71afb3a
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview LevelLayoutPanel — CSS Grid контейнер для виджетов одного уровня drill-down (COMP-025) * @version 1.5.2 * @element level-layout-panel * * Контейнерный компонент: получает LevelConfig, создаёт grid, * монтирует виджеты, прокидывает levelContext для pull-модели. * * v1.5.2 — `context-updated` и getter `levelContext` возвращают смёрженный * контекст с `dataDomain`/`baseFilters` из LevelConfig, чтобы FilterResourceTabs * и FilterPanel получали актуальные level filters для синхронизации с * FilterStateManager. * * v1.5.1 — Если setLevelContext вызван до подключения панели к DOM, * откладываем dispatch context-updated до connectedCallback, иначе событие * не всплывёт до document-слушателей. * * @fires drill-down-request — виджет инициировал drill-down * @fires widget-loaded — виджет завершил загрузку { widgetId, levelId } * @fires widget-error — ошибка виджета { widgetId, levelId, error } * @fires context-updated — контекст уровня изменён */ /** * @typedef {import('../types.js').LevelConfig} LevelConfig * @typedef {import('../types.js').WidgetInstanceConfig} WidgetInstanceConfig * @typedef {import('../types.js').LevelContext} LevelContext * @typedef {import('../types.js').Filter} Filter */ import dataService from "../services/DataService.js"; import filterState from "../services/FilterStateManager.js"; import weekIndexService from "../services/WeekIndexService.js"; import defaultFilterReadiness from "../services/DefaultFilterReadiness.js"; import { LayoutPresetService } from "../services/LayoutPresetService.js"; class LevelLayoutPanel extends HTMLElement { static get observedAttributes() { return ["level-id", "level-name", "level-index", "database-id"]; } /** * Runtime mapping of widget component tags to their data domain. * Used for domain compatibility warnings and auto-level widget selection. * @type {Record<string, 'manhours'|'material'>} */ static get WIDGET_DOMAIN_MAP() { return { "summary-s-curve-widget": "manhours", "summary-progress-widget": "manhours", "summary-bar-chart-widget": "manhours", "summary-milestone-widget": "manhours", "summary-drill-down-widget": "manhours", "summary-auto-widget": "manhours", "volume-progress-widget": "material", "volume-s-curve-widget": "material", "volume-table-widget": "material", "volume-auto-widget": "material", }; } constructor() { super(); this.attachShadow({ mode: "open" }); /** @type {LevelConfig|null} */ this._config = null; /** @type {LevelContext|null} */ this._levelContext = null; /** @type {any[]|null} */ this._levelData = null; /** @type {string|null} */ this._databaseId = null; /** @type {Map<string, HTMLElement>} */ this._widgets = new Map(); /** @type {WidgetInstanceConfig[]} */ this._resolvedWidgets = []; /** @type {boolean} */ this._autoBuildPending = false; /** @type {number} */ this._autoBuildId = 0; /** @type {(() => void)|null} */ this._autoBuildWaitCancel = null; /** @type {string|null} */ this._lastAutoBuildReportDate = null; /** @type {boolean} */ this._isLoading = false; /** @type {HTMLElement|null} */ this._gridContainer = null; /** @type {boolean} */ this._contextUpdatedPending = false; // Bound handlers this._boundHandleDrillDown = this._handleDrillDown.bind(this); this._boundHandleWidgetLoaded = this._handleWidgetLoaded.bind(this); this._boundHandleWidgetError = this._handleWidgetError.bind(this); this._boundHandleFilterStateChange = this._handleFilterStateChange.bind(this); this._boundHandleNormalizationChange = this._handleNormalizationChange.bind(this); this._boundHandleDataDateChange = this._handleDataDateChange.bind(this); this._boundHandleWidgetSettingChange = this._handleWidgetSettingChange.bind(this); } // ==================== Attributes / Properties ==================== get config() { return this._config; } get levelContext() { return this._getMergedLevelContext(); } get levelData() { return this._levelData; } get widgets() { return this._widgets; } get isLoading() { return this._isLoading; } connectedCallback() { this._databaseId = this.getAttribute("database-id") || null; if (!this._gridContainer) { this._renderSkeleton(); } this._setupEventListeners(); this._setupResizeObserver(); if (this._widgets.size > 0) { this._propagateDatabaseId(); if (this._levelContext) { this._propagateContext(); } if (this._levelData) { this._propagateLevelData(); } } // Если setLevelContext был вызван до подключения к DOM, событие // context-updated не всплыло до document. Повторяем dispatch после // подключения, чтобы document-слушатели (FilterResourceTabs) получили // актуальный контекст уровня. if (this._contextUpdatedPending && this._levelContext) { this._contextUpdatedPending = false; this._dispatchContextUpdated(this._levelContext); } } disconnectedCallback() { this._removeEventListeners(); if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } this._widgets.forEach((widget) => { if (widget.parentNode) { widget.parentNode.removeChild(widget); } }); this._widgets.clear(); this._cancelAutoBuildWait(); this._autoBuildPending = false; this._autoBuildId++; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case "database-id": this._cancelAutoBuildWait(); this._databaseId = newValue || null; this._propagateDatabaseId(); // Retry auto-widget build if database became available after initial failure if ( this._databaseId && this._config?.generationMode === "auto" && this._widgets.size === 0 ) { this._maybeStartAutoBuild("database-id changed"); } break; } } // ==================== Public API ==================== /** * Установить конфигурацию уровня и перестроить grid. * @param {LevelConfig} config */ setLevelConfig(config) { const isSameConfig = JSON.stringify(this._config) === JSON.stringify(config); this._config = config; if (!this._gridContainer) { this._renderSkeleton(); } if (!isSameConfig) { this._renderGrid(); if (config.generationMode === "auto") { this._autoBuildPending = true; this._resolvedWidgets = []; this._gridContainer.innerHTML = '<div class="empty-state">Ожидание контекста уровня…</div>'; } else { this._autoBuildPending = false; this._resolvedWidgets = config.widgets || []; this._mountWidgets(); } } this._propagateDatabaseId(); if (this._levelContext) { this._propagateContext(); } if (this._levelData) { this._propagateLevelData(); } } /** * Обновить контекст и уведомить виджеты. * @param {LevelContext} context */ setLevelContext(context) { this._levelContext = context; if (this._config?.generationMode === "auto" && this._autoBuildPending) { // Ждём применения дефолтных бизнес-фильтров, иначе авто-уровень // сначала считается без фильтров, а потом пересчитывается заново. this._maybeStartAutoBuild("setLevelContext"); } else { this._propagateContext(); } if (this.isConnected) { this._dispatchContextUpdated(this._getMergedLevelContext() || context); } else { // Откладываем dispatch до connectedCallback, иначе событие не // всплывёт до document-слушателей. this._contextUpdatedPending = true; } } /** * Возвращает контекст уровня, смёрженный с dataDomain/baseFilters из LevelConfig. * Используется как для виджетов, так и для dispatch `context-updated`, чтобы * слушатели (FilterResourceTabs/FilterPanel) получали актуальные baseFilters. * @returns {LevelContext|null} * @private */ _getMergedLevelContext() { if (!this._levelContext) return null; return { ...(this._levelContext || {}), dataDomain: this._config?.dataDomain || this._levelContext?.dataDomain || "manhours", baseFilters: this._config?.baseFilters || this._levelContext?.baseFilters || [], }; } /** * Уведомляет document-слушателей об изменении контекста уровня. * @param {LevelContext} context * @private */ _dispatchContextUpdated(context) { const merged = this._getMergedLevelContext() || context; this.dispatchEvent( new CustomEvent("context-updated", { bubbles: true, composed: true, detail: { levelId: this.getAttribute("level-id") || "", context: merged, widgetsReloaded: true, }, }), ); } /** * Запускает авто-построение уровня, только когда дефолтные фильтры готовы. * @param {string} reason * @private */ _maybeStartAutoBuild(reason) { if (!this._config || this._config.generationMode !== "auto") return; if (!this._autoBuildPending) return; if (!this._gridContainer) return; const dbId = this._databaseId || dataService.getActiveDatabaseId(); if (!dbId) { this._gridContainer.innerHTML = '<div class="empty-state">Нет подключённой базы данных</div>'; return; } if (defaultFilterReadiness.isReady(dbId)) { this._startAutoBuild(); return; } if (this._autoBuildWaitCancel) return; this._autoBuildWaitCancel = defaultFilterReadiness.waitForDefaultFilters( dbId, (readyDbId) => { if (readyDbId && readyDbId !== dbId) return; this._autoBuildWaitCancel = null; if (this._autoBuildPending) { this._startAutoBuild(); } }, 15000, ); } /** * @private */ _startAutoBuild() { this._cancelAutoBuildWait(); if (!this._autoBuildPending) return; this._autoBuildPending = false; this._autoBuildPromise = this._buildAutoWidgets(this._config); } /** * Возвращает Promise текущего/последнего авто-построения. * Полезен для тестов и для родительских компонентов, которым нужно * дождаться отрисовки автовиджетов. * @returns {Promise<void>} */ waitForAutoBuild() { return this._autoBuildPromise || Promise.resolve(); } /** * @private */ _cancelAutoBuildWait() { if (this._autoBuildWaitCancel) { this._autoBuildWaitCancel(); this._autoBuildWaitCancel = null; } } /** * Установить данные уровня и прокинуть в виджеты. * @param {any[]} data */ setLevelData(data) { this._levelData = data || []; this._propagateLevelData(); } /** * Получить виджет по instance ID. * @param {string} widgetId * @returns {HTMLElement|null} */ getWidget(widgetId) { return this._widgets.get(widgetId) || null; } /** * Перезагрузить указанные виджеты (все, если не указано). * @param {string[]} [widgetIds] * @returns {Promise<void>} */ async reloadWidgets(widgetIds) { const targets = widgetIds ? widgetIds.map((id) => this._widgets.get(id)).filter(Boolean) : Array.from(this._widgets.values()); // Run all widget refreshes in parallel instead of awaiting each one // sequentially. Each widget already posts its SQL to the worker queue, // so parallel dispatch lets the worker pipeline stay busy and the UI // update incrementally rather than one-by-one. await Promise.all( targets.map((widget) => { if (typeof widget.refresh === "function") { return widget.refresh().catch((e) => { console.warn("[LevelLayoutPanel] Widget refresh failed:", e); }); } return Promise.resolve(); }), ); } /** * Динамически добавить виджет. * @param {WidgetInstanceConfig} widgetConfig */ addWidget(widgetConfig) { if (!this._gridContainer) return; const widget = this._createWidgetElement(widgetConfig); this._gridContainer.appendChild(widget); this._widgets.set(widgetConfig.id, widget); if (this._levelContext) { this._applyContextToWidget(widget.firstElementChild, widgetConfig); } if (this._levelData) { const inner = widget.firstElementChild; if (inner && "levelData" in inner) { inner.levelData = this._levelData; } } } /** * Удалить виджет по ID. * @param {string} widgetId * @returns {boolean} */ removeWidget(widgetId) { const widget = this._widgets.get(widgetId); if (!widget) return false; if (widget.parentNode) { widget.parentNode.removeChild(widget); } this._widgets.delete(widgetId); return true; } /** * Сериализовать текущую раскладку. * @returns {object} */ serializeLayout() { const widgets = []; // For auto levels, do not serialize generated widgets — they are recreated at runtime. if (this._config?.generationMode !== "auto") { this._widgets.forEach((widget, id) => { const wcfg = (this._resolvedWidgets || []).find((w) => w.id === id); if (wcfg) { widgets.push({ id: wcfg.id, componentTag: wcfg.componentTag, position: wcfg.position, size: wcfg.size, props: wcfg.props, privateFilters: wcfg.privateFilters, }); } }); } return { levelId: this.getAttribute("level-id") || "", levelName: this.getAttribute("level-name") || "", levelIndex: Number(this.getAttribute("level-index") || 0), grid: this._config?.grid || { columns: 12, rows: 8, gap: 8 }, widgets, }; } // ==================== Auto Widget Generation ==================== /** * Асинхронно генерирует виджеты для авто-уровня на основе справочника. * Оптимизировано: один SQL-запрос, batch mount, push-model. * @param {LevelConfig} config * @private */ async _buildAutoWidgets(config) { if (!this._gridContainer) return; const buildId = ++this._autoBuildId; const levelId = this.getAttribute("level-id") || "?"; // Avoid re-clearing the DOM if another build already showed the loader if (!this._gridContainer.querySelector(".empty-state")) { this._gridContainer.innerHTML = '<div class="empty-state">Загрузка автовиджетов…</div>'; } const dbId = this._databaseId || dataService.getActiveDatabaseId(); if (!dbId) { this._gridContainer.innerHTML = '<div class="empty-state">Нет подключённой базы данных</div>'; return; } try { // 1. Resolve actual reportDate from FilterStateManager. // levelContext may have been created before TimeLinePanel initialized // the data date, so its reportDate can be stale/null on first load. const dataDate = filterState.getEffectiveDataDate(); let reportDate = this._levelContext?.reportDate || null; if (dataDate.mode === "timeline" && dataDate.weekEnd) { reportDate = dataDate.weekEnd; } else if (dataDate.mode === "range" && dataDate.endIdx != null) { reportDate = weekIndexService.indexToWeekEnd(dataDate.endIdx); } // Fallback to project data_date if timeline hasn't initialized yet. if (!reportDate && dbId) { try { const meta = weekIndexService.getMeta(dbId); if (meta?.dataDate) { reportDate = meta.dataDate; } else { // Phase 6: JS Data Engine only — replaced SQL fallback with // dataService.getProjects() and uses the latest project data date. const projects = await dataService.getProjects(dbId); const latestDataDate = projects .map((p) => p.dataDate) .filter(Boolean) .sort() .pop(); reportDate = latestDataDate ? latestDataDate.substring(0, 10) : null; } } catch (e) { console.warn( "[LevelLayoutPanel] Failed to resolve data date fallback:", e, ); } } if (this._levelContext) { this._levelContext.reportDate = reportDate; } this._lastAutoBuildReportDate = reportDate; // 2. Resolve global filters from FilterStateManager. // Auto-level widgets must respect the same code/WBS/week filters // as pull-model widgets, otherwise first-load numbers differ. const codeFilters = filterState.getEffectiveCodeFilters(); const wbsFilterIds = filterState.getEffectiveWbsFilterIds(); const weekRange = filterState.getEffectiveWeekRange(); // 3. Merge drill-down + global code filters for auto-level metrics. const inheritedFilters = this._levelContext?.inheritedFilters || []; const levelFilters = this._levelContext?.levelFilters || []; const allFilters = [...inheritedFilters, ...levelFilters]; for (const cf of codeFilters) { if (!cf.enabled || !cf.codeValueIds?.length) continue; allFilters.push({ field: `code:${cf.codeTypeId}`, value: cf.codeValueIds, condition: "equals", enabled: true, }); } const isMaterialLevel = config.dataDomain === "material"; const resourceFilterIds = filterState.getEffectiveResourceFilterIds(); const metrics = await dataService.getAutoLevelMetrics( config.autoCodeType, dbId, { scheduleType: "both", weekRange, reportDate, filters: allFilters, resourceFilterIds: isMaterialLevel ? resourceFilterIds : [], wbsFilterIds, sortBy: config.autoSortBy || "codeOrder", sortDir: config.autoSortDir || "asc", resourceType: config.autoResourceType || (isMaterialLevel ? "material" : "labor"), dataDomain: config.dataDomain || "manhours", baseFilters: config.baseFilters || [], }, ); if (buildId !== this._autoBuildId) return; if (!metrics.length) { this._gridContainer.innerHTML = `<div class="empty-state">Справочник «${this._escapeHtml(config.autoCodeType)}» пуст</div>`; return; } // На автоматическом уровне скрываем виджеты без данных (count === 0): // это значения справочника, по которым нет работ после применения фильтров. const visibleMetrics = metrics.filter((m) => (m.count ?? 0) > 0); if (!visibleMetrics.length) { this._gridContainer.innerHTML = '<div class="empty-state">Нет данных для отображения</div>'; return; } // 3. Build widget configs from metrics // Force the lightweight auto widget for the level's data domain to avoid // N pull-model SQL queries. Material levels use volume-auto-widget. const componentTag = isMaterialLevel ? "volume-auto-widget" : "summary-auto-widget"; if (config.autoWidgetType && config.autoWidgetType !== componentTag) { console.warn( `[LevelLayoutPanel] Auto-level forces '${componentTag}' instead of preset '${config.autoWidgetType}' to avoid ${visibleMetrics.length} pull-model SQL queries.`, ); } const cols = config.grid?.columns || 2; const neededRows = Math.ceil(visibleMetrics.length / cols); const rows = Math.max(config.grid?.rows || 4, neededRows); this._gridContainer.style.gridTemplateRows = `repeat(${rows}, minmax(70px, 185px))`; this._resolvedWidgets = visibleMetrics.map((m, idx) => { const col = idx % cols; const row = Math.floor(idx / cols); const widgetId = `auto-${config.id}-${m.valueId ?? idx}`; // Runtime-настройки виджета (chartType и т.п.), сохранённые в пресете const savedSettings = config.widgetSettings?.[widgetId] || {}; return { id: widgetId, type: "widget", parentLevelId: config.id, componentTag, title: m.shortName && m.description && m.shortName !== m.description ? `${m.shortName} — ${m.description}` : m.shortName || m.description || `Значение ${idx + 1}`, position: { x: col, y: row }, size: { width: 1, height: 1 }, privateFilters: [ { field: `code:${config.autoCodeType}`, condition: "equals", value: m.shortName, enabled: true, }, ], allowDrillDown: true, props: { color: m.color || undefined, ...savedSettings }, _metric: m, }; }); // 4. Batch mount (20 per rAF) + push levelData immediately await this._batchMountWidgets(this._resolvedWidgets, 20); this._propagateDatabaseId(); if (this._levelContext) { this._propagateContext(); } } catch (err) { console.error("[LevelLayoutPanel] Auto widget generation failed:", err); this._gridContainer.innerHTML = '<div class="empty-state">Ошибка генерации виджетов</div>'; } } // ==================== Rendering ==================== _renderSkeleton() { if (this._gridContainer) return; this.shadowRoot.innerHTML = ` <style> :host { display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; } .level-header { display: flex; align-items: center; gap: var(--space-xs, 8px); padding: var(--space-2xs, 4px) var(--space-xs, 8px); min-height: 24px; flex-shrink: 0; border-bottom: 1px solid var(--color-bg-border, #e0e0e0); } .level-header-title { font-size: var(--size-text-xs, 10px); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-primary, #212121); } .level-header-badge { background: var(--color-bg-secondary, #e0e0e0); color: var(--color-typo-secondary, #757575); font-size: var(--size-text-2xs, 9px); font-weight: 600; padding: 2px 6px; border-radius: var(--radius-xs, 2px); } .level-layout-grid { display: grid; flex: 1 1 auto; min-height: 0; overflow: auto; padding: var(--space-xs, 8px); align-content: start; } .widget-container { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .empty-state { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--color-typo-ghost, #999); font-size: var(--size-text-s, 11px); } </style> <div class="level-layout-grid" id="grid-container" role="region" aria-label="${this._escapeHtml(this.getAttribute("level-name") || "Уровень аналитики")}"> <div class="empty-state">Нет виджетов</div> </div> `; this._gridContainer = this.shadowRoot.getElementById("grid-container"); } _renderGrid() { if (!this._gridContainer || !this._config) return; const grid = this._config.grid || { columns: 12, rows: 8, gap: 8 }; this._gridContainer.style.gridTemplateColumns = `repeat(${grid.columns}, 1fr)`; this._gridContainer.style.gridTemplateRows = `repeat(${grid.rows}, minmax(60px, 1fr))`; this._gridContainer.style.gap = `${grid.gap}px`; // Clear existing widgets this._widgets.forEach((widget) => { if (widget.parentNode) widget.parentNode.removeChild(widget); }); this._widgets.clear(); } _mountWidgets() { if (!this._config || !this._gridContainer) return; const widgets = this._resolvedWidgets || []; if (widgets.length === 0) { this._gridContainer.innerHTML = '<div class="empty-state">Нет виджетов</div>'; return; } this._gridContainer.innerHTML = ""; for (const wcfg of widgets) { const widget = this._createWidgetElement(wcfg); this._gridContainer.appendChild(widget); this._widgets.set(wcfg.id, widget); } } /** * Batch mount widgets to avoid blocking UI for large counts (e.g. 181). * @param {WidgetInstanceConfig[]} widgets * @param {number} [batchSize=20] * @returns {Promise<void>} * @private */ async _batchMountWidgets(widgets, batchSize = 20) { if (!this._gridContainer) return; this._gridContainer.innerHTML = ""; this._widgets.clear(); const total = widgets.length; for (let i = 0; i < total; i += batchSize) { // Yield to browser between batches if (i > 0) { await new Promise((resolve) => requestAnimationFrame(resolve)); } const batch = widgets.slice(i, i + batchSize); for (const wcfg of batch) { const widget = this._createWidgetElement(wcfg); this._gridContainer.appendChild(widget); this._widgets.set(wcfg.id, widget); // Push metric and context immediately (push-model, no pull SQL) const inner = widget.firstElementChild; if (inner) { if (wcfg._metric && "levelData" in inner) { inner.levelData = wcfg._metric; } if (wcfg.privateFilters && "privateFilters" in inner) { inner.privateFilters = wcfg.privateFilters; } } } } } /** * @param {WidgetInstanceConfig} wcfg * @returns {HTMLElement} */ _createWidgetElement(wcfg) { const container = document.createElement("div"); container.className = "widget-container"; container.style.gridColumn = `${wcfg.position.x + 1} / span ${wcfg.size.width}`; container.style.gridRow = `${wcfg.position.y + 1} / span ${wcfg.size.height}`; container.setAttribute("role", "article"); container.setAttribute( "aria-label", wcfg.title || wcfg.componentTag || "Виджет", ); const levelDomain = this._config?.dataDomain || "manhours"; const widgetDomain = LevelLayoutPanel.WIDGET_DOMAIN_MAP[wcfg.componentTag] || "manhours"; if (levelDomain !== widgetDomain) { console.warn( `[LevelLayoutPanel] Widget domain mismatch: level='${levelDomain}', widget='${widgetDomain}' (${wcfg.componentTag}). The widget may show incorrect data.`, ); } let widget; try { widget = document.createElement(wcfg.componentTag); } catch (e) { console.warn( `[LevelLayoutPanel] Unknown widget tag: ${wcfg.componentTag}`, ); widget = document.createElement("div"); widget.textContent = `Unknown widget: ${wcfg.componentTag}`; } // Let the widget control its own display mode (e.g. flex for proper header layout) // widget.style.display = 'block'; widget.style.width = "100%"; widget.style.height = "100%"; widget.style.minHeight = "0"; // Apply title as attribute for widgets that render their own header (e.g. SummaryProgressWidget) if (wcfg.title) { widget.setAttribute("title", wcfg.title); } // Apply props if (wcfg.props) { Object.entries(wcfg.props).forEach(([key, value]) => { if (key in widget) { widget[key] = value; } else { widget.setAttribute(key, String(value)); } }); } // Store config reference for event handling widget._widgetInstanceConfig = wcfg; widget.dataset.widgetId = wcfg.id; // Propagate drillable flag from preset config (default true per types.js) if (wcfg.allowDrillDown !== false) { widget.setAttribute("drillable", ""); if ("drillable" in widget) { widget.drillable = true; } } container.appendChild(widget); // Forward drill-down events widget.addEventListener("drill-down-request", this._boundHandleDrillDown); widget.addEventListener( "summary-data-loaded", this._boundHandleWidgetLoaded, ); widget.addEventListener("summary-data-error", this._boundHandleWidgetError); return container; } // ==================== Context Propagation ==================== _propagateContext() { if (!this._levelContext) return; this._widgets.forEach((container, id) => { const widget = container.firstElementChild; const wcfg = (this._resolvedWidgets || []).find((w) => w.id === id); if (widget) { this._applyContextToWidget(widget, wcfg); } }); } /** * @param {HTMLElement} widget * @param {WidgetInstanceConfig} [wcfg] */ _applyContextToWidget(widget, wcfg) { // Set database-id (always, even without levelContext) if (this._databaseId && "databaseId" in widget) { widget.databaseId = this._databaseId; } else if (this._databaseId) { widget.setAttribute("database-id", this._databaseId); } if (!this._levelContext) return; // Set privateFilters BEFORE levelContext so the first _loadData() already has them. const privateFilters = wcfg?.privateFilters || []; if ("privateFilters" in widget) { widget.privateFilters = privateFilters; } // Set levelContext if supported — this triggers the first _loadData() in pull-mode. // Merge level-level dataDomain/baseFilters so widgets receive the correct domain scope. if ("levelContext" in widget) { const mergedContext = this._getMergedLevelContext(); widget.levelContext = mergedContext; } // Set normalization mode from context or attribute const normMode = this._levelContext.normalizationMode || this.getAttribute("normalization-mode"); if (normMode) { widget.setAttribute("normalization-mode", normMode); if ("normalizationMode" in widget) { widget.normalizationMode = normMode; } } // Set report date if (this._levelContext.reportDate) { widget.setAttribute("report-date", this._levelContext.reportDate); } } _propagateLevelData() { if (!this._levelData) return; this._widgets.forEach((container) => { const widget = container.firstElementChild; if (widget && "levelData" in widget) { widget.levelData = this._levelData; } }); } _propagateDatabaseId() { this._widgets.forEach((container) => { const widget = container.firstElementChild; if (widget && this._databaseId) { if ("databaseId" in widget) { widget.databaseId = this._databaseId; } else { widget.setAttribute("database-id", this._databaseId); } } }); } // ==================== Resize / DockableLayoutManager Integration ==================== _setupResizeObserver() { if (this._resizeObserver) return; this._resizeObserver = new ResizeObserver(() => { if (this._resizeRaf) return; this._resizeRaf = requestAnimationFrame(() => { this._resizeRaf = null; this._resizeWidgets(); }); }); if (this._gridContainer) { this._resizeObserver.observe(this._gridContainer); } } /** * Принудительно вызвать resize() на всех ECharts-виджетах внутри панели. * Вызывается DockableLayoutManager при resize/undock модуля. */ resize() { this._resizeWidgets(); } _resizeWidgets() { this._widgets.forEach((container) => { const widget = container.firstElementChild; if (!widget) return; // Try shadow DOM first (most widgets use Shadow DOM) const shadow = widget.shadowRoot; if (shadow && typeof echarts !== "undefined") { const chartDiv = shadow.querySelector("#chart-container"); if (chartDiv) { const inst = echarts.getInstanceByDom(chartDiv); if (inst) inst.resize(); } } // Also try direct child for non-shadow widgets if (typeof echarts !== "undefined") { const chartDiv = widget.querySelector("#chart-container"); if (chartDiv) { const inst = echarts.getInstanceByDom(chartDiv); if (inst) inst.resize(); } } }); } // ==================== Event Handling ==================== _setupEventListeners() { document.addEventListener( "filter-state-change", this._boundHandleFilterStateChange, ); document.addEventListener( "normalization-mode-change", this._boundHandleNormalizationChange, ); document.addEventListener( "data-date-change", this._boundHandleDataDateChange, ); // Runtime-настройки виджетов (например, chartType) — всплывают из shadow DOM this.addEventListener( "widget-setting-change", this._boundHandleWidgetSettingChange, ); } _removeEventListeners() { document.removeEventListener( "filter-state-change", this._boundHandleFilterStateChange, ); document.removeEventListener( "normalization-mode-change", this._boundHandleNormalizationChange, ); document.removeEventListener( "data-date-change", this._boundHandleDataDateChange, ); this.removeEventListener( "widget-setting-change", this._boundHandleWidgetSettingChange, ); } /** * Сохранить runtime-настройку виджета в активный пресет. * Событие `widget-setting-change` диспатчится виджетом (bubbles, composed), * detail: { props: { ... } }. * @param {CustomEvent} e */ _handleWidgetSettingChange(e) { const props = e.detail?.props; if (!props || typeof props !== "object") return; // Ищем в composed path элемент виджета верхнего уровня с dataset.widgetId // (для summary-auto-widget настройка приходит из вложенного // summary-progress-widget, поэтому e.target — не тот элемент). const path = typeof e.composedPath === "function" ? e.composedPath() : []; const holder = path.find( (el) => el && el.dataset && el.dataset.widgetId, ); const widgetId = holder?.dataset.widgetId; if (!widgetId) return; // Обновляем in-memory конфиг, чтобы serializeLayout отдавал актуальные props const wcfg = (this._resolvedWidgets || []).find((w) => w.id === widgetId); if (wcfg) { wcfg.props = { ...(wcfg.props || {}), ...props }; } LayoutPresetService.getInstance() .updateWidgetProps(widgetId, props) .catch((err) => { console.error( `[LevelLayoutPanel] Failed to persist widget setting for ${widgetId}:`, err, ); }); } /** * @param {CustomEvent} e */ _handleDataDateChange(e) { const detail = e.detail; if (!detail || !detail.dataDate) return; const dataDate = detail.dataDate; let reportDate = null; if (dataDate.mode === "timeline") { reportDate = dataDate.weekEnd || null; } else if (dataDate.mode === "range") { if ( dataDate.endIdx != null && typeof window !== "undefined" && window.weekIndexService ) { reportDate = window.weekIndexService.indexToWeekEnd(dataDate.endIdx); } } if (this._levelContext) { const oldReportDate = this._levelContext.reportDate; this._levelContext.reportDate = reportDate; // Always propagate the updated reportDate to manual widgets so they // can recalculate or re-fetch against the current data date. if (oldReportDate !== reportDate) { this._propagateContext(); } if ( oldReportDate !== reportDate && this._config?.generationMode === "auto" ) { // If a build is queued but hasn't started yet, the updated reportDate // will be picked up automatically when the setTimeout callback fires. if (this._autoBuildPending) { return; } // Cancel any in-flight build and start fresh this._autoBuildId++; this._buildAutoWidgets(this._config); } } } /** * @param {CustomEvent} e */ _handleDrillDown(e) { const detail = e.detail; if (!detail) return; const widget = e.target; const wcfg = widget?._widgetInstanceConfig; const levelId = this.getAttribute("level-id") || ""; // Stop the original event from bubbling further — we forward it ourselves. e.stopPropagation(); // Respect allowDrillDown setting from PresetBuilderCanvas if (wcfg?.allowDrillDown === false) { return; } let clickedField = detail.groupField || detail.clickedField || ""; let clickedValue = detail.groupValue || detail.clickedValue || ""; let clickedDisplayName = detail.groupValueLabel || detail.clickedDisplayName || ""; // For auto-level widgets that don't emit their own groupField/groupValue // (e.g. a single-value progress widget), infer drill-down key from the // widget's private filter (code:Type = Value). if (!clickedField && wcfg?.privateFilters?.length === 1) { const pf = wcfg.privateFilters[0]; if (pf.field && pf.field.startsWith("code:")) { clickedField = pf.field; clickedValue = pf.value; clickedDisplayName = pf.value; } } this.dispatchEvent( new CustomEvent("drill-down-request", { bubbles: true, composed: true, detail: { widgetId: wcfg?.id || "", levelId, clickedField, clickedValue, clickedDisplayName, privateFilters: wcfg?.privateFilters || [], }, }), ); } /** * @param {CustomEvent} e */ _handleWidgetLoaded(e) { const widget = e.target; const wcfg = widget?._widgetInstanceConfig; const levelId = this.getAttribute("level-id") || ""; this.dispatchEvent( new CustomEvent("widget-loaded", { bubbles: true, composed: true, detail: { widgetId: wcfg?.id || "", levelId }, }), ); } /** * @param {CustomEvent} e */ _handleWidgetError(e) { const widget = e.target; const wcfg = widget?._widgetInstanceConfig; const levelId = this.getAttribute("level-id") || ""; const detail = e.detail || {}; this.dispatchEvent( new CustomEvent("widget-error", { bubbles: true, composed: true, detail: { widgetId: wcfg?.id || "", levelId, error: detail.error || String(detail), }, }), ); } /** * @param {CustomEvent} e */ _handleFilterStateChange(e) { const detail = e.detail; if (!detail) return; // Re-apply context so widgets see the latest filters. // Pull-model widgets (SummaryWidgetMixin) already listen to // filter-state-change themselves and schedule their own parallel reload // with debounce. Calling reloadWidgets() here would serialize those // refreshes and often double-load each widget. if (this._levelContext) { this._propagateContext(); } // Auto-level widgets are push-model: they do not self-fetch on filter // changes, so we must rebuild them when global filters change. const rebuildActions = [ "code-filter-set", "code-filter-remove", "code-filter-toggle", "code-filter-clear", "code-filter-replace", "wbs-filter-change", "resource-filter-change", ]; if ( this._config?.generationMode === "auto" && rebuildActions.includes(detail.action) ) { this._autoBuildId++; this._buildAutoWidgets(this._config); } } /** * @param {CustomEvent} e */ _handleNormalizationChange(e) { const mode = e.detail?.mode; if (!mode) return; this._widgets.forEach((container) => { const widget = container.firstElementChild; if (widget) { widget.setAttribute("normalization-mode", mode); if ("normalizationMode" in widget) { widget.normalizationMode = mode; } } }); } // ==================== Utilities ==================== /** * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ""; return String(text) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } } if (!customElements.get("level-layout-panel")) { customElements.define("level-layout-panel", LevelLayoutPanel); } export { LevelLayoutPanel }; export default LevelLayoutPanel;