/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/MainDataModule.js
717 строк
25 KB
Starolat Sergei
Фаза 6: полное удаление SQL.js и переход на JS Data Engine
28 июн 2026, 10:02
28 июн 2026, 10:02
ff2000f
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview MainDataModule - Центральный модуль отображения данных (v1.1.5) * @version 1.1.5 * * Изменения в v1.1.5: * - Добавлен слушатель 'filter-state-change' (action 'week-filter-change') * - Перезагружает данные при изменении диапазона недель * * Изменения в v1.1.4: * - Добавлена интеграция с FilterPanel (FLOW-002) * - Слушает событие 'filter-change' и перезагружает данные с фильтрами * + weekRange передается через DataService.getActivities(filters, dbId, { weekRange }) * * Изменения в v1.1.3: * - Иконки заменены на Consta Icons (Storage, Alert, Restart, DataNull) * * Изменения в v1.1.2: * - Добавлена поддержка группировки данных (GroupingPanel integration) * - Добавлено событие grouping-change * - Убран inline onclick из error state * * Изменения в v1.1.1: * - Исправлена утечка памяти ResizeObserver (сохраняется ссылка, disconnect() при очистке) * - Исправлена утечка памяти ECharts (dispose() в disconnectedCallback) * * Изменения в v1.1.0: * - Работа с DataService (JS Data Engine) * - Поддержка событий database-ready и database-changed * - Состояние "Waiting DB" при отсутствии базы */ import dataService from "../services/DataService.js"; import { filterState } from "../services/FilterStateManager.js"; import { Database, AlertCircle, RefreshCw, Inbox } from "./ConstantinIcons.js"; /** * @typedef {import('../types.js').Activity} Activity * @typedef {import('../types.js').Filter} Filter * @typedef {import('../types.js').Grouping} Grouping * @typedef {import('../types.js').GroupedResult} GroupedResult */ class MainDataModule extends HTMLElement { constructor() { super(); /** @type {Activity[]} */ this.data = []; /** @type {any} */ this.chart = null; /** @type {boolean} */ this.isChartInitialized = false; /** @type {string|null} */ this.currentDatabaseId = null; /** @type {boolean} */ this.isWaitingForDatabase = true; /** @type {ResizeObserver|null} */ this._resizeObserver = null; /** @type {Grouping[]} */ this.groupings = []; /** @type {Filter[]} */ this.currentFilters = []; // Bind event handlers this._handleDatabaseReady = this._handleDatabaseReady.bind(this); this._handleDatabaseChanged = this._handleDatabaseChanged.bind(this); this._handleGroupingChange = this._handleGroupingChange.bind(this); this._handleFilterChange = this._handleFilterChange.bind(this); this._handleFilterStateChange = this._handleFilterStateChange.bind(this); this._handleRetryClick = this._handleRetryClick.bind(this); } connectedCallback() { this._renderInitialState(); this.setupTabs(); this._setupEventListeners(); } disconnectedCallback() { this._removeEventListeners(); this._disposeChart(); } /** * Очистка ресурсов ECharts и ResizeObserver * @private */ _disposeChart() { // Отключаем ResizeObserver if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } // Уничтожаем экземпляр ECharts if (this.chart) { this.chart.dispose(); this.chart = null; this.isChartInitialized = false; } } /** * Настройка слушателей событий * @private */ _setupEventListeners() { document.addEventListener("database-ready", this._handleDatabaseReady); document.addEventListener("database-changed", this._handleDatabaseChanged); document.addEventListener("grouping-change", this._handleGroupingChange); document.addEventListener("filter-change", this._handleFilterChange); document.addEventListener( "filter-state-change", this._handleFilterStateChange, ); } /** * Удаление слушателей событий * @private */ _removeEventListeners() { document.removeEventListener("database-ready", this._handleDatabaseReady); document.removeEventListener( "database-changed", this._handleDatabaseChanged, ); document.removeEventListener("grouping-change", this._handleGroupingChange); document.removeEventListener("filter-change", this._handleFilterChange); document.removeEventListener( "filter-state-change", this._handleFilterStateChange, ); } /** * Обработчик события grouping-change * @private * @param {CustomEvent} event */ _handleGroupingChange(event) { console.log("[MainDataModule] Grouping changed:", event.detail); this.groupings = event.detail.groupings || []; this.render(); // Если диаграмма активна, обновляем её if (this.isChartInitialized) { this.renderChart(); } } /** * Обработчик события filter-change * @private * @param {CustomEvent} event */ _handleFilterChange(event) { console.log("[MainDataModule] Filter changed:", event.detail); this.currentFilters = event.detail.filters || []; this.loadData(this.currentFilters); } /** * Обработчик события filter-state-change (week-filter-change) * @private * @param {CustomEvent} event */ _handleFilterStateChange(event) { const detail = event.detail; if (detail && detail.action === "week-filter-change") { console.log("[MainDataModule] Week range changed:", detail); this.loadData(this.currentFilters); } } /** * Обработчик клика по кнопке "Повторить" в error state * @private * @param {Event} event */ _handleRetryClick(event) { const button = event.target.closest(".retry-btn"); if (button) { this.loadData(); } } /** * Обработчик события database-ready * @private * @param {CustomEvent} event */ _handleDatabaseReady(event) { console.log("[MainDataModule] Database ready:", event.detail); this.isWaitingForDatabase = false; this.currentDatabaseId = event.detail.databaseId; this.loadData(); } /** * Обработчик события database-changed * @private * @param {CustomEvent} event */ _handleDatabaseChanged(event) { console.log("[MainDataModule] Database changed:", event.detail); this.currentDatabaseId = event.detail.databaseId; this.loadData(); } /** * Рендер начального состояния (ожидание БД) * @private */ _renderInitialState() { this.innerHTML = ` <div class="tabs" role="tablist" aria-label="Виды отображения данных"> <button class="active" data-tab="table" role="tab" aria-selected="true" aria-controls="table" id="tab-table"> Таблица </button> <button data-tab="diagram" role="tab" aria-selected="false" aria-controls="diagram" id="tab-diagram" tabindex="-1"> Диаграмма </button> </div> <div id="table" class="tab-content active" role="tabpanel" aria-labelledby="tab-table" style="flex-grow: 1; display: flex; flex-direction: column;" aria-live="polite"> <div class="waiting-database-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 40px; text-align: center;"> <span style="display: flex; width: 48px; height: 48px; margin-bottom: 16px; color: var(--text-secondary);">${Database}</span> <h3 style="margin-bottom: 8px; color: var(--text-primary);">Ожидание загрузки данных</h3> <p style="color: var(--text-secondary); max-width: 400px;"> Для начала работы выберите источник данных: загрузите из Primavera API или импортируйте файл .dddb </p> </div> </div> <div id="diagram" class="tab-content" role="tabpanel" aria-labelledby="tab-diagram" style="min-height: 400px; height: 100%; display: none;" aria-live="polite"> </div> `; } /** * Загрузка данных * @param {Filter[]} [filters] - Фильтры */ async loadData(filters = this.currentFilters) { // Проверяем, есть ли активная база // Используем явно заданный currentDatabaseId (например, из database-ready) // или fallback на getActiveDatabaseId() const activeDbId = this.currentDatabaseId || dataService.getActiveDatabaseId(); if (!activeDbId) { this._showWaitingState(); return; } this.currentDatabaseId = activeDbId; this._showLoadingState(); try { const weekRange = filterState.getEffectiveWeekRange(); const options = {}; if (weekRange.startIdx !== null || weekRange.endIdx !== null) { options.weekRange = weekRange; } this.data = await dataService.getActivities(filters, activeDbId, options); this.render(); // Если диаграмма уже инициализирована, обновить её if (this.isChartInitialized) { this.renderChart(); } // Оповещаем другие компоненты о новых данных this.dispatchEvent( new CustomEvent("data-loaded", { detail: { count: this.data.length, databaseId: this.currentDatabaseId, }, bubbles: true, }), ); document.dispatchEvent( new CustomEvent("data-updated", { detail: { data: this.data, timestamp: Date.now(), databaseId: this.currentDatabaseId, }, }), ); } catch (error) { console.error("[MainDataModule] Failed to load data:", error); this._showErrorState(error); } } /** * Показать состояние ожидания * @private */ _showWaitingState() { const tableContainer = this.querySelector("#table"); if (tableContainer) { tableContainer.innerHTML = ` <div class="waiting-database-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 40px; text-align: center;"> <span style="display: flex; width: 48px; height: 48px; margin-bottom: 16px; color: var(--text-secondary);">${Database}</span> <h3 style="margin-bottom: 8px; color: var(--text-primary);">Ожидание загрузки данных</h3> <p style="color: var(--text-secondary); max-width: 400px;"> Для начала работы выберите источник данных: загрузите из Primavera API или импортируйте файл .dddb </p> </div> `; } } /** * Показать состояние загрузки * @private */ _showLoadingState() { const tableContainer = this.querySelector("#table"); if (tableContainer) { tableContainer.innerHTML = ` <div class="loading-state" role="status" aria-live="polite" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 40px;"> <div class="loading-spinner" style="width: 40px; height: 40px; margin-bottom: 16px;"></div> <p class="text-muted text-sm">Загрузка данных...</p> </div> `; } } /** * Показать состояние ошибки * @private * @param {Error} error */ _showErrorState(error) { const tableContainer = this.querySelector("#table"); if (tableContainer) { tableContainer.innerHTML = ` <div class="error-state" role="alert" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 40px; text-align: center;"> <span style="display: flex; width: 48px; height: 48px; margin-bottom: 16px; color: var(--consta-alert);">${AlertCircle}</span> <h3 style="margin-bottom: 8px; color: var(--text-primary);">Ошибка загрузки данных</h3> <p style="color: var(--text-secondary); margin-bottom: 16px;">${error.message}</p> <button class="primary retry-btn" style="display: inline-flex; align-items: center; gap: 8px;"> <span style="display: flex; width: 18px; height: 18px;">${RefreshCw}</span> Повторить </button> </div> `; // Добавляем обработчик клика без inline onclick const retryBtn = tableContainer.querySelector(".retry-btn"); if (retryBtn) { retryBtn.addEventListener("click", this._handleRetryClick); } } this.dispatchEvent( new CustomEvent("data-error", { detail: { error: error.message }, bubbles: true, }), ); } /** * Рендер таблицы данных */ render() { const tableContainer = this.querySelector("#table"); if (!tableContainer) return; if (!this.data || this.data.length === 0) { tableContainer.innerHTML = ` <div class="empty-state" style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; padding: 40px; text-align: center;"> <span style="display: flex; width: 48px; height: 48px; margin-bottom: 16px; color: var(--text-secondary);">${Inbox}</span> <h3 style="margin-bottom: 8px; color: var(--text-primary);">Нет данных</h3> <p style="color: var(--text-secondary);">Для выбранных критериев нет данных для отображения</p> </div> `; return; } // Если есть группировки, рендерим сгруппированную таблицу if (this.groupings && this.groupings.length > 0) { tableContainer.innerHTML = this._renderGroupedTable(); return; } const tableHTML = ` <table class="data-table" role="grid" aria-label="Таблица работ проекта"> <thead> <tr role="row"> <th scope="col" style="width: 50px" role="columnheader">ID</th> <th scope="col" role="columnheader">Название работы</th> <th scope="col" style="width: 90px" role="columnheader">Начало</th> <th scope="col" style="width: 90px" role="columnheader">Окончание</th> </tr> </thead> <tbody> ${this.data .map( (row) => ` <tr role="row"> <td role="gridcell">${row.id}</td> <td class="text-truncate" title="${this._escapeHtml(row.taskName)}" role="gridcell">${row.taskName}</td> <td role="gridcell">${this.formatDate(row.startDate)}</td> <td role="gridcell">${this.formatDate(row.endDate)}</td> </tr> `, ) .join("")} </tbody> </table> `; tableContainer.innerHTML = tableHTML; } /** * Группировка данных * @private * @param {Activity[]} data * @param {Grouping[]} groupings * @returns {GroupedResult[]} */ _groupData(data, groupings) { if (!groupings || groupings.length === 0) { return [ { key: "all", displayName: "Все работы", count: data.length, items: data, }, ]; } const primaryGrouping = groupings[0]; const groups = new Map(); data.forEach((item) => { const key = item[primaryGrouping.field] || "Не указано"; if (!groups.has(key)) { groups.set(key, { key: key, displayName: key, count: 0, items: [], }); } const group = groups.get(key); group.count++; group.items.push(item); }); return Array.from(groups.values()); } /** * Рендер сгруппированной таблицы * @private * @returns {string} */ _renderGroupedTable() { const hasResourceGrouping = (this.groupings || []).some( (g) => g.kind === "resource" || g.field === "resource", ); if (hasResourceGrouping) { return ` <div class="grouped-table" role="region" aria-label="Таблица работ проекта"> <div class="compact-placeholder" role="status" style="min-height:120px;"> Группировка по ресурсам в этом модуле пока не поддерживается. Используйте таблицу WBS/работ. </div> </div> `; } const groupedData = this._groupData(this.data, this.groupings); const primaryGrouping = this.groupings[0]; let html = ` <div class="grouped-table" role="region" aria-label="Таблица работ проекта (сгруппировано по ${primaryGrouping.displayName})"> <div class="grouping-header" style="padding: 8px 16px; background: var(--bg-color); border-bottom: 1px solid var(--border-color); margin-bottom: 8px;"> <span class="text-sm text-muted">Группировка: <strong>${primaryGrouping.displayName}</strong></span> <span class="text-sm text-muted" style="margin-left: 16px;">Групп: ${groupedData.length}</span> </div> `; groupedData.forEach((group) => { html += ` <div class="group-section" style="margin-bottom: 16px;"> <div class="group-header" style="padding: 8px 16px; background: var(--panel-bg); border: 1px solid var(--border-color); border-radius: 4px 4px 0 0; font-weight: 500;"> ${this._escapeHtml(group.displayName)} <span class="text-sm text-muted" style="margin-left: 8px;">(${group.count})</span> </div> <table class="data-table" role="grid" style="border-top: none; border-radius: 0 0 4px 4px;"> <thead> <tr role="row"> <th scope="col" style="width: 50px" role="columnheader">ID</th> <th scope="col" role="columnheader">Название работы</th> <th scope="col" style="width: 90px" role="columnheader">Начало</th> <th scope="col" style="width: 90px" role="columnheader">Окончание</th> </tr> </thead> <tbody> ${group.items .map( (row) => ` <tr role="row"> <td role="gridcell">${row.id}</td> <td class="text-truncate" title="${this._escapeHtml(row.taskName)}" role="gridcell">${row.taskName}</td> <td role="gridcell">${this.formatDate(row.startDate)}</td> <td role="gridcell">${this.formatDate(row.endDate)}</td> </tr> `, ) .join("")} </tbody> </table> </div> `; }); html += "</div>"; return html; } /** * Экранирование HTML * @private * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ""; return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } /** * Форматирование даты * @param {string} dateStr * @returns {string} */ formatDate(dateStr) { if (!dateStr) return "—"; const parts = dateStr.split("-"); if (parts.length !== 3) return dateStr; return `${parts[2]}.${parts[1]}.${parts[0].slice(2)}`; } /** * Настройка табов */ setupTabs() { const tabsContainer = this.querySelector(".tabs"); if (!tabsContainer) return; const tabContents = this.querySelectorAll(".tab-content"); const tabButtons = tabsContainer.querySelectorAll("button"); tabsContainer.addEventListener("click", (event) => { const clickedButton = event.target.closest("button"); if (!clickedButton) return; const tabId = clickedButton.dataset.tab; tabButtons.forEach((button) => { const isActive = button === clickedButton; button.classList.toggle("active", isActive); button.setAttribute("aria-selected", isActive.toString()); button.tabIndex = isActive ? 0 : -1; }); tabContents.forEach((content) => { content.style.display = content.id === tabId ? "flex" : "none"; if (content.id === "table") { content.style.flexDirection = "column"; content.style.flexGrow = "1"; } }); // Инициализируем диаграмму при первом клике на вкладку if (tabId === "diagram" && !this.isChartInitialized) { this.renderChart(); } }); } /** * Рендер диаграммы */ renderChart() { if (typeof echarts === "undefined") { const diagram = this.querySelector("#diagram"); if (diagram) { diagram.innerHTML = "<p>Библиотека ECharts не загружена.</p>"; } return; } const chartDom = this.querySelector("#diagram"); if (!chartDom) return; // Placeholder: resource grouping is not yet supported in charts const hasResourceGrouping = (this.groupings || []).some( (g) => g.kind === "resource" || g.field === "resource", ); if (hasResourceGrouping) { if (this.chart) { this.chart.dispose(); this.chart = null; this.isChartInitialized = false; } chartDom.innerHTML = '<div class="compact-placeholder" role="status">Группировка по ресурсам в диаграммах пока не поддерживается</div>'; return; } // Избегаем повторной инициализации this.chart = this.chart || echarts.init(chartDom); this.isChartInitialized = true; // Обрабатываем данные для диаграммы (группировка по типу работы) const typeCounts = this.data.reduce((acc, curr) => { const type = curr.taskType || "Не указано"; acc[type] = (acc[type] || 0) + 1; return acc; }, {}); const chartData = Object.keys(typeCounts).map((key) => ({ name: key, value: typeCounts[key], })); const option = { tooltip: { trigger: "item", }, legend: { top: "5%", left: "center", }, series: [ { name: "Тип работы", type: "pie", radius: ["40%", "70%"], avoidLabelOverlap: false, itemStyle: { borderRadius: 10, borderColor: "#fff", borderWidth: 2, }, label: { show: false, position: "center", }, emphasis: { label: { show: true, fontSize: "20", fontWeight: "bold", }, }, labelLine: { show: false, }, data: chartData, }, ], }; this.chart.setOption(option); // Делаем диаграмму адаптивной // Отключаем предыдущий observer если есть if (this._resizeObserver) { this._resizeObserver.disconnect(); } this._resizeObserver = new ResizeObserver(() => { if (this.chart) { this.chart.resize(); } }); this._resizeObserver.observe(chartDom); } /** * Переключение на другую базу данных * @param {string} databaseId */ switchDatabase(databaseId) { this.currentDatabaseId = databaseId; this.loadData(); } } customElements.define("main-data-module", MainDataModule); export { MainDataModule }; export default MainDataModule;