/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
js/components/DataGrid.js
2 544 строки
80 KB
Starolat Sergei
feat: UI локального критического пути (COMP-033) и исправления расчёта ЛКП (SYS-024 v1.1.0)
22 июл 2026, 08:38
22 июл 2026, 08:38
96ab31b
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview DataGrid - Универсальное data-agnostic табличное ядро * @version 0.1.0 * @element data-grid * * Базовый компонент таблицы с виртуализацией, автофильтрами, * мультисортировкой, выделением строк, копированием в буфер и настройкой колонок. * * @fires grid-row-click - При клике по строке * @fires grid-row-dblclick - При двойном клике по строке * @fires grid-selection-change - При изменении выбора * @fires grid-sort-change - При изменении сортировки * @fires grid-filter-change - При изменении автофильтра * @fires grid-columns-change - При изменении конфигурации столбцов * @fires grid-row-expand - При разворачивании/сворачивании строки */ import { Settings, FilterLucide, Copy, Download, X, ChevronRight, ChevronDown } from "./ConstantinIcons.js"; import { ToastManager } from "./ToastManager.js"; /** * @typedef {import('../types.js').TableColumn} TableColumn * @typedef {import('../types.js').TableRow} TableRow * @typedef {import('../types.js').AutoFilter} AutoFilter * @typedef {import('../types.js').SortRule} SortRule * @typedef {import('../types.js').CopyOptions} CopyOptions */ /** * @typedef {import('../services/DataGridDataSource.js').DataGridDataSource} DataGridDataSource * @typedef {import('../services/DataGridDataSource.js').ToolbarItem} ToolbarItem */ /** * Конфигурация виртуализации * @typedef {Object} VirtualScrollConfig * @property {number} rowHeight - Базовая высота строки (px) * @property {number} bufferSize - Количество буферных строк * @property {number} overscan - Дополнительный overscan (px) */ class DataGrid extends HTMLElement { static get observedAttributes() { return ["row-height", "embedded"]; } constructor() { super(); this.attachShadow({ mode: "open" }); // Состояние /** @type {TableColumn[]} */ this.columns = []; /** @type {TableRow[]} */ this.allRows = []; /** @type {TableRow[]} */ this.visibleRows = []; /** @type {AutoFilter[]} */ this.activeFilters = []; /** @type {Set<string>} */ this.selectedRowIds = new Set(); /** @type {Set<string>} */ this.expandedRowIds = new Set(); /** @type {SortRule[]} */ this.sortRules = []; /** @type {string|null} */ this.sortColumn = null; /** @type {'asc'|'desc'} */ this.sortDirection = "asc"; /** @type {boolean} */ this.isLoading = false; /** @type {boolean} */ this.hideEmptyGroups = true; /** @type {boolean} */ this._treeColumnAutoFit = true; /** @type {number} */ this._minTreeWidth = 200; /** @type {DataGridDataSource|null} */ this._dataSource = null; /** @type {boolean} */ this._dataSourceAttached = false; /** @type {VirtualScrollConfig} */ this.virtualConfig = { rowHeight: parseInt(this.getAttribute("row-height")) || 20, bufferSize: 5, overscan: 50, }; // DOM элементы /** @type {HTMLElement|null} */ this.tableBody = null; /** @type {HTMLElement|null} */ this.tableHeaderRow = null; /** @type {HTMLElement|null} */ this.rowsContainer = null; /** @type {HTMLElement|null} */ this.rowsViewport = null; /** @type {HTMLElement|null} */ this.footerElement = null; // Пул DOM элементов /** @type {HTMLElement[]} */ this.rowPool = []; this.poolSize = 60; // ResizeObserver /** @type {ResizeObserver|null} */ this.resizeObserver = null; // Высоты строк /** @type {number[]} */ this._rowHeights = []; /** @type {Float64Array|null} */ this._heightPrefixSum = null; // Canvas для измерения ширины текста /** @type {CanvasRenderingContext2D|null} */ this._canvasCtx = null; /** @type {Map<string, number>} */ this._textWidthCache = new Map(); /** @type {Map<string, number>} */ this._rowHeightCache = new Map(); /** @type {number|null} */ this._fitTreeColumnRaf = null; /** @type {number|null} */ this._rafId = null; // Bound handlers this._boundHandleScroll = this._handleScroll.bind(this); this._boundHandleResize = this._handleResize.bind(this); this._boundHandleKeydown = this._handleKeydown.bind(this); this._boundHandleCopy = this._handleCopy.bind(this); this._boundHandleDataSourceDataChanged = this._handleDataSourceDataChanged.bind(this); this._boundHandleDataSourceToolbarConfigChanged = this._handleDataSourceToolbarConfigChanged.bind(this); } // ==================== Lifecycle ==================== connectedCallback() { this.render(); this._initializeElements(); this._setupEventListeners(); this._initializeRowPool(); this._updateCopyButtonState(); this.resizeObserver = new ResizeObserver(() => this._handleResize()); this.resizeObserver.observe(this); } disconnectedCallback() { this._detachDataSource(); this._cleanup(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch (name) { case "row-height": this.virtualConfig.rowHeight = parseInt(newValue) || 20; this._updateVirtualScroll(); break; } } // ==================== Public API ==================== /** * Установить конфигурацию столбцов * @param {TableColumn[]} columns */ setColumns(columns) { const next = columns.map((c) => ({ ...c })); const prev = this.columns || []; const changed = next.length !== prev.length || next.some((c, i) => { const p = prev[i]; return !p || c.id !== p.id || c.visible !== p.visible; }); // Сохраняем авто-подобранную ширину tree-колонки, пока набор колонок // не изменился по составу/видимости. Внешний DataSource передаёт // исходную ширину из конфига, но после автофита она уже неактуальна. if (!changed) { const treeCol = this.columns.find((c) => c.isTreeColumn); if (treeCol) { const nextTree = next.find((c) => c.id === treeCol.id); if (nextTree) { nextTree.width = treeCol.width; } } } this.columns = next; if (changed) { this._treeColumnAutoFit = true; this._textWidthCache.clear(); this._rowHeightCache.clear(); } if (this.tableHeaderRow) { this.tableHeaderRow.innerHTML = this._renderHeaderCells() + '<div class="header-spacer"></div>'; this._setupHeaderEventListeners(); } this._applyFilters(); this._updateContentWidth(); this.dispatchEvent( new CustomEvent("grid-columns-change", { detail: { columns: this.columns }, bubbles: true }), ); } /** * Получить текущую конфигурацию столбцов * @returns {TableColumn[]} */ getColumns() { return [...this.columns]; } /** * Установить строки данных * @param {TableRow[]} rows */ setRows(rows) { this.allRows = rows.map((r) => ({ ...r })); this._textWidthCache.clear(); this._rowHeightCache.clear(); this._updateTreeLevelStyles(); this._applySort(); this._applyFilters(); } /** * Получить все строки * @returns {TableRow[]} */ getRows() { return this.allRows; } /** * Получить видимые строки * @returns {TableRow[]} */ getVisibleRows() { return this.visibleRows; } /** * Применить автофильтры * @param {AutoFilter[]} filters */ applyFilters(filters) { this.activeFilters = filters.map((f) => ({ ...f, isActive: true })); this._applyFilters(); } /** * Очистить фильтры * @param {string} [columnId] */ clearFilters(columnId) { if (columnId) { this.activeFilters = this.activeFilters.filter((f) => f.columnId !== columnId); } else { this.activeFilters = []; } this._applyFilters(); } /** * Сортировать по столбцу * @param {string} columnId * @param {'asc'|'desc'} [direction='asc'] */ sortBy(columnId, direction = "asc") { this.sortRules = [{ columnId, direction }]; this.sortColumn = columnId; this.sortDirection = direction; this._applySort(); this._updateHeaderCells(); this._updateVirtualScroll(); } /** * Развернуть все строки с hasChildren */ expandAll() { if (this._dataSource) { this._dataSource.expandAll(); return; } this.expandedRowIds.clear(); for (const row of this.allRows) { if (row.hasChildren) { this.expandedRowIds.add(row.id); } } this._applyFilters(); } /** * Свернуть все строки */ collapseAll() { if (this._dataSource) { this._dataSource.collapseAll(); return; } this.expandedRowIds.clear(); this._applyFilters(); } /** * Развернуть конкретную строку * @param {string} rowId */ expandRow(rowId) { if (this._dataSource) { this._dataSource.expandRow(rowId); return; } const row = this.allRows.find((r) => r.id === rowId); if (row?.hasChildren) { this.expandedRowIds.add(rowId); this._applyFilters(); } } /** * Свернуть конкретную строку * @param {string} rowId */ collapseRow(rowId) { if (this._dataSource) { this._dataSource.collapseRow(rowId); return; } this.expandedRowIds.delete(rowId); this._applyFilters(); } /** * Получить или установить источник данных. * @param {DataGridDataSource|null} dataSource */ set dataSource(dataSource) { this._detachDataSource(); this._dataSource = dataSource; this._attachDataSource(); } /** * @returns {DataGridDataSource|null} */ get dataSource() { return this._dataSource; } /** * Подписаться на события DataSource. * @private */ _attachDataSource() { if (!this._dataSource || this._dataSourceAttached) return; this._dataSource.addEventListener("data-changed", this._boundHandleDataSourceDataChanged); this._dataSource.addEventListener("toolbar-config-changed", this._boundHandleDataSourceToolbarConfigChanged); this._dataSourceAttached = true; // Привязываем expandedRowIds к DataSource, чтобы он мог фильтровать дерево this._dataSource._expandedRowIds = this.expandedRowIds; this._handleDataSourceDataChanged({ detail: { rows: this._dataSource.getRows(), columns: this._dataSource.getColumns(), loading: this._dataSource.isLoading(), error: this._dataSource.getError() } }); } /** * Отписаться от событий DataSource. * @private */ _detachDataSource() { if (!this._dataSource || !this._dataSourceAttached) return; this._dataSource.removeEventListener("data-changed", this._boundHandleDataSourceDataChanged); this._dataSource.removeEventListener("toolbar-config-changed", this._boundHandleDataSourceToolbarConfigChanged); this._dataSourceAttached = false; } /** * Обработчик события data-changed от DataSource. * @param {CustomEvent} event * @private */ _handleDataSourceDataChanged(event) { const { rows, columns, loading, error } = event.detail || {}; this.isLoading = !!loading; if (error) { // DataSource already logs the error; surface via footer if needed. } if (columns && columns.length > 0) { this.setColumns(columns); } if (rows) { this.setRows(rows); } } /** * Обработчик события toolbar-config-changed от DataSource. * @param {CustomEvent} event * @private */ _handleDataSourceToolbarConfigChanged(event) { // Toolbar будет реализован на этапе 3. Пока эмитим событие наружу. this.dispatchEvent( new CustomEvent("grid-toolbar-config-changed", { detail: event.detail, bubbles: true, }), ); } /** * Прокрутить к строке * @param {string} rowId */ scrollToRow(rowId) { const index = this.visibleRows.findIndex((r) => r.id === rowId); if (index !== -1 && this.tableBody) { this.tableBody.scrollTop = index * this.virtualConfig.rowHeight; this._renderVisibleRows(); } } /** * Копировать в буфер обмена * @param {CopyOptions} [options={}] * @returns {Promise<void>} */ async copyToClipboard(options = {}) { const opts = { includeHeaders: true, expandedOnly: false, delimiter: "\t", format: "tsv", ...options, }; try { let rows = this.selectedRowIds.size > 0 ? this.allRows.filter((r) => this.selectedRowIds.has(r.id)) : opts.expandedOnly ? this.visibleRows : this.allRows; const columns = opts.columns ? this.columns.filter((c) => opts.columns.includes(c.id) && c.visible) : this.columns.filter((c) => c.visible); let tsv = ""; if (opts.includeHeaders) { tsv += columns.map((c) => c.header).join(opts.delimiter) + "\n"; } let rowIndex = 0; for (const row of rows) { rowIndex++; const values = columns.map((col) => this._formatCellValue(row, col, rowIndex)); tsv += values.join(opts.delimiter) + "\n"; } await navigator.clipboard.writeText(tsv); ToastManager.getInstance().success("Скопировано в буфер обмена", 3000); this.dispatchEvent( new CustomEvent("grid-copied", { detail: { rowCount: rows.length, columnCount: columns.length, format: opts.format }, bubbles: true, }), ); } catch (error) { console.error("[DataGrid] Copy failed:", error); ToastManager.getInstance().error("Ошибка копирования", 4000); } } /** * Экспорт в CSV * @param {string} [filename='data.csv'] */ exportToCsv(filename = "data.csv") { this.copyToClipboard({ format: "csv", delimiter: ";" }); } /** * Очистить ресурсы */ dispose() { this._cleanup(); } // ==================== Rendering ==================== render() { const isEmbedded = this.getAttribute("embedded") !== null; this.shadowRoot.innerHTML = ` <style>${this._getStyles()}</style> <style id="dynamic-tree-levels"></style> <div class="data-grid ${isEmbedded ? "embedded" : ""}" role="grid" aria-label="Таблица данных"> ${isEmbedded ? "" : ` <div class="table-header"> <div class="header-title"> <span>ТАБЛИЦА</span> </div> <div class="header-actions"> <button class="btn-icon" id="btn-copy" title="Копировать для Excel" disabled>${Copy}</button> <button class="btn-icon" id="btn-settings" title="Настройки столбцов">${Settings}</button> </div> </div> `} <div class="table-container" role="rowgroup"> <div class="table-header-row" role="row"> ${this._renderHeaderCells()} <div class="header-spacer"></div> </div> <div class="table-body" role="rowgroup" tabindex="0"> <div class="rows-container"> <div class="rows-viewport"></div> </div> <div class="empty-state" style="display: none;">Нет данных</div> </div> </div> <div class="table-footer"> <span class="footer-info">Загрузка...</span> </div> </div> `; } _getStyles() { return ` :host { display: block; width: 100%; height: 100%; font-family: var(--font-primary, Inter, sans-serif); } .data-grid { display: flex; flex-direction: column; height: 100%; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #e5e5e5); border-radius: var(--radius-s, 4px); overflow: hidden; } .data-grid.embedded { border-radius: 0; border: none; } .data-grid.resizing, .data-grid.resizing * { cursor: col-resize !important; user-select: none !important; } .table-header { display: flex; align-items: center; justify-content: space-between; min-height: 24px; padding: var(--space-2xs, 4px) var(--space-xs, 8px); background: var(--color-bg-default, #fff); border-bottom: 1px solid var(--color-bg-border, #e5e5e5); flex-shrink: 0; } .header-title { display: flex; align-items: center; gap: var(--space-2xs, 4px); font-size: var(--size-text-2xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-primary, #333); } .header-actions { display: flex; gap: var(--space-2xs, 4px); align-items: center; } .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; background: transparent; border: none; border-radius: var(--radius-xs, 2px); color: var(--color-typo-secondary, #666); cursor: pointer; transition: all 0.15s; } .btn-icon:hover { background: var(--color-bg-ghost, #eee); color: var(--color-typo-primary, #333); } .btn-icon svg { width: 16px; height: 16px; } .btn-icon:disabled { opacity: 0.5; cursor: not-allowed; color: var(--color-typo-ghost, #999); pointer-events: none; } .table-container { display: flex; flex-direction: column; flex: 1; overflow: hidden; } .table-header-row { display: flex; background: var(--color-bg-default, #fff); border-bottom: 1px solid var(--panel-border-color); flex-shrink: 0; will-change: transform; } .header-cell { display: flex; align-items: center; justify-content: center; padding: 0 var(--space-xs, 8px); height: 20px; font-size: var(--size-text-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-primary, #333); border-right: 1px solid var(--panel-border-color); user-select: none; cursor: pointer; position: relative; box-sizing: border-box; flex-shrink: 0; align-self: stretch; } .table-header-row > .header-cell:nth-last-child(2) { border-right: none; } .header-cell:hover { background: var(--color-bg-ghost, #eee); } .header-cell.sortable .sort-indicator { display: inline-flex; align-items: center; margin-right: var(--space-2xs, 4px); opacity: 0.3; font-size: 8px; min-width: 8px; } .header-cell.sort-asc .sort-indicator, .header-cell.sort-desc .sort-indicator { opacity: 1; } .sort-priority { margin-left: 2px; font-size: 7px; opacity: 0.8; } .header-cell-content { display: flex; align-items: center; justify-content: center; flex: 1; overflow: hidden; } .header-cell-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .header-filter-btn { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; position: absolute; right: 2px; top: 50%; transform: translateY(-50%); padding: 0; background: transparent; border: none; border-radius: var(--radius-xs, 2px); color: var(--color-typo-ghost, #999); cursor: pointer; opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s; } .header-cell:hover .header-filter-btn { opacity: 1; } .header-filter-btn.active { opacity: 1; color: var(--color-control-typo-primary, #fff); background: var(--color-control-bg-primary, #0071b2); } .header-filter-btn svg { width: 12px; height: 12px; } .resize-handle { position: absolute; top: 0; right: -4px; bottom: 0; width: 10px; cursor: col-resize; z-index: 10; background: transparent; transition: background 0.15s; } .resize-handle::after { content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 2px; height: 100%; background: transparent; transition: background 0.15s; } .resize-handle:hover::after, .resize-handle:active::after { background: var(--color-control-bg-primary, #0071b2); } .resize-handle:hover, .resize-handle:active { background: transparent; } .table-body { flex: 1; overflow-y: auto; overflow-x: auto; position: relative; } .table-body:focus { outline: none; } .rows-container { position: relative; } .rows-viewport { position: absolute; left: 0; width: 100%; display: flex; flex-direction: column; } .table-row { display: flex; align-items: center; min-height: 20px; height: auto; width: 100%; transition: background-color 0.1s; } .table-row:hover { background: var(--color-bg-ghost, #f5f5f5); } .table-row.selected { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .table-row.selected .table-cell { color: var(--color-control-typo-primary, #fff); } .table-row.tree-row { background: var(--color-bg-default, #fff); font-weight: 500; } .table-row.leaf-row { background: var(--color-bg-default, #fff); } .table-row.leaf-row.selected { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .table-row.leaf-row.selected .table-cell { color: var(--color-control-typo-primary, #fff); } .table-row.leaf-row:hover { background: var(--color-bg-ghost, #f5f5f5); } .table-row.leaf-row.selected:hover { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .table-row.tree-row:hover { background: var(--color-bg-ghost, #f5f5f5); } .table-row.tree-row.selected { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .table-row.tree-row.selected:hover { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .table-row.tree-row.selected .table-cell { color: var(--color-control-typo-primary, #fff); } .table-row.tree-row { font-weight: 500; border-top: 1px solid transparent; } .table-row[data-level="0"].tree-row { font-weight: 700; border-top-color: var(--border-light); } .table-cell { display: flex; align-items: center; justify-content: flex-end; padding: 2px var(--space-xs, 8px); min-height: 20px; height: auto; font-size: var(--size-text-xs, 12px); color: var(--color-typo-primary, #333); border-right: 1px solid var(--panel-border-color); border-bottom: 1px solid var(--panel-border-color); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; box-sizing: border-box; flex-shrink: 0; align-self: stretch; } .table-cell[data-column-id="name"] { justify-content: flex-start; } .table-cell.cell-wrap { white-space: normal; word-break: break-word; line-height: 1.3; } .table-cell.cell-date { font-family: var(--font-mono, "IBM Plex Mono", "JetBrains Mono", "SF Mono", Monaco, Inconsolata, "Fira Code", Consolas, monospace); font-variant-numeric: tabular-nums; } .table-cell.cell-dimmed { color: var(--color-typo-ghost, #999); } .table-cell.cell-negative { color: var(--color-typo-alert, var(--error-color, #eb5757)); } /* COMP-033: цветовые зоны колонки «Резерв до вехи» (спека F.7) */ .table-cell.localcp-cell-critical { color: var(--color-typo-alert, var(--error-color, #eb5757)); background: var(--color-bg-alert, var(--error-bg, rgba(235, 87, 87, 0.12))); } .table-cell.localcp-cell-near { color: var(--color-typo-warning, var(--warning-color, #f38b00)); background: var(--color-bg-warning, var(--warning-bg, rgba(243, 139, 0, 0.12))); } .table-cell.localcp-cell-negative { font-weight: 700; } .table-row.selected .table-cell.cell-dimmed { opacity: 0.55; } .expand-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: var(--space-2xs, 4px); cursor: pointer; color: var(--color-typo-secondary, #666); transition: transform 0.15s; flex-shrink: 0; } .expand-icon:hover { color: var(--color-typo-primary, #333); } .table-row.selected .expand-icon { color: var(--color-control-typo-primary, #fff); } .expand-icon svg { width: 14px; height: 14px; } .expand-icon.leaf { visibility: hidden; cursor: default; } .tree-indent { display: inline-block; width: 16px; flex-shrink: 0; } .table-footer { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px); border-top: 1px solid var(--color-bg-border, #e5e5e5); background: var(--color-bg-default, #fff); font-size: var(--size-text-xs, 12px); color: var(--color-typo-secondary, #666); flex-shrink: 0; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-xl, 32px); color: var(--color-typo-ghost, #999); text-align: center; flex: 1; } .table-body::-webkit-scrollbar { width: 8px; height: 8px; } .table-body::-webkit-scrollbar-track { background: var(--color-bg-secondary, #f5f5f5); } .table-body::-webkit-scrollbar-thumb { background: var(--color-bg-border, #ccc); border-radius: var(--radius-xs, 2px); } .table-body::-webkit-scrollbar-thumb:hover { background: var(--color-typo-ghost, #999); } .filter-dropdown { position: fixed; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #e5e5e5); border-radius: var(--radius-s, 4px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); min-width: 220px; max-height: none; font-size: var(--size-text-xs, 12px); display: flex; flex-direction: column; z-index: 1000; } .filter-dropdown-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px) var(--space-s, 12px); border-bottom: 1px solid var(--color-bg-border, #e5e5e5); } .filter-dropdown-title { font-weight: 600; color: var(--color-typo-primary, #333); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; } .filter-dropdown-close { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; background: transparent; border: none; color: var(--color-typo-ghost, #999); cursor: pointer; } .filter-dropdown-close:hover { color: var(--color-typo-primary, #333); } .filter-dropdown-close svg { width: 12px; height: 12px; } .filter-dropdown-body { padding: var(--space-xs, 8px) var(--space-s, 12px); display: flex; flex-direction: column; gap: var(--space-xs, 8px); } .filter-operator-row, .filter-value-row { display: flex; flex-direction: column; gap: var(--space-2xs, 4px); } .filter-label { font-size: var(--size-text-2xs, 8px); color: var(--color-typo-secondary, rgba(0, 32, 51, 0.6)); text-transform: uppercase; letter-spacing: 0.05em; } .filter-operator-select, .filter-value-input { width: 100%; height: 28px; padding: 0 var(--space-xs, 8px); font-size: var(--size-text-xs, 10px); border: 1px solid var(--color-control-bg-border-default, rgba(0, 66, 105, 0.25)); border-radius: var(--radius-xs, 2px); background: var(--color-control-bg-default, #fff); color: var(--color-typo-primary, #333); box-sizing: border-box; } .filter-operator-select:focus, .filter-value-input:focus { outline: none; border-color: var(--color-control-bg-border-focus, #0071b2); } .filter-between-inputs { display: flex; align-items: center; gap: var(--space-xs, 8px); } .filter-between-inputs .filter-value-input { flex: 1; min-width: 0; } .filter-between-sep { color: var(--color-typo-ghost, #999); font-size: var(--size-text-xs, 12px); } .filter-dropdown-footer { display: flex; justify-content: flex-end; gap: var(--space-xs, 8px); padding: var(--space-xs, 8px) var(--space-s, 12px); border-top: 1px solid var(--color-bg-border, #e5e5e5); } .filter-btn { height: 24px; padding: 0 var(--space-xs, 8px); border: 1px solid transparent; border-radius: var(--radius-xs, 2px); font-size: var(--size-text-3xs, 8px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; } .filter-btn-primary { background: var(--color-control-bg-primary, #0071b2); color: var(--color-control-typo-primary, #fff); } .filter-btn-primary:hover { background: var(--color-control-bg-primary-hover, #005a8e); } .filter-btn-secondary { background: var(--color-control-bg-ghost, rgba(0, 66, 105, 0.07)); color: var(--color-typo-ghost, rgba(0, 32, 51, 0.3)); border-color: var(--color-control-bg-border-default, rgba(0, 66, 105, 0.25)); } .filter-btn-secondary:hover { background: var(--color-bg-ghost, #eee); } .settings-dropdown { position: fixed; background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border, #e5e5e5); border-radius: var(--radius-s, 4px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); min-width: 180px; max-width: 240px; max-height: 320px; overflow: hidden; display: flex; flex-direction: column; z-index: 1000; font-size: var(--size-text-xs, 12px); } .settings-dropdown-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, 8px) var(--space-s, 12px); border-bottom: 1px solid var(--color-bg-border, #e5e5e5); flex-shrink: 0; } .settings-dropdown-title { font-weight: 600; color: var(--color-typo-primary, #333); font-size: var(--size-text-xs, 12px); } .settings-dropdown-close { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; background: transparent; border: none; color: var(--color-typo-ghost, #999); cursor: pointer; } .settings-dropdown-close:hover { color: var(--color-typo-primary, #333); } .settings-dropdown-close svg { width: 12px; height: 12px; } .settings-dropdown-body { padding: var(--space-2xs, 4px) 0; overflow-y: auto; display: flex; flex-direction: column; } .settings-item { display: flex; align-items: center; gap: var(--space-xs, 8px); padding: var(--space-2xs, 4px) var(--space-s, 12px); cursor: pointer; font-size: var(--size-text-xs, 12px); color: var(--color-typo-primary, #333); transition: background 0.15s; } .settings-item:hover { background: var(--color-bg-ghost, #f5f5f5); } .settings-item input[type="checkbox"] { flex-shrink: 0; width: 14px; height: 14px; margin: 0; } .settings-item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `; } _renderHeaderCells() { return this.columns .filter((col) => col.visible) .map((col) => { const ruleIndex = this.sortRules.findIndex((r) => r.columnId === col.id); const rule = ruleIndex >= 0 ? this.sortRules[ruleIndex] : null; const sortClass = rule ? (rule.direction === "asc" ? "sort-asc" : "sort-desc") : ""; const hasFilter = this.activeFilters.some((f) => f.columnId === col.id); const sortIndicator = col.sortable && rule ? `<span class="sort-indicator">${rule.direction === "asc" ? "▲" : "▼"}${ this.sortRules.length > 1 ? `<span class="sort-priority">${ruleIndex + 1}</span>` : "" }</span>` : ""; return ` <div class="header-cell ${col.sortable ? "sortable" : ""} ${sortClass}" role="columnheader" data-column-id="${col.id}" style="width: ${col.width}px; flex-shrink: 0;"> <div class="header-cell-content"> ${sortIndicator} <span class="header-cell-text">${col.header}</span> ${col.filterable ? ` <button class="header-filter-btn ${hasFilter ? "active" : ""}" data-column-id="${col.id}" aria-label="Фильтр ${col.header}">${FilterLucide}</button> ` : ""} </div> <div class="resize-handle" data-column-id="${col.id}"></div> </div> `; }) .join(""); } _updateHeaderCells() { if (!this.tableHeaderRow) return; const scrollLeft = this.tableBody ? this.tableBody.scrollLeft : 0; this.tableHeaderRow.innerHTML = this._renderHeaderCells(); const spacer = document.createElement("div"); spacer.className = "header-spacer"; this.tableHeaderRow.appendChild(spacer); this._setupHeaderEventListeners(); if (this.tableBody) { this.tableBody.scrollLeft = scrollLeft; this.tableHeaderRow.style.transform = `translateX(-${scrollLeft}px)`; } } // ==================== Initialization ==================== _initializeElements() { this.tableBody = this.shadowRoot.querySelector(".table-body"); this.tableHeaderRow = this.shadowRoot.querySelector(".table-header-row"); this.rowsContainer = this.shadowRoot.querySelector(".rows-container"); this.rowsViewport = this.shadowRoot.querySelector(".rows-viewport"); this.footerElement = this.shadowRoot.querySelector(".footer-info"); this.emptyStateElement = this.shadowRoot.querySelector(".empty-state"); this.dynamicLevelStyles = this.shadowRoot.getElementById("dynamic-tree-levels"); } _initializeRowPool() { this.rowPool = []; for (let i = 0; i < this.poolSize; i++) { const rowEl = document.createElement("div"); rowEl.className = "table-row"; rowEl.style.display = "none"; rowEl.innerHTML = '<div class="table-cell">Loading...</div>'; this.rowsViewport.appendChild(rowEl); this.rowPool.push(rowEl); } } _setupEventListeners() { if (this.tableBody) { this.tableBody.addEventListener("scroll", this._boundHandleScroll, { passive: true }); this.tableBody.addEventListener("keydown", this._boundHandleKeydown); this.tableBody.addEventListener("copy", this._boundHandleCopy); } const copyBtn = this.shadowRoot.getElementById("btn-copy"); const settingsBtn = this.shadowRoot.getElementById("btn-settings"); copyBtn?.addEventListener("click", () => this.copyToClipboard()); settingsBtn?.addEventListener("click", () => this._openSettings()); this.rowsViewport?.addEventListener("click", (e) => { const expandIcon = e.target.closest(".expand-icon:not(.leaf)"); if (expandIcon) { e.stopPropagation(); const rowEl = expandIcon.closest(".table-row"); const index = parseInt(rowEl?.dataset.index || "-1", 10); const row = this.visibleRows[index]; if (row && row.id === rowEl?.dataset.rowId) { this._toggleRowExpand(row); } return; } const rowEl = e.target.closest(".table-row"); if (!rowEl) return; const index = parseInt(rowEl.dataset.index, 10); const row = this.visibleRows[index]; if (row && row.id === rowEl.dataset.rowId) { this._handleRowClick(row, e); } }); this.rowsViewport?.addEventListener("dblclick", (e) => { const rowEl = e.target.closest(".table-row"); if (!rowEl) return; const index = parseInt(rowEl.dataset.index, 10); const row = this.visibleRows[index]; if (row && row.id === rowEl.dataset.rowId) { this._handleRowDblClick(row, e); } }); this.rowsViewport?.addEventListener("mousedown", (e) => { if (e.shiftKey || e.ctrlKey || e.metaKey) { e.preventDefault(); } }); this._setupHeaderEventListeners(); } _setupHeaderEventListeners() { this.tableHeaderRow?.querySelectorAll(".header-cell").forEach((cell) => { cell.addEventListener("click", (e) => { if (e.target.closest(".header-filter-btn") || e.target.closest(".resize-handle")) return; const columnId = cell.dataset.columnId; this._handleSort(columnId, e); }); }); this.tableHeaderRow?.querySelectorAll(".header-filter-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); this._openFilterDropdown(btn.dataset.columnId, btn); }); }); this.tableHeaderRow?.querySelectorAll(".resize-handle").forEach((handle) => { handle.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); this._startColumnResize(e, handle.dataset.columnId); }); }); } // ==================== Virtual Scroll ==================== _handleScroll() { if (this._rafId) return; this._rafId = requestAnimationFrame(() => { this._rafId = null; this._renderVisibleRows(); if (this.tableHeaderRow && this.tableBody) { this.tableHeaderRow.style.transform = `translateX(-${this.tableBody.scrollLeft}px)`; } }); } _handleResize() { if (this._fitTreeColumnRaf) return; this._fitTreeColumnRaf = requestAnimationFrame(() => { this._fitTreeColumnRaf = null; this._fitTreeColumn(); this._updateVirtualScroll(); }); } _updateVirtualScroll() { this._computeVisibleRows(); this._computeRowHeights(); this._renderVisibleRows(); this._updateFooter(); } _computeVisibleRows() { if (this.activeFilters.length === 0) { this.visibleRows = this._filterExpandedRows(this.allRows); return; } if (!this.hideEmptyGroups) { this.visibleRows = this._filterExpandedRows( this.allRows.filter((row) => { if (row.hasChildren) return true; return this.activeFilters.every((filter) => this._matchFilter(row, filter)); }), ); return; } // Скрываем пустые группы const parentMap = new Map(); const stack = []; for (const row of this.allRows) { while (stack.length > 0 && stack[stack.length - 1].level >= row.level) { stack.pop(); } if (stack.length > 0) { parentMap.set(row.id, stack[stack.length - 1].id); } stack.push(row); } const visibleIds = new Set(); for (const row of this.allRows) { if (!row.hasChildren && this.activeFilters.every((filter) => this._matchFilter(row, filter))) { visibleIds.add(row.id); } } for (const id of visibleIds) { let currentId = id; while (parentMap.has(currentId)) { const parentId = parentMap.get(currentId); visibleIds.add(parentId); currentId = parentId; } } this.visibleRows = this._filterExpandedRows( this.allRows.filter((row) => visibleIds.has(row.id)), ); } _filterExpandedRows(rows) { const result = []; const stack = []; for (const row of rows) { const level = row.level ?? 0; while (stack.length > 0 && (stack[stack.length - 1].level ?? 0) >= level) { stack.pop(); } const parent = stack.length > 0 ? stack[stack.length - 1] : null; const parentVisible = parent === null || (parent.expanded && parent.visible); const isVisible = parentVisible; const clone = { ...row, visible: isVisible, expanded: this.expandedRowIds.has(row.id) }; if (isVisible) { result.push(clone); } stack.push(clone); } return result; } _computeRowHeights() { this._rowHeights = this.visibleRows.map((r) => this._estimateRowHeight(r)); this._heightPrefixSum = null; } _recomputeHeightPrefixSum() { const sum = new Float64Array(this._rowHeights.length + 1); sum[0] = 0; for (let i = 0; i < this._rowHeights.length; i++) { sum[i + 1] = sum[i] + this._rowHeights[i]; } this._heightPrefixSum = sum; } _getTotalHeight() { if (!this._heightPrefixSum) this._recomputeHeightPrefixSum(); return this._heightPrefixSum[this._rowHeights.length]; } _getRowOffset(index) { if (!this._heightPrefixSum) this._recomputeHeightPrefixSum(); return this._heightPrefixSum[index]; } _findStartIndex(scrollTop) { if (!this._heightPrefixSum) this._recomputeHeightPrefixSum(); let lo = 0; let hi = this._rowHeights.length; while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (this._heightPrefixSum[mid] < scrollTop) { lo = mid + 1; } else { hi = mid; } } return Math.max(0, lo - 1); } _renderVisibleRows() { if (!this.tableBody || !this.rowsContainer || !this.rowsViewport) return; if (this.visibleRows.length === 0) { this.rowsViewport.style.display = "none"; this.rowsContainer.style.height = "0px"; for (const rowEl of this.rowPool) { rowEl.style.display = "none"; } if (this.emptyStateElement) { this.emptyStateElement.style.display = "flex"; this.emptyStateElement.textContent = this.allRows.length === 0 ? "Нет данных" : "Нет строк, соответствующих фильтрам"; } return; } if (this.emptyStateElement) { this.emptyStateElement.style.display = "none"; } this.rowsViewport.style.display = "flex"; const scrollTop = this.tableBody.scrollTop; const viewportHeight = this.tableBody.clientHeight; const startIndex = Math.max(0, this._findStartIndex(scrollTop) - this.virtualConfig.bufferSize); const endIndex = Math.min( this.visibleRows.length - 1, this._findStartIndex(scrollTop + viewportHeight) + this.virtualConfig.bufferSize, ); const totalHeight = this._getTotalHeight(); this.rowsContainer.style.height = `${totalHeight}px`; const topOffset = this._getRowOffset(startIndex); this.rowsViewport.style.transform = `translateY(${topOffset}px)`; let poolIndex = 0; for (let i = startIndex; i <= endIndex; i++) { const rowEl = this.rowPool[poolIndex++]; if (!rowEl) break; this._updateRowElement(rowEl, this.visibleRows[i], i); rowEl.style.display = "flex"; } // Синхронно измеряем фактическую высоту отрендеренных строк и корректируем // модель виртуального скролла. Это устраняет пустое пространство внизу и // обрезку строк, когда оценочная высота (особенно при переносе текста в // tree-колонке) расходится с реальной CSS-высотой после изменения ширины // контейнера (например, показ боковых панелей). let heightsChanged = false; const anchorIndex = startIndex; const anchorOffsetBefore = this._getRowOffset(anchorIndex); const anchorPixelOffset = anchorOffsetBefore - scrollTop; for (let i = 0; i < poolIndex; i++) { const rowEl = this.rowPool[i]; const rowIndex = startIndex + i; if (rowIndex >= this.visibleRows.length) break; const actualHeight = rowEl.offsetHeight; if (this._rowHeights[rowIndex] !== actualHeight) { this._rowHeights[rowIndex] = actualHeight; heightsChanged = true; } } if (heightsChanged) { this._heightPrefixSum = null; const newTotal = this._getTotalHeight(); const currentTotal = parseFloat(this.rowsContainer.style.height) || 0; if (Math.abs(newTotal - currentTotal) > 1) { this.rowsContainer.style.height = `${newTotal}px`; } // Закрепляем anchor-строку, чтобы не было визуальных скачков. const newAnchorOffset = this._getRowOffset(anchorIndex); const newScrollTop = newAnchorOffset - anchorPixelOffset; const maxScroll = Math.max(0, newTotal - this.tableBody.clientHeight); const desiredScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)); if (Math.abs(this.tableBody.scrollTop - desiredScrollTop) > 1) { this.tableBody.scrollTop = desiredScrollTop; } } // Если реальные высоты оказались меньше оценочных, первый пул строк может // не покрыть весь viewport — появляется пустое пространство внизу. // Дозаполняем видимую область дополнительными строками из пула. const targetBottom = this.tableBody.scrollTop + viewportHeight + this.virtualConfig.bufferSize * this.virtualConfig.rowHeight; let renderedBottom = this._getRowOffset(startIndex + poolIndex); while ( renderedBottom < targetBottom - 1 && startIndex + poolIndex < this.visibleRows.length && poolIndex < this.rowPool.length ) { const rowEl = this.rowPool[poolIndex]; const rowIndex = startIndex + poolIndex; this._updateRowElement(rowEl, this.visibleRows[rowIndex], rowIndex); rowEl.style.display = "flex"; poolIndex++; const actualHeight = rowEl.offsetHeight; if (this._rowHeights[rowIndex] !== actualHeight) { this._rowHeights[rowIndex] = actualHeight; heightsChanged = true; } this._heightPrefixSum = null; renderedBottom = this._getRowOffset(startIndex + poolIndex); } for (let i = poolIndex; i < this.rowPool.length; i++) { this.rowPool[i].style.display = "none"; } if (heightsChanged) { this._heightPrefixSum = null; const newTotal = this._getTotalHeight(); const currentTotal = parseFloat(this.rowsContainer.style.height) || 0; if (Math.abs(newTotal - currentTotal) > 1) { this.rowsContainer.style.height = `${newTotal}px`; } // scrollTop anchor уже скорректирован выше; строки, добавленные ниже // anchor, не влияют на его положение. } } // ==================== Row Rendering ==================== _updateRowElement(rowEl, row, index) { const isSelected = this.selectedRowIds.has(row.id); const isTree = row.hasChildren; rowEl.className = `table-row ${isTree ? "tree-row" : "leaf-row"} ${isSelected ? "selected" : ""}`; rowEl.dataset.rowId = row.id; rowEl.dataset.rowType = row.type; rowEl.dataset.index = index; rowEl.dataset.level = String(row.level); let cellsHtml = ""; for (const col of this.columns.filter((c) => c.visible)) { cellsHtml += this._renderCell(row, col, index); } rowEl.innerHTML = cellsHtml; } _renderCell(row, col, index) { if (col.isTreeColumn) { return this._renderTreeCell(row, col); } // Колонка «№» — номер видимой строки (1-based), как в copy-пути (_formatCellValue). const isRowNumber = col.id === "rowNumber"; const value = isRowNumber ? index + 1 : row.data[col.field]; const formatted = isRowNumber ? String(index + 1) : col.formatter ? col.formatter(value) : value !== undefined && value !== null ? this._escapeHtml(String(value)) : ""; const dimmedClass = col.dimmer && col.dimmer(row.data) ? " cell-dimmed" : ""; const customClass = col.cellClass ? ` ${typeof col.cellClass === "function" ? col.cellClass(row.data) : col.cellClass}` : ""; const customStyle = col.cellStyle ? (typeof col.cellStyle === "function" ? col.cellStyle(row.data) : col.cellStyle) : ""; const cellClass = col.type === "date" ? "table-cell cell-date" + dimmedClass + customClass : "table-cell" + dimmedClass + customClass; const cellStyle = `width: ${col.width}px;${customStyle ? " " + customStyle : ""}`; return `<div class="${cellClass}" data-column-id="${col.id}" style="${cellStyle}">${formatted}</div>`; } _renderTreeCell(row, col) { const expandIcon = row.hasChildren ? row.expanded ? ChevronDown : ChevronRight : ""; const indent = "<span class=\"tree-indent\"></span>".repeat(row.level); return ` <div class="table-cell cell-wrap" data-column-id="${col.id}" style="width: ${col.width}px;"> ${indent} <span class="expand-icon ${row.hasChildren ? "" : "leaf"}" data-row-id="${row.id}">${expandIcon}</span> <span>${this._escapeHtml(row.displayName)}</span> </div> `; } _formatCellValue(row, col, rowIndex) { if (col.id === "rowNumber") return String(rowIndex); if (col.isTreeColumn) return row.displayName || ""; const value = row.data[col.field]; let formatted = col.formatter ? col.formatter(value) : (value ?? ""); if (typeof value === "number" && typeof formatted === "string") { formatted = formatted.replace(/[\s\u00A0\u202F]/g, ""); } return formatted; } // ==================== Interaction ==================== _handleRowClick(row, event) { this.tableBody?.focus(); if (window.getSelection) window.getSelection().removeAllRanges(); if (event.shiftKey) { this._handleShiftSelect(row, event); } else if (event.ctrlKey || event.metaKey) { this.selectedRowIds.has(row.id) ? this.selectedRowIds.delete(row.id) : this.selectedRowIds.add(row.id); } else { this.selectedRowIds.clear(); this.selectedRowIds.add(row.id); } this._lastSelectedRowId = row.id; this._updateSelectionVisuals(); this._updateCopyButtonState(); this.dispatchEvent( new CustomEvent("grid-row-click", { detail: { type: row.type, id: row.id, data: row.data, expanded: row.expanded }, bubbles: true, }), ); this._dispatchSelectionChange(); } _handleShiftSelect(row, event) { if (this._lastSelectedRowId) { const lastIndex = this.visibleRows.findIndex((r) => r.id === this._lastSelectedRowId); const currentIndex = this.visibleRows.findIndex((r) => r.id === row.id); if (lastIndex !== -1 && currentIndex !== -1) { const start = Math.min(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex); if (!event.ctrlKey && !event.metaKey) { this.selectedRowIds.clear(); } for (let i = start; i <= end; i++) { this.selectedRowIds.add(this.visibleRows[i].id); } } else { if (!event.ctrlKey && !event.metaKey) { this.selectedRowIds.clear(); } this.selectedRowIds.add(row.id); } } else { if (!event.ctrlKey && !event.metaKey) { this.selectedRowIds.clear(); } this.selectedRowIds.add(row.id); } } _handleRowDblClick(row, event) { this.dispatchEvent( new CustomEvent("grid-row-dblclick", { detail: { type: row.type, id: row.id, data: row.data, expanded: row.expanded }, bubbles: true, }), ); } _toggleRowExpand(row) { if (row.expanded) { this.expandedRowIds.delete(row.id); } else { this.expandedRowIds.add(row.id); } this._applyFilters(); this.dispatchEvent( new CustomEvent("grid-row-expand", { detail: { rowId: row.id, expanded: !row.expanded }, bubbles: true, }), ); } _selectAllVisibleRows() { if (this.visibleRows.length === 0) return; this.selectedRowIds.clear(); for (const row of this.visibleRows) this.selectedRowIds.add(row.id); this._lastSelectedRowId = this.visibleRows[this.visibleRows.length - 1].id; this._updateSelectionVisuals(); this._updateCopyButtonState(); this._dispatchSelectionChange(); } _handleKeydown(e) { if (!(e.ctrlKey || e.metaKey)) return; if (e.code === "KeyC") { e.preventDefault(); if (this.selectedRowIds.size > 0) this.copyToClipboard(); } else if (e.code === "KeyA") { e.preventDefault(); this._selectAllVisibleRows(); } } _handleCopy(e) { if (this.selectedRowIds.size > 0) { e.preventDefault(); this.copyToClipboard(); } } _updateSelectionVisuals() { for (const rowEl of this.rowPool) { if (rowEl.style.display === "none") continue; rowEl.classList.toggle("selected", this.selectedRowIds.has(rowEl.dataset.rowId)); } } _updateCopyButtonState() { const copyBtn = this.shadowRoot.getElementById("btn-copy"); if (copyBtn) copyBtn.disabled = this.selectedRowIds.size === 0; } _dispatchSelectionChange() { this.dispatchEvent( new CustomEvent("grid-selection-change", { detail: { selectedIds: Array.from(this.selectedRowIds) }, bubbles: true, composed: true, }), ); } // ==================== Sort ==================== _handleSort(columnId, event) { const column = this.columns.find((c) => c.id === columnId); if (!column?.sortable) return; const isMulti = event?.ctrlKey || event?.metaKey; let rules = [...this.sortRules]; if (!isMulti) { if (rules.length === 1 && rules[0].columnId === columnId) { rules[0].direction = rules[0].direction === "asc" ? "desc" : "asc"; } else { rules = [{ columnId, direction: "asc" }]; } } else { const idx = rules.findIndex((r) => r.columnId === columnId); if (idx >= 0) { if (rules[idx].direction === "asc") rules[idx].direction = "desc"; else rules.splice(idx, 1); } else { rules.push({ columnId, direction: "asc" }); } } this.sortRules = rules; this.sortColumn = rules[0]?.columnId || null; this.sortDirection = rules[0]?.direction || "asc"; this._updateHeaderCells(); this._applySort(); this._updateVirtualScroll(); this.dispatchEvent( new CustomEvent("grid-sort-change", { detail: { columnId: this.sortColumn, direction: this.sortDirection, rules: this.sortRules }, bubbles: true, }), ); } _updateTreeLevelStyles() { if (!this.dynamicLevelStyles) return; const hasHierarchy = this.allRows.some((r) => r.hasChildren || r.level > 0); if (!hasHierarchy) { this.dynamicLevelStyles.textContent = ""; return; } const maxLevel = Math.max(0, ...this.allRows.map((r) => r.level ?? 0)); const base = this._getTokenRGBA("--surface-secondary"); const selectedBg = getComputedStyle(this).getPropertyValue("--color-control-bg-primary").trim() || "#0071b2"; let css = ""; for (let level = 0; level <= maxLevel; level++) { const ratio = maxLevel === 0 ? 0 : level / maxLevel; const alpha = 0.32 - ratio * 0.28; const finalAlpha = Math.round(Math.max(0.04, alpha) * 1000) / 1000; if (base) { css += `.data-grid .table-row[data-level="${level}"].tree-row { background: rgba(${base.r}, ${base.g}, ${base.b}, ${finalAlpha}); }\n`; css += `.data-grid .table-row[data-level="${level}"].leaf-row { background: rgba(${base.r}, ${base.g}, ${base.b}, ${finalAlpha}); }\n`; } else { const percent = Math.round(finalAlpha / 0.32 * 100); css += `.data-grid .table-row[data-level="${level}"].tree-row { background: var(--surface-secondary); opacity: ${percent}%; }\n`; css += `.data-grid .table-row[data-level="${level}"].leaf-row { background: var(--surface-secondary); opacity: ${percent}%; }\n`; } css += `.data-grid .table-row[data-level="${level}"].tree-row:hover { background: rgba(0, 66, 105, 0.12); }\n`; css += `.data-grid .table-row[data-level="${level}"].leaf-row:hover { background: rgba(0, 66, 105, 0.12); }\n`; css += `.data-grid .table-row[data-level="${level}"].tree-row.selected { background: ${selectedBg}; opacity: 1; }\n`; css += `.data-grid .table-row[data-level="${level}"].leaf-row.selected { background: ${selectedBg}; opacity: 1; }\n`; css += `.data-grid .table-row[data-level="${level}"].tree-row.selected .table-cell { color: var(--color-control-typo-primary, #fff); }\n`; css += `.data-grid .table-row[data-level="${level}"].leaf-row.selected .table-cell { color: var(--color-control-typo-primary, #fff); }\n`; } this.dynamicLevelStyles.textContent = css; } _getTokenRGBA(tokenName) { const value = getComputedStyle(this).getPropertyValue(tokenName).trim(); const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (!match) return null; return { r: parseInt(match[1], 10), g: parseInt(match[2], 10), b: parseInt(match[3], 10), a: match[4] ? parseFloat(match[4]) : 1, }; } _applySort() { if (this.sortRules.length === 0 || this.allRows.length === 0) { this._computeVisibleRows(); return; } // Определяем, есть ли в данных иерархия (hasChildren или level > 0). const hasHierarchy = this.allRows.some((r) => r.hasChildren || r.level > 0); if (!hasHierarchy) { this.allRows.sort((a, b) => this._compareBySortRules(a.data, b.data)); } else { // Для tree-данных сортируем только внутри одного уровня, сохраняя порядок родитель->потомок. this._sortTreeRows(); } this._computeVisibleRows(); } _sortTreeRows() { // Собираем детей по parentId const childrenByParent = new Map(); const roots = []; for (const row of this.allRows) { if (row.parentId) { if (!childrenByParent.has(row.parentId)) childrenByParent.set(row.parentId, []); childrenByParent.get(row.parentId).push(row); } else { roots.push(row); } } const compare = (a, b) => this._compareBySortRules(a.data, b.data); const sortChildren = (row) => { const children = childrenByParent.get(row.id); if (children) { children.sort(compare); for (const child of children) sortChildren(child); } }; roots.sort(compare); for (const root of roots) sortChildren(root); // Перестраиваем allRows в depth-first порядке const result = []; const walk = (row) => { result.push(row); const children = childrenByParent.get(row.id); if (children) { for (const child of children) walk(child); } }; for (const root of roots) walk(root); this.allRows = result; } _compareBySortRules(aData, bData) { for (const { columnId, direction } of this.sortRules) { const column = this.columns.find((c) => c.id === columnId); if (!column) continue; const aValue = aData[column.field]; const bValue = bData[column.field]; const result = this._compareValues(aValue, bValue, column.type); if (result !== 0) return direction === "asc" ? result : -result; } return 0; } _compareValues(a, b, type) { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; if (type === "number" || type === "percent") { return Number(a) - Number(b); } if (type === "date") { return new Date(a).getTime() - new Date(b).getTime(); } return String(a).localeCompare(String(b), "ru-RU"); } // ==================== Filters ==================== _applyFilters() { this._computeVisibleRows(); this._computeRowHeights(); this._updateVirtualScroll(); this._updateHeaderCells(); // id листовых строк, проходящих фильтры заголовков (null = фильтров нет). // DataSource может пересчитать агрегаты групп по видимым работам. const visibleLeafIds = this.activeFilters.length === 0 ? null : this.allRows .filter( (row) => !row.hasChildren && this.activeFilters.every((filter) => this._matchFilter(row, filter)), ) .map((row) => row.id); this.dispatchEvent( new CustomEvent("grid-filter-change", { detail: { filters: this.activeFilters, filteredCount: this.visibleRows.length, totalCount: this.allRows.length, visibleLeafIds, }, bubbles: true, }), ); } _matchFilter(row, filter) { const field = filter.field || filter.columnId; const value = row.data[field]; const filterValue = filter.value; if (value === undefined || value === null) { return filter.operator === "notEquals" ? filterValue != null : false; } const strValue = String(value).toLowerCase(); const strFilter = String(filterValue).toLowerCase(); switch (filter.operator) { case "equals": return strValue === strFilter; case "notEquals": return strValue !== strFilter; case "contains": return strValue.includes(strFilter); case "startsWith": return strValue.startsWith(strFilter); case "endsWith": return strValue.endsWith(strFilter); case "greaterThan": case "greaterOrEqual": case "lessThan": case "lessOrEqual": case "between": { const numValue = this._parseComparableValue(value); if (numValue === null) return false; if (filter.operator === "between") { const [min, max] = Array.isArray(filterValue) ? filterValue : [filterValue, filterValue]; const numMin = this._parseComparableValue(min); const numMax = this._parseComparableValue(max); if (numMin === null || numMax === null) return false; return numValue >= numMin && numValue <= numMax; } const numFilter = this._parseComparableValue(filterValue); if (numFilter === null) return false; switch (filter.operator) { case "greaterThan": return numValue > numFilter; case "greaterOrEqual": return numValue >= numFilter; case "lessThan": return numValue < numFilter; case "lessOrEqual": return numValue <= numFilter; } return false; } default: return true; } } _parseComparableValue(value) { if (value === null || value === undefined || value === "") return null; if (value instanceof Date) return value.getTime(); if (typeof value === "number") return value; const num = Number(value); if (!Number.isNaN(num)) return num; const date = new Date(value); if (!Number.isNaN(date.getTime())) return date.getTime(); return null; } _isNumericColumn(column) { return ["number", "percent", "date"].includes(column.type); } _getOperatorsForColumn(column) { if (this._isNumericColumn(column)) { return [ { value: "equals", label: "Равно" }, { value: "notEquals", label: "Не равно" }, { value: "greaterThan", label: "Больше" }, { value: "greaterOrEqual", label: "Больше или равно" }, { value: "lessThan", label: "Меньше" }, { value: "lessOrEqual", label: "Меньше или равно" }, { value: "between", label: "Между" }, ]; } return [ { value: "equals", label: "Равно" }, { value: "notEquals", label: "Не равно" }, { value: "contains", label: "Содержит" }, { value: "startsWith", label: "Начинается с" }, { value: "endsWith", label: "Заканчивается на" }, ]; } _openFilterDropdown(columnId, button) { this._closeFilterDropdown(); const column = this.columns.find((c) => c.id === columnId); if (!column || !column.filterable) return; const existingFilter = this.activeFilters.find((f) => f.columnId === columnId); const isNumeric = this._isNumericColumn(column); const operators = this._getOperatorsForColumn(column); const currentOperator = existingFilter?.operator || (isNumeric ? "equals" : "contains"); const currentValue = existingFilter?.value ?? ""; const betweenValues = Array.isArray(currentValue) ? currentValue : [currentValue || "", currentValue || ""]; const rect = button.getBoundingClientRect(); const popover = document.createElement("div"); popover.className = "filter-dropdown filter-dropdown-active"; popover.dataset.columnId = columnId; popover.style.position = "fixed"; popover.style.top = `${rect.bottom + 4}px`; popover.style.left = `${Math.max(4, rect.left)}px`; popover.style.zIndex = "1000"; const operatorOptions = operators .map((op) => `<option value="${op.value}" ${op.value === currentOperator ? "selected" : ""}>${op.label}</option>`) .join(""); const betweenInputs = isNumeric ? `<div class="filter-between-inputs" style="display: ${currentOperator === "between" ? "flex" : "none"}"> <input type="number" class="filter-value-input filter-value-min" placeholder="от" value="${this._escapeHtml(betweenValues[0])}"> <span class="filter-between-sep">—</span> <input type="number" class="filter-value-input filter-value-max" placeholder="до" value="${this._escapeHtml(betweenValues[1])}"> </div>` : ""; const singleInputType = isNumeric ? "number" : "text"; const singleInputValue = currentOperator === "between" ? "" : this._escapeHtml(String(currentValue)); popover.innerHTML = ` <div class="filter-dropdown-header"> <span class="filter-dropdown-title">${this._escapeHtml(column.header)}</span> <button class="filter-dropdown-close" aria-label="Закрыть">${X}</button> </div> <div class="filter-dropdown-body"> <div class="filter-operator-row"> <label class="filter-label">Условие</label> <select class="filter-operator-select">${operatorOptions}</select> </div> <div class="filter-value-row filter-single-value" style="display: ${currentOperator === "between" ? "none" : "block"}"> <input type="${singleInputType}" class="filter-value-input filter-value-single" placeholder="${isNumeric ? "Введите число" : "Значение"}" value="${singleInputValue}"> </div> ${betweenInputs} </div> <div class="filter-dropdown-footer"> <button class="filter-btn filter-btn-secondary filter-btn-clear">Очистить</button> <button class="filter-btn filter-btn-primary filter-btn-apply">Применить</button> </div> `; this.shadowRoot.appendChild(popover); requestAnimationFrame(() => { const popRect = popover.getBoundingClientRect(); const overflowRight = popRect.right - window.innerWidth + 8; if (overflowRight > 0) { popover.style.left = `${Math.max(4, rect.left - overflowRight)}px`; } }); const operatorSelect = popover.querySelector(".filter-operator-select"); const singleValueRow = popover.querySelector(".filter-single-value"); const singleInput = popover.querySelector(".filter-value-single"); const betweenRow = popover.querySelector(".filter-between-inputs"); operatorSelect?.addEventListener("change", () => { const isBetween = operatorSelect.value === "between"; if (singleValueRow) singleValueRow.style.display = isBetween ? "none" : "block"; if (betweenRow) betweenRow.style.display = isBetween ? "flex" : "none"; }); popover.querySelector(".filter-btn-apply")?.addEventListener("click", (e) => { e.stopPropagation(); const operator = operatorSelect.value; let value; if (operator === "between") { value = [popover.querySelector(".filter-value-min")?.value, popover.querySelector(".filter-value-max")?.value]; } else { value = singleInput?.value ?? ""; } this._applyColumnFilter(columnId, operator, value); this._closeFilterDropdown(); }); popover.querySelector(".filter-btn-clear")?.addEventListener("click", (e) => { e.stopPropagation(); this._clearColumnFilter(columnId); this._closeFilterDropdown(); }); popover.querySelector(".filter-dropdown-close")?.addEventListener("click", (e) => { e.stopPropagation(); this._closeFilterDropdown(); }); this._boundCloseFilterDropdown = (e) => { const path = e.composedPath ? e.composedPath() : [e.target]; if (!path.includes(popover)) this._closeFilterDropdown(); }; setTimeout(() => { document.addEventListener("click", this._boundCloseFilterDropdown); }, 0); } _closeFilterDropdown() { const popover = this.shadowRoot?.querySelector(".filter-dropdown-active"); if (popover) popover.remove(); if (this._boundCloseFilterDropdown) { document.removeEventListener("click", this._boundCloseFilterDropdown); this._boundCloseFilterDropdown = null; } } _applyColumnFilter(columnId, operator, value) { const column = this.columns.find((c) => c.id === columnId); if (!column) return; const filters = this.activeFilters.filter((f) => f.columnId !== columnId); const isEmptyBetween = operator === "between" && (!value[0] || !value[1]); const isEmptySingle = operator !== "between" && String(value).trim() === ""; if (!isEmptyBetween && !isEmptySingle) { filters.push({ columnId, field: column.field, operator, value, isActive: true }); } this.applyFilters(filters); } _clearColumnFilter(columnId) { const filters = this.activeFilters.filter((f) => f.columnId !== columnId); this.applyFilters(filters); } // ==================== Column Resize ==================== _startColumnResize(event, columnId) { const column = this.columns.find((c) => c.id === columnId); if (!column) return; this.shadowRoot.querySelector(".data-grid")?.classList.add("resizing"); const startX = event.clientX; const startWidth = column.width; const onMove = (e) => { const newWidth = Math.max(24, startWidth + e.clientX - startX); this._setColumnWidth(column, newWidth); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); this.shadowRoot.querySelector(".data-grid")?.classList.remove("resizing"); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); } _setColumnWidth(column, width, isAutoFit = false) { if (!isAutoFit && this.columns.find((c) => c.isTreeColumn && c.id === column.id)) { this._treeColumnAutoFit = false; } column.width = Math.max(24, width); this._updateHeaderCells(); this._updateContentWidth(); this._renderVisibleRows(); } _fitTreeColumn() { if (!this._treeColumnAutoFit) return; const treeCol = this.columns.find((c) => c.isTreeColumn && c.visible); if (!treeCol || !this.tableBody) return; const clientWidth = this.tableBody.clientWidth; const otherWidth = this.columns.filter((c) => c.visible && !c.isTreeColumn).reduce((sum, c) => sum + c.width, 0); const newWidth = Math.max(this._minTreeWidth, clientWidth - otherWidth); if (clientWidth === 0) return; if (treeCol.width !== newWidth) { this._setColumnWidth(treeCol, newWidth, true); } } _updateContentWidth() { if (!this.tableHeaderRow || !this.rowsViewport) return; const totalWidth = this.columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 0); this.tableHeaderRow.style.width = `${totalWidth}px`; this.rowsViewport.style.width = `${totalWidth}px`; } // ==================== Helpers ==================== _estimateRowHeight(row) { const treeCol = this.columns.find((c) => c.isTreeColumn); if (!treeCol || !row.displayName) return this.virtualConfig.rowHeight; const cacheKey = `${treeCol.width}|${row.level}|${row.displayName}`; const cached = this._rowHeightCache.get(cacheKey); if (cached !== undefined) return cached; const textWidth = this._measureTextWidth(row.displayName); const iconWidth = 20; const availableWidth = treeCol.width - (row.level * 16 + 8 + 8) - iconWidth; if (availableWidth <= 0 || textWidth <= availableWidth) { this._rowHeightCache.set(cacheKey, this.virtualConfig.rowHeight); return this.virtualConfig.rowHeight; } const lines = Math.ceil(textWidth / availableWidth); // line-height 1.3 для 12px = 15.6px; padding 2px top + 2px bottom = 4px. // Используем реальные CSS-размеры вместо умножения на rowHeight, // чтобы оценочная высота ближе соответствовала фактической высоте ячейки. const contentHeight = lines * 15.6 + 4; const height = Math.max(this.virtualConfig.rowHeight, Math.ceil(contentHeight)); this._rowHeightCache.set(cacheKey, height); return height; } _measureTextWidth(text, font = '12px Inter, sans-serif') { if (!text) return 0; const key = `${font}|${text}`; const cached = this._textWidthCache.get(key); if (cached !== undefined) return cached; if (!this._canvasCtx) { const canvas = document.createElement("canvas"); this._canvasCtx = canvas.getContext("2d"); } this._canvasCtx.font = font; const width = this._canvasCtx.measureText(text).width; this._textWidthCache.set(key, width); return width; } _escapeHtml(text) { if (text === null || text === undefined) return ""; const div = document.createElement("div"); div.textContent = String(text); return div.innerHTML; } _updateFooter() { if (!this.footerElement) return; if (this.isLoading) { this.footerElement.textContent = "Загрузка..."; } else if (this.visibleRows.length === 0) { this.footerElement.textContent = "Нет данных"; } else { this.footerElement.textContent = `Всего: ${this.allRows.length} (показано ${this.visibleRows.length})`; } } setLoading(loading) { this.isLoading = loading; this._updateFooter(); } _openSettings() { this._closeSettingsDropdown(); const button = this.shadowRoot.getElementById("btn-settings"); const rect = button?.getBoundingClientRect(); if (!rect) return; const popover = document.createElement("div"); popover.className = "settings-dropdown settings-dropdown-active"; popover.style.position = "fixed"; popover.style.top = `${rect.bottom + 4}px`; popover.style.left = `${Math.max(4, rect.right - 220)}px`; popover.style.zIndex = "1000"; const items = this.columns .map( (col) => ` <label class="settings-item" title="${this._escapeHtml(col.header)}"> <input type="checkbox" data-column-id="${col.id}" ${col.visible ? "checked" : ""}> <span class="settings-item-text">${this._escapeHtml(col.header)}</span> </label> `, ) .join(""); popover.innerHTML = ` <div class="settings-dropdown-header"> <span class="settings-dropdown-title">Столбцы</span> <button class="settings-dropdown-close" aria-label="Закрыть">${X}</button> </div> <div class="settings-dropdown-body">${items}</div> `; this.shadowRoot.appendChild(popover); requestAnimationFrame(() => { const popRect = popover.getBoundingClientRect(); const overflowRight = popRect.right - window.innerWidth + 8; if (overflowRight > 0) { popover.style.left = `${Math.max(4, parseFloat(popover.style.left) - overflowRight)}px`; } }); popover.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { checkbox.addEventListener("change", () => { const columnId = checkbox.dataset.columnId; const column = this.columns.find((c) => c.id === columnId); if (column) { column.visible = checkbox.checked; this.setColumns(this.columns); } }); }); popover.querySelector(".settings-dropdown-close")?.addEventListener("click", (e) => { e.stopPropagation(); this._closeSettingsDropdown(); }); this._boundCloseSettingsDropdown = (e) => { const path = e.composedPath ? e.composedPath() : [e.target]; if (!path.includes(popover)) this._closeSettingsDropdown(); }; setTimeout(() => { document.addEventListener("click", this._boundCloseSettingsDropdown); }, 0); } _closeSettingsDropdown() { const popover = this.shadowRoot?.querySelector(".settings-dropdown-active"); if (popover) popover.remove(); if (this._boundCloseSettingsDropdown) { document.removeEventListener("click", this._boundCloseSettingsDropdown); this._boundCloseSettingsDropdown = null; } } _cleanup() { if (this.tableBody) { this.tableBody.removeEventListener("scroll", this._boundHandleScroll); this.tableBody.removeEventListener("keydown", this._boundHandleKeydown); this.tableBody.removeEventListener("copy", this._boundHandleCopy); } if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } if (this._rafId) cancelAnimationFrame(this._rafId); if (this._fitTreeColumnRaf) cancelAnimationFrame(this._fitTreeColumnRaf); this._closeFilterDropdown(); this._closeSettingsDropdown(); } } customElements.define("data-grid", DataGrid); export { DataGrid }; export default DataGrid;