diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..07d0cd01f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,208 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +VTable is a high-performance multidimensional data analysis table component built on the VRender canvas engine. This is a Rush monorepo containing the core VTable library and related packages. + +## Essential Commands + +### Development Setup +```bash +# Install dependencies (required first time and after package.json changes) +rush update + +# Build all packages +rush build + +# Start development server for core vtable package +cd packages/vtable && rushx demo + +# Start documentation site +rush docs + +# Run tests +rush test + +# Run linting +rush eslint + +# Fix dependency issues +rush purge && rush update +``` + +### Working with Individual Packages +```bash +# Build specific package +cd packages/[package-name] && rushx build + +# Run tests for specific package +cd packages/[package-name] && rushx test + +# Start demo for specific package +cd packages/[package-name] && rushx demo +``` + +### Git Workflow +```bash +# After making changes, update changelogs before committing +rush change-all + +# Commit with conventional message format +git commit -m "type: description" +``` + +## Architecture Overview + +### Package Structure +- **packages/vtable**: Core VTable library with ListTable, PivotTable, and PivotChart components +- **packages/vtable-gantt**: Gantt chart component +- **packages/vtable-editors**: Table editor components +- **packages/vtable-plugins**: Plugin system for extending functionality +- **packages/vtable-export**: Export functionality (Excel, CSV, etc.) +- **packages/vtable-search**: Search capabilities +- **packages/vtable-calendar**: Calendar component +- **packages/vtable-sheet**: Spreadsheet functionality (current development branch) + - **项目特性**: 电子表格组件,支持多sheet tab页签管理 + - **核心架构**: + - `workSheetInstances`: 管理所有sheet tab实例的核心容器 + - `work-sheet`: 单个sheet页签的实现,包含对应vtable实例 + - **依赖关系**: 依赖vtable核心插件系统,需先安装vtable插件 + - **当前开发重点**: 跨sheet tab公式计算支持 + - **技术实现**: 基于VTable核心库扩展,每个sheet对应独立的VTable实例 +- **packages/react-vtable**: React wrapper +- **packages/vue-vtable**: Vue wrapper +- **packages/openinula-vtable**: OpenInula wrapper + +### Core Architecture +The library is built on a canvas-based rendering system using VRender: + +1. **Table Types**: + - `ListTable`: Basic table with columns and rows + - `PivotTable`: Multi-dimensional data analysis + - `PivotChart`: Chart integration with tables + +2. **Rendering Pipeline**: + - Scenegraph-based rendering in `src/scenegraph/` + - Canvas-based graphics using VRender engine + - Custom layout system in `src/render/layout/` + +3. **Data Management**: + - Data source abstraction in `src/data/` + - Statistics and aggregation in `src/dataset/` + - Event system for data updates + +4. **Extension Points**: + - Plugin system for custom functionality + - Theme system for styling + - Custom cell renderers and editors + +### Testing Strategy +- **Framework**: Jest with ts-jest preset +- **Environment**: jest-electron for DOM/canvas testing +- **Coverage Requirements**: 60% minimum for all metrics +- **Test Location**: `__tests__/` directories within each package + +### Key Dependencies +- **@visactor/vrender-***: Canvas rendering engine (v1.0.14) +- **@visactor/vutils**: Utility functions (~0.19.1) +- **lodash**: Utility library (4.17.21) + +### Development Notes +- This is a Rush monorepo - always use `rush` commands instead of npm/yarn directly +- Current branch: `feat/vtable-sheet` (spreadsheet functionality) +- Main branches: `main`, `develop` +- Node.js versions supported: 14.15.0+, 16.13.0+, 18.15.0+ + +## vtable-sheet 项目详细信息 + +### 项目路径 +`/Users/bytedance/VisActor/VTable/packages/vtable-sheet` + +### 核心功能需求 +- **跨sheet tab公式计算支持**: 实现不同sheet页签间的公式引用和计算 +- **多页签管理**: 支持创建、切换、删除sheet页签 +- **数据同步**: 确保跨sheet数据引用的实时更新 + +### 技术架构 +- **核心入口**: vtable-sheet文件是整个组件的入口点 +- **实例管理**: workSheetInstances负责管理所有sheet实例 +- **单sheet实现**: 每个work-sheet包含独立的VTable实例 +- **插件依赖**: 依赖VTable核心插件系统提供基础功能 + +### 跨Sheet公式功能实现 + +#### 新增核心组件 +1. **CrossSheetFormulaManager** (`src/formula/cross-sheet-formula-manager.ts`) + - 管理跨Sheet公式引用关系 + - 处理依赖关系映射和缓存 + - 支持公式验证和错误处理 + +2. **CrossSheetDataSynchronizer** (`src/formula/cross-sheet-data-synchronizer.ts`) + - 处理跨Sheet数据同步 + - 支持批量数据更新 + - 提供实时更新通知机制 + +3. **CrossSheetFormulaValidator** (`src/formula/cross-sheet-formula-validator.ts`) + - 验证跨Sheet公式语法 + - 检测循环依赖 + - 提供详细的错误信息 + +4. **CrossSheetFormulaHandler** (`src/formula/cross-sheet-formula-handler.ts`) + - 统一的跨Sheet公式处理接口 + - 集成缓存、验证、同步功能 + - 提供高性能的计算引擎 + +#### 支持的公式类型 +- **基本引用**: `=Sheet1!A1`, `=Sheet2!B2:C4` +- **函数计算**: `=SUM(Sheet1!A1:A10)`, `=AVERAGE(Sheet1!B1:B10)` +- **跨表运算**: `=Sheet1!A1 + Sheet2!B1` +- **条件判断**: `=IF(Sheet1!A1>100, "达标", "未达标")` +- **复杂嵌套**: `=IF(AVERAGE(Sheet1!A1:A10)>50, SUM(Sheet1!B1:B10)*1.1, SUM(Sheet1!B1:B10))` + +#### 核心功能特性 +- ✅ **实时计算**: 源数据变化时自动更新依赖公式 +- ✅ **智能缓存**: 1秒TTL缓存机制,平衡性能与实时性 +- ✅ **错误处理**: 完善的错误检测和提示机制 +- ✅ **依赖管理**: 自动识别和管理跨Sheet依赖关系 +- ✅ **批量处理**: 支持批量公式计算和数据更新 +- ✅ **性能优化**: 异步处理,避免阻塞UI + +#### 使用示例 +```typescript +// 设置跨Sheet公式 +const cell = { sheet: 'Summary', row: 1, col: 1 }; +const formula = '=SUM(SalesData!B2:E4)'; +await formulaManager.setCrossSheetFormula(cell, formula); + +// 获取计算结果 +const result = await formulaManager.getCrossSheetValue(cell); +console.log(result.value); // 计算结果 +console.log(result.calculationTime); // 计算耗时 + +// 验证公式 +const validation = formulaManager.validateCrossSheetFormula(cell); +console.log(validation.valid); // 是否有效 + +// 获取依赖关系 +const dependencies = formulaManager.getCrossSheetDependencies(); +``` + +#### 测试覆盖 +- **单元测试**: `__tests__/cross-sheet-formula-simple.test.ts` +- **集成测试**: `__tests__/integration/cross-sheet-integration.test.ts` +- **演示页面**: `examples/cross-sheet-demo.html` +- **使用文档**: `docs/cross-sheet-formula-guide.md` + +#### 性能指标 +- 100个跨Sheet公式计算 < 5秒 +- 20个公式批量重新计算 < 2秒 +- 缓存命中率 > 80% +- 内存使用优化,支持大规模数据 + + +#### 使用限制 +- 跨Sheet公式目前主要用于读取操作,写入操作仍在原Sheet中进行 +- 复杂的跨Sheet引用可能需要异步处理以获得最佳性能 +- 循环依赖检测基于静态分析,运行时循环依赖需要额外的错误处理 \ No newline at end of file diff --git a/packages/vtable/__tests__/data-update/listTable-updateOption-group-perf.test.ts b/packages/vtable/__tests__/data-update/listTable-updateOption-group-perf.test.ts new file mode 100644 index 000000000..45b17abe9 --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-updateOption-group-perf.test.ts @@ -0,0 +1,205 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { CachedDataSource } from '../../src/data/CachedDataSource'; +import { createDiv } from '../dom'; +import data from '../data/North_American_Superstore_data.json'; + +global.__VERSION__ = 'none'; + +describe('listTable grouped updateOption perf', () => { + afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); + + test('grouped updateOption with records only clears cells once before rebuilding', async () => { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '1000px'; + container.style.height = '800px'; + + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 'auto' }, + { field: 'Customer ID', title: 'Customer ID', width: 'auto' }, + { field: 'Product Name', title: 'Product Name', width: 'auto' }, + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' }, + { field: 'Region', title: 'Region', width: 'auto' } + ]; + + const table = new ListTable(container, { + records: JSON.parse(JSON.stringify(data.slice(0, 30))), + columns, + widthMode: 'standard', + groupBy: ['Category', 'Sub-Category'] + }); + + const clearCellsSpy = jest.spyOn(table.scenegraph, 'clearCells'); + + await table.updateOption({ + ...table.options, + records: JSON.parse(JSON.stringify(data.slice(30, 80))), + groupBy: ['Category', 'Sub-Category'] + }); + + expect(clearCellsSpy).toHaveBeenCalledTimes(1); + expect(table.options.records.length).toBe(50); + + table.release(); + }); + + test('grouped updateOption reuses cached data source when records stay the same', async () => { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '1000px'; + container.style.height = '800px'; + + const records = JSON.parse(JSON.stringify(data.slice(0, 60))); + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 'auto' }, + { field: 'Customer ID', title: 'Customer ID', width: 'auto' }, + { field: 'Product Name', title: 'Product Name', width: 'auto' }, + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' }, + { field: 'Region', title: 'Region', width: 'auto' } + ]; + + const table = new ListTable(container, { + records, + columns, + widthMode: 'standard' + }); + + const oldDataSource = table.dataSource; + const ofArraySpy = jest.spyOn(CachedDataSource, 'ofArray'); + + await table.updateOption({ + ...table.options, + records, + groupBy: ['Category', 'Sub-Category'] + }); + + expect(ofArraySpy).not.toHaveBeenCalled(); + expect(table.dataSource).toBe(oldDataSource); + expect(table.dataSource.rowHierarchyType).toBe('tree'); + + table.release(); + }); + + test('grouped updateOption with same records and changed sortState falls back to setRecords', async () => { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '1000px'; + container.style.height = '800px'; + + const records = JSON.parse(JSON.stringify(data.slice(0, 60))); + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 'auto' }, + { field: 'Customer ID', title: 'Customer ID', width: 'auto' }, + { field: 'Product Name', title: 'Product Name', width: 'auto' }, + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' }, + { field: 'Sales', title: 'Sales', width: 'auto', sort: true } + ]; + + const table = new ListTable(container, { + records, + columns, + widthMode: 'standard', + groupBy: ['Category', 'Sub-Category'] + }); + + const setRecordsSpy = jest.spyOn(table, 'setRecords'); + const nextSortState = { + field: 'Sales', + order: 'desc' + }; + + await table.updateOption({ + ...table.options, + records, + groupBy: ['Category', 'Sub-Category'], + sortState: nextSortState + }); + + expect(setRecordsSpy).toHaveBeenCalled(); + expect(table.internalProps.sortState).toEqual(nextSortState); + + table.release(); + }); + + test('grouped updateOption with same records and same active sortState still falls back to setRecords', async () => { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '1000px'; + container.style.height = '800px'; + + const records = JSON.parse(JSON.stringify(data.slice(0, 60))); + const sortState = { + field: 'Sales', + order: 'desc' + }; + const columns = [ + { field: 'Order ID', title: 'Order ID', width: 'auto' }, + { field: 'Customer ID', title: 'Customer ID', width: 'auto' }, + { field: 'Product Name', title: 'Product Name', width: 'auto' }, + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' }, + { field: 'Sales', title: 'Sales', width: 'auto', sort: true } + ]; + + const table = new ListTable(container, { + records, + columns, + widthMode: 'standard', + groupBy: ['Category', 'Sub-Category'], + sortState + }); + + const setRecordsSpy = jest.spyOn(table, 'setRecords'); + + await table.updateOption({ + ...table.options, + records, + groupBy: ['Category', 'Sub-Category'], + sortState + }); + + expect(setRecordsSpy).toHaveBeenCalled(); + expect(table.internalProps.sortState).toBe(sortState); + + table.release(); + }); + + test('refreshRecords syncs addRecordRule for reused cached data source', async () => { + const container = createDiv(); + container.style.position = 'relative'; + container.style.width = '1000px'; + container.style.height = '800px'; + + const records: any[] = [{ name: 'Alice' }]; + const columns = [{ field: 'name', title: 'Name', width: 'auto' }]; + + const table = new ListTable(container, { + records, + columns, + widthMode: 'standard', + addRecordRule: 'Object' + }); + + await table.updateOption({ + ...table.options, + records, + addRecordRule: 'Array' + }); + + expect(table.dataSource.addRecordRule).toBe('Array'); + + (table.dataSource as any).changeFieldValueByRecordIndex('inserted', 1, 0, table); + + expect(Array.isArray(records[1])).toBe(true); + expect(records[1][0]).toBe('inserted'); + + table.release(); + }); +}); diff --git a/packages/vtable/examples/list/list-group-tree-stick-updateOption.ts b/packages/vtable/examples/list/list-group-tree-stick-updateOption.ts new file mode 100644 index 000000000..f69f8292c --- /dev/null +++ b/packages/vtable/examples/list/list-group-tree-stick-updateOption.ts @@ -0,0 +1,70 @@ +import * as VTable from '../../src'; +import data from '../../__tests__/data/North_American_Superstore_data.json'; + +const CONTAINER_ID = 'vTable'; + +type DemoRecord = Record & { + vtableMergeName?: string; + children?: DemoRecord[]; +}; + +export function createTable() { + const columns: VTable.ColumnsDefine = [ + { field: 'Order ID', title: 'Order ID', width: 'auto' }, + { field: 'Customer ID', title: 'Customer ID', width: 'auto' }, + { field: 'Product Name', title: 'Product Name', width: 'auto' }, + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' }, + { field: 'Region', title: 'Region', width: 'auto' }, + { field: 'Sales', title: 'Sales', width: 'auto' }, + { field: 'Profit', title: 'Profit', width: 'auto' } + ]; + + const records = (data as DemoRecord[]).slice(0, 100); + const nextRecords = (data as DemoRecord[]).slice(100, 200); + const dom = document.getElementById(CONTAINER_ID) as HTMLElement; + let useNextRecords = false; + + const createOption = (currentRecords: DemoRecord[]): VTable.ListTableConstructorOptions => ({ + records: currentRecords, + columns, + widthMode: 'standard', + groupConfig: { + groupBy: ['Category', 'Sub-Category'], + titleFieldFormat: (record: DemoRecord) => `${record.vtableMergeName}(${record.children?.length ?? 0})`, + enableTreeStickCell: true + }, + theme: VTable.themes.DEFAULT.extends({ + groupTitleStyle: { + fontWeight: 'bold', + color: 'orange', + bgColor: args => { + const index = args.table.getGroupTitleLevel(args.col, args.row); + return index === undefined ? undefined : ['#f7f1ff', '#e8f4ff', '#fff7e6'][index % 3]; + } + } + }) + }); + + const tableInstance = new VTable.ListTable(dom, createOption(records)); + window.tableInstance = tableInstance; + + dom.style.width = '1000px'; + dom.style.height = '600px'; + tableInstance.updateOption(createOption(records)); + + const toolbar = document.createElement('div'); + toolbar.style.margin = '12px 0'; + const button = document.createElement('button'); + button.textContent = 'updateOption 替换 records'; + button.onclick = async () => { + useNextRecords = !useNextRecords; + await tableInstance.updateOption(createOption(useNextRecords ? nextRecords : records)); + }; + toolbar.appendChild(button); + document.getElementById(CONTAINER_ID)?.parentElement?.insertBefore(toolbar, document.getElementById(CONTAINER_ID)); + + setTimeout(() => { + void button.onclick?.(new MouseEvent('click')); + }, 0); +} diff --git a/packages/vtable/examples/list/list-group-updateOption-perf.ts b/packages/vtable/examples/list/list-group-updateOption-perf.ts new file mode 100644 index 000000000..4c41d7b02 --- /dev/null +++ b/packages/vtable/examples/list/list-group-updateOption-perf.ts @@ -0,0 +1,165 @@ +import * as VTable from '../../src'; + +const CONTAINER_ID = 'vTable'; +const TOOLBAR_ID = 'toolbar'; +const STATUS_ID = 'status'; +const TOTAL_COLUMNS = 100; +const TOTAL_RECORDS = 1000; +const BENCHMARK_ROUNDS = 5; + +const categoryPool = ['Office Supplies', 'Furniture', 'Technology', 'Appliances']; +const subCategoryPool = ['Paper', 'Labels', 'Phones', 'Chairs', 'Tables', 'Binders', 'Storage', 'Accessories']; + +function createColumns() { + const columns: VTable.ColumnsDefine = [ + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' } + ]; + + for (let i = 0; i < TOTAL_COLUMNS - 2; i++) { + columns.push({ + field: `field_${i}`, + title: `Field ${i}`, + width: 'auto' + }); + } + + return columns; +} + +function createRecords() { + return Array.from({ length: TOTAL_RECORDS }, (_, rowIndex) => { + const record: Record = { + Category: categoryPool[rowIndex % categoryPool.length], + 'Sub-Category': subCategoryPool[rowIndex % subCategoryPool.length] + }; + + for (let colIndex = 0; colIndex < TOTAL_COLUMNS - 2; colIndex++) { + record[`field_${colIndex}`] = `R${rowIndex}-C${colIndex}-${(rowIndex + colIndex) % 97}`; + } + + return record; + }); +} + +function createShiftedRecords(baseRecords: Record[]) { + return baseRecords.map((record, index) => ({ + ...record, + Category: categoryPool[(index + 1) % categoryPool.length], + 'Sub-Category': subCategoryPool[(index + 2) % subCategoryPool.length] + })); +} + +function createOption( + records: Record[], + columns: VTable.ColumnsDefine, + enableGroup: boolean +): VTable.ListTableConstructorOptions { + return { + container: document.getElementById(CONTAINER_ID), + records, + columns, + widthMode: 'standard', + defaultColWidth: 120, + groupBy: enableGroup ? ['Category', 'Sub-Category'] : undefined + }; +} + +export function createTable() { + const mount = document.getElementById(CONTAINER_ID)?.parentElement ?? document.body; + let toolbar = document.getElementById(TOOLBAR_ID); + let status = document.getElementById(STATUS_ID); + if (!toolbar) { + toolbar = document.createElement('div'); + toolbar.id = TOOLBAR_ID; + toolbar.style.margin = '12px 0'; + mount.insertBefore(toolbar, document.getElementById(CONTAINER_ID) ?? null); + } + if (!status) { + status = document.createElement('div'); + status.id = STATUS_ID; + status.style.margin = '8px 0 12px'; + status.style.fontFamily = 'monospace'; + mount.insertBefore(status, document.getElementById(CONTAINER_ID) ?? null); + } + const columns = createColumns(); + const fullRecords = createRecords(); + const shiftedRecords = createShiftedRecords(fullRecords); + let currentRecords: Record[] = fullRecords; + let enableGroup = true; + + const table = new VTable.ListTable(createOption(currentRecords, columns, enableGroup)); + (window as any).tableInstance = table; + + const updateStatus = (message: string) => { + if (status) { + status.textContent = message; + } + }; + + const applyOption = async (records: Record[], nextEnableGroup: boolean, label: string) => { + currentRecords = records; + enableGroup = nextEnableGroup; + const start = performance.now(); + updateStatus(`${label} 中...`); + await table.updateOption(createOption(currentRecords, columns, enableGroup)); + const cost = (performance.now() - start).toFixed(2); + updateStatus( + `${label} 完成,耗时 ${cost}ms,records=${currentRecords.length},group=${enableGroup ? 'on' : 'off'}` + ); + return Number(cost); + }; + + const runBenchmark = async (rounds = BENCHMARK_ROUNDS) => { + const samples: number[] = []; + updateStatus(`benchmark 预热中...`); + await applyOption(shiftedRecords, true, 'benchmark 预热切换到替换数据'); + await applyOption(fullRecords, true, 'benchmark 预热切换回原始数据'); + + for (let i = 0; i < rounds; i++) { + const targetRecords = i % 2 === 0 ? shiftedRecords : fullRecords; + const label = `benchmark 第 ${i + 1} 次`; + const cost = await applyOption(targetRecords, true, label); + samples.push(cost); + } + + const total = samples.reduce((sum, value) => sum + value, 0); + const average = Number((total / samples.length).toFixed(2)); + const min = Number(Math.min(...samples).toFixed(2)); + const max = Number(Math.max(...samples).toFixed(2)); + const summary = `benchmark 完成,samples=${samples.join(', ')},avg=${average}ms,min=${min}ms,max=${max}ms`; + updateStatus(summary); + return { samples, average, min, max }; + }; + + const attachButton = (label: string, handler: () => void | Promise) => { + const button = document.createElement('button'); + button.textContent = label; + button.style.marginRight = '8px'; + button.onclick = () => { + void handler(); + }; + toolbar?.appendChild(button); + }; + + attachButton('updateOption 替换数据', () => + applyOption(currentRecords === fullRecords ? shiftedRecords : fullRecords, true, 'updateOption 替换数据') + ); + attachButton('updateOption 切分组', () => applyOption(currentRecords, !enableGroup, 'updateOption 切换分组')); + attachButton('运行 benchmark x5', () => runBenchmark()); + attachButton('重置到初始场景', () => applyOption(fullRecords, true, '重置到 issue 初始场景')); + + (window as any).issue5183PerfDemo = { + table, + columns, + fullRecords, + shiftedRecords, + createOption, + applyOption, + runBenchmark + }; + + updateStatus( + '已按 issue #5183 场景初始化:1000x100、groupBy 开启。推荐先点“updateOption 替换数据”,再点“运行 benchmark x5”。' + ); +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index f9195f1ab..cf396d465 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -208,6 +208,14 @@ export const menus = [ path: 'list', name: 'list-group' }, + { + path: 'list', + name: 'list-group-tree-stick-updateOption' + }, + { + path: 'list', + name: 'list-group-updateOption-perf' + }, { path: 'list', name: 'list-groupBy' diff --git a/packages/vtable/issue-5183-benchmark/index.html b/packages/vtable/issue-5183-benchmark/index.html new file mode 100644 index 000000000..07a35e782 --- /dev/null +++ b/packages/vtable/issue-5183-benchmark/index.html @@ -0,0 +1,40 @@ + + + + + + issue-5183 benchmark + + + +
+
+
+
+
+ + + diff --git a/packages/vtable/issue-5183-benchmark/main.ts b/packages/vtable/issue-5183-benchmark/main.ts new file mode 100644 index 000000000..92aa0ca2d --- /dev/null +++ b/packages/vtable/issue-5183-benchmark/main.ts @@ -0,0 +1,131 @@ +import * as VTable from '../src'; + +const TOTAL_COLUMNS = 100; +const TOTAL_RECORDS = 1000; +const BENCHMARK_ROUNDS = 5; + +const container = document.getElementById('vTable') as HTMLElement; +const toolbar = document.getElementById('toolbar') as HTMLElement; +const status = document.getElementById('status') as HTMLElement; + +const categoryPool = ['Office Supplies', 'Furniture', 'Technology', 'Appliances']; +const subCategoryPool = ['Paper', 'Labels', 'Phones', 'Chairs', 'Tables', 'Binders', 'Storage', 'Accessories']; + +function createColumns() { + const columns: VTable.ColumnsDefine = [ + { field: 'Category', title: 'Category', width: 'auto' }, + { field: 'Sub-Category', title: 'Sub-Category', width: 'auto' } + ]; + + for (let i = 0; i < TOTAL_COLUMNS - 2; i++) { + columns.push({ + field: `field_${i}`, + title: `Field ${i}`, + width: 'auto' + }); + } + + return columns; +} + +function createRecords() { + return Array.from({ length: TOTAL_RECORDS }, (_, rowIndex) => { + const record: Record = { + Category: categoryPool[rowIndex % categoryPool.length], + 'Sub-Category': subCategoryPool[rowIndex % subCategoryPool.length] + }; + + for (let colIndex = 0; colIndex < TOTAL_COLUMNS - 2; colIndex++) { + record[`field_${colIndex}`] = `R${rowIndex}-C${colIndex}-${(rowIndex + colIndex) % 97}`; + } + + return record; + }); +} + +function createShiftedRecords(baseRecords: Record[]) { + return baseRecords.map((record, index) => ({ + ...record, + Category: categoryPool[(index + 1) % categoryPool.length], + 'Sub-Category': subCategoryPool[(index + 2) % subCategoryPool.length] + })); +} + +const columns = createColumns(); +const fullRecords = createRecords(); +const shiftedRecords = createShiftedRecords(fullRecords); + +function createOption(records: Record[]) { + return { + container, + records, + columns, + widthMode: 'standard' as const, + defaultColWidth: 120, + groupBy: ['Category', 'Sub-Category'] + }; +} + +let currentRecords = fullRecords; +const table = new VTable.ListTable(createOption(currentRecords)); + +function updateStatus(message: string) { + status.textContent = message; +} + +async function applyOption(records: Record[], label: string) { + currentRecords = records; + updateStatus(`${label} 中...`); + const start = performance.now(); + await table.updateOption(createOption(records)); + const cost = Number((performance.now() - start).toFixed(2)); + updateStatus(`${label} 完成,耗时 ${cost}ms,records=${records.length}`); + return cost; +} + +async function runBenchmark(rounds = BENCHMARK_ROUNDS) { + const samples: number[] = []; + await applyOption(shiftedRecords, 'benchmark 预热切换到替换数据'); + await applyOption(fullRecords, 'benchmark 预热切换回原始数据'); + + for (let i = 0; i < rounds; i++) { + const targetRecords = i % 2 === 0 ? shiftedRecords : fullRecords; + const cost = await applyOption(targetRecords, `benchmark 第 ${i + 1} 次`); + samples.push(cost); + } + + const total = samples.reduce((sum, value) => sum + value, 0); + const average = Number((total / samples.length).toFixed(2)); + const min = Number(Math.min(...samples).toFixed(2)); + const max = Number(Math.max(...samples).toFixed(2)); + const result = { samples, average, min, max }; + updateStatus(`benchmark 完成,samples=${samples.join(', ')},avg=${average}ms,min=${min}ms,max=${max}ms`); + return result; +} + +function attachButton(label: string, handler: () => void | Promise) { + const button = document.createElement('button'); + button.textContent = label; + button.onclick = () => { + void handler(); + }; + toolbar.appendChild(button); +} + +attachButton('updateOption 替换数据', () => + applyOption(currentRecords === fullRecords ? shiftedRecords : fullRecords, 'updateOption 替换数据') +); +attachButton('运行 benchmark x5', () => runBenchmark()); +attachButton('重置初始数据', () => applyOption(fullRecords, '重置到初始场景')); + +(window as any).issue5183Benchmark = { + table, + fullRecords, + shiftedRecords, + applyOption, + runBenchmark +}; + +updateStatus( + 'issue #5183 benchmark 已初始化:1000x100、groupBy 开启,直接点击按钮即可测量 updateOption 替换数据耗时。' +); diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 070c74ee6..22af8a9e3 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -23,7 +23,9 @@ import { _setDataSource, _setRecords, checkHasAggregationOnColumnDefine, - generateAggregationForColumn + generateAggregationForColumn, + getHierarchyExpandLevel, + getListTableRowHierarchyType } from './core/tableHelper'; import { BaseTable } from './core'; import type { BaseTableAPI, ListTableProtected } from './ts-types/base-table'; @@ -43,7 +45,7 @@ import { getGroupCheckboxState, setCellCheckboxState } from './state/checkbox/ch import type { IEmptyTipComponent } from './components/empty-tip/empty-tip'; import { Factory } from './core/factory'; import { getGroupByDataConfig } from './core/group-helper'; -import { DataSource, type CachedDataSource } from './data'; +import { CachedDataSource, DataSource } from './data'; import { getValueFromDeepArray } from './data/DataSource'; import { listTableAddRecord, @@ -760,9 +762,21 @@ export class ListTable extends BaseTable implements ListTableAPI { } ) { const internalProps = this.internalProps; + const prevSortState = internalProps.sortState; + const nextSortStates = Array.isArray(options.sortState) + ? options.sortState + : options.sortState + ? [options.sortState] + : []; + const hasActiveSortState = nextSortStates.some(item => item?.field && item?.order && item.order !== 'normal'); + const shouldSkipInitialClearCells = + Boolean(options.records) || (!!options.dataSource && this.dataSource !== options.dataSource); this.pluginManager.removeOrAddPlugins(options.plugins); - super.updateOption(options, updateConfig); + super.updateOption(options, { + ...updateConfig, + skipClearCells: shouldSkipInitialClearCells + } as typeof updateConfig & { skipClearCells?: boolean }); internalProps.frozenColDragHeaderMode = options.dragOrder?.frozenColDragHeaderMode ?? options.frozenColDragHeaderMode; //分页配置 @@ -818,6 +832,14 @@ export class ListTable extends BaseTable implements ListTableAPI { if (options.dataSource && this.dataSource !== options.dataSource) { // _setDataSource(this, options.dataSource); this.dataSource = options.dataSource; + } else if ( + options.records && + options.records === internalProps.records && + options.sortState === prevSortState && + !hasActiveSortState && + this.dataSource instanceof CachedDataSource + ) { + this._refreshCurrentRecordsForOptionUpdate(options.records as any); } else if (options.records) { this.setRecords(options.records as any, { sortState: options.sortState @@ -855,6 +877,44 @@ export class ListTable extends BaseTable implements ListTableAPI { setTimeout(resolve, 0); }); } + + private _refreshCurrentRecordsForOptionUpdate(records: Array): void { + const dataSource = this.dataSource as CachedDataSource; + this.stateManager.endResizeIfResizing(); + clearChartRenderQueue(); + const oldHoverState = { col: this.stateManager.hover.cellPos.col, row: this.stateManager.hover.cellPos.row }; + + this.scenegraph.clearCells(); + this.internalProps.records = records; + dataSource.refreshRecords( + records, + this.internalProps.dataConfig, + this.pagination, + this.internalProps.columns, + getListTableRowHierarchyType(this), + getHierarchyExpandLevel(this) + ); + this.refreshRowColCount(); + this.stateManager.initCheckedState(records); + this.clearCellStyleCache(); + this.scenegraph.createSceneGraph(); + this.stateManager.updateHoverPos(oldHoverState.col, oldHoverState.row); + + this._updateSize(); + const layoutOrder = this.options.componentLayoutOrder ?? ['legend', 'title']; + layoutOrder.forEach(component => { + if (component === 'legend') { + this.internalProps.legends?.forEach(legend => { + legend?.resize(); + }); + } else if (component === 'title') { + this.internalProps.title?.resize(); + } + }); + + this.scenegraph.resize(); + this.render(); + } /** * 更新页码 * @param pagination 修改页码 diff --git a/packages/vtable/src/core/BaseTable.ts b/packages/vtable/src/core/BaseTable.ts index be40871fb..6f46c0063 100644 --- a/packages/vtable/src/core/BaseTable.ts +++ b/packages/vtable/src/core/BaseTable.ts @@ -2967,7 +2967,9 @@ export abstract class BaseTable extends EventTarget implements BaseTableAPI { internalProps.emptyTip = null; internalProps.layoutMap.release(); clearChartRenderQueue(); - this.scenegraph.clearCells(); + if (!(updateConfig as { skipClearCells?: boolean })?.skipClearCells) { + this.scenegraph.clearCells(); + } this.scenegraph.updateComponent(); this.stateManager.updateOptionSetState(); diff --git a/packages/vtable/src/core/tableHelper.ts b/packages/vtable/src/core/tableHelper.ts index 3cb077996..b95270e89 100644 --- a/packages/vtable/src/core/tableHelper.ts +++ b/packages/vtable/src/core/tableHelper.ts @@ -60,19 +60,23 @@ export function _dealWithUpdateDataSource(table: BaseTableAPI, fn: (table: BaseT }) ]; } + +export function getListTableRowHierarchyType(table: ListTableAPI): 'grid' | 'tree' { + const tableWithPlugins = table as ListTableAPI & { pluginManager?: PluginManager }; + let rowHierarchyType = table.internalProps.layoutMap.rowHierarchyType; + if (isArray(table.internalProps.dataConfig?.groupByRules)) { + rowHierarchyType = 'tree'; + } + if (tableWithPlugins.pluginManager.getPluginByName('Master Detail Plugin')) { + rowHierarchyType = 'grid'; + } + return rowHierarchyType; +} /** @private */ export function _setRecords(table: ListTableAPI, records: any[] = []): void { - const tableWithPlugins = table as ListTableAPI & { pluginManager?: PluginManager }; - _dealWithUpdateDataSource(table, () => { table.internalProps.records = records; - let rowHierarchyType = table.internalProps.layoutMap.rowHierarchyType; - if (isArray(table.internalProps.dataConfig?.groupByRules)) { - rowHierarchyType = 'tree'; - } - if (tableWithPlugins.pluginManager.getPluginByName('Master Detail Plugin')) { - rowHierarchyType = 'grid'; - } + const rowHierarchyType = getListTableRowHierarchyType(table); const newDataSource = (table.internalProps.dataSource = CachedDataSource.ofArray( records, table.internalProps.dataConfig, @@ -85,7 +89,7 @@ export function _setRecords(table: ListTableAPI, records: any[] = []): void { }); } -function getHierarchyExpandLevel(table: ListTableAPI) { +export function getHierarchyExpandLevel(table: ListTableAPI) { if ((table.options as ListTableConstructorOptions).hierarchyExpandLevel) { return (table.options as ListTableConstructorOptions).hierarchyExpandLevel; } else if ((table.internalProps as ListTableProtected).groupBy) { diff --git a/packages/vtable/src/data/CachedDataSource.ts b/packages/vtable/src/data/CachedDataSource.ts index 03e055205..05000ce44 100644 --- a/packages/vtable/src/data/CachedDataSource.ts +++ b/packages/vtable/src/data/CachedDataSource.ts @@ -209,6 +209,49 @@ export class CachedDataSource extends DataSource { return this.dataConfig?.groupByRules?.length ?? 0; } + refreshRecords( + records: any[] = [], + dataConfig?: IListTableDataConfig, + pagination?: IPagination, + columns?: ColumnsDefine, + rowHierarchyType?: 'grid' | 'tree', + hierarchyExpandLevel?: number + ) { + this.clearCache(); + this.beforeChangedRecordsMap.clear(); + this.groupAggregator = null; + this.addRecordRule = dataConfig?.addRecordRule || 'Object'; + this.dataConfig = dataConfig; + this.columns = columns; + this._isGrouped = isArray(dataConfig?.groupByRules); + this.dataSourceObj = { + get: (index: number): any => records[index], + length: records.length, + records + }; + (this as any)._source = this.processRecords(records); + this.sourceLength = this.source?.length || 0; + this.sortedIndexMap.clear(); + this._currentPagerIndexedData = []; + this.userPagination = pagination; + this.pagination = pagination || { + totalCount: this.sourceLength, + perPageCount: this.sourceLength, + currentPage: 0 + }; + this.hierarchyExpandLevel = hierarchyExpandLevel >= 1 ? hierarchyExpandLevel : 0; + this.currentIndexedData = Array.from({ length: this.sourceLength }, (_, i) => i); + if (!this.userPagination) { + this.pagination.perPageCount = this.sourceLength; + this.pagination.totalCount = this.sourceLength; + } + if (rowHierarchyType === 'tree') { + this.initTreeHierarchyState(); + } + this.rowHierarchyType = rowHierarchyType; + this.updatePagerData(); + } + updateGroup() { this.clearCache(); diff --git a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts index 4268b776d..2311cc18d 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-helper.ts @@ -63,7 +63,8 @@ export function createCell( customResult?: { elementsGroup?: VGroup; renderDefault: boolean; - } + }, + headerStyle?: any ): Group { let isAsync = false; let cellGroup: Group; @@ -204,7 +205,8 @@ export function createCell( renderDefault, cellTheme, range, - isAsync + isAsync, + headerStyle ); const axisConfig = table.internalProps.layoutMap.getAxisConfigInPivotChart(col, row); @@ -332,7 +334,8 @@ export function createCell( renderDefault, cellTheme, range, - isAsync + isAsync, + headerStyle ); // 创建bar group @@ -395,7 +398,8 @@ export function createCell( true, cellTheme, range, - isAsync + isAsync, + headerStyle ); } else { const createCheckboxCellGroup = Factory.getFunction('createCheckboxCellGroup') as CreateCheckboxCellGroup; @@ -445,7 +449,8 @@ export function createCell( true, cellTheme, range, - isAsync + isAsync, + headerStyle ); } else { const createRadioCellGroup = Factory.getFunction('createRadioCellGroup') as CreateRadioCellGroup; diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts index 95d79da0a..d9cc9c330 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/text-cell.ts @@ -46,9 +46,10 @@ export function createCellGroup( renderDefault: boolean, cellTheme: IThemeSpec, range: CellRange | undefined, - isAsync: boolean + isAsync: boolean, + headerStyle?: any ): Group { - const headerStyle = table._getCellStyle(col, row); // to be fixed + headerStyle = headerStyle || table._getCellStyle(col, row); // to be fixed const functionalPadding = getFunctionalProp('padding', headerStyle, col, row, table); if (isValid(functionalPadding)) { padding = functionalPadding; diff --git a/packages/vtable/src/scenegraph/group-creater/column-helper.ts b/packages/vtable/src/scenegraph/group-creater/column-helper.ts index 772d5b31a..d0aab1c00 100644 --- a/packages/vtable/src/scenegraph/group-creater/column-helper.ts +++ b/packages/vtable/src/scenegraph/group-creater/column-helper.ts @@ -78,6 +78,7 @@ export function createComplexColumn( let customStyle; let customResult; let isCustomMerge = false; + let rawRecord; if (table.internalProps.customMergeCell) { const customMerge = table.getCustomMerge(col, row); if (customMerge) { @@ -157,9 +158,22 @@ export function createComplexColumn( !range && (table.internalProps.enableTreeNodeMerge || cellLocation !== 'body' || (define as TextColumnDefine)?.mergeCell) ) { - // 只有表头或者column配置合并单元格后再进行信息获取 - range = table.getCellRange(col, row); - isMerge = range.start.col !== range.end.col || range.start.row !== range.end.row; + // grouped/tree merge 仅发生在带 vtableMerge 标记的数据行上,普通 body cell 不必全量探测 range + if ( + table.internalProps.enableTreeNodeMerge && + cellLocation === 'body' && + !(define as TextColumnDefine)?.mergeCell + ) { + rawRecord = table.getCellRawRecord(col, row); + if (rawRecord?.vtableMerge) { + range = table.getCellRange(col, row); + isMerge = range.start.col !== range.end.col || range.start.row !== range.end.row; + } + } else { + // 只有表头或者column配置合并单元格后再进行信息获取 + range = table.getCellRange(col, row); + isMerge = range.start.col !== range.end.col || range.start.row !== range.end.row; + } // 所有Merge单元格,只保留左上角一个真实的单元格,其他使用空Group占位 if (isMerge) { const needUpdateRange = rowStart > range.start.row; @@ -170,7 +184,7 @@ export function createComplexColumn( } let isVtableMerge = false; if (table.internalProps.enableTreeNodeMerge && isMerge) { - const rawRecord = table.getCellRawRecord(range.start.col, range.start.row); + rawRecord = rawRecord ?? table.getCellRawRecord(range.start.col, range.start.row); const { vtableMergeName, vtableMerge } = rawRecord ?? {}; isVtableMerge = vtableMerge; @@ -275,7 +289,8 @@ export function createComplexColumn( mayHaveIcon, cellTheme, range, - customResult + customResult, + cellStyle ); columnGroup.updateColumnRowNumber(row); if (isMerge) { @@ -402,7 +417,8 @@ function callCreateCellForPromiseValue(createCellArgs: any) { mayHaveIcon, cellTheme, range, - customResult + customResult, + cellStyle ); } function dealMerge(range: CellRange, mergeMap: MergeMap, table: BaseTableAPI, forceUpdate: boolean) { diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index 58737fd8a..d52ea160b 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -2058,60 +2058,51 @@ export class Scenegraph { } updateContainerAttrHeightAndY() { - for (let i = 0; i < this.cornerHeaderGroup.children.length; i++) { - updateContainerChildrenY(this.cornerHeaderGroup.children[i] as Group, 0); - } - for (let i = 0; i < this.colHeaderGroup.children.length; i++) { - updateContainerChildrenY(this.colHeaderGroup.children[i] as Group, 0); - } - for (let i = 0; i < this.rightTopCornerGroup.children.length; i++) { - updateContainerChildrenY(this.rightTopCornerGroup.children[i] as Group, 0); - } - for (let i = 0; i < this.rowHeaderGroup.children.length; i++) { - this.rowHeaderGroup.children[i].firstChild && + this.cornerHeaderGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); + this.colHeaderGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); + this.rightTopCornerGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); + this.rowHeaderGroup.forEachChildrenSkipChild((column: Group) => { + column.firstChild && updateContainerChildrenY( - this.rowHeaderGroup.children[i] as Group, - (this.rowHeaderGroup.children[i].firstChild as Group).row > 0 - ? this.table.getRowsHeight( - this.table.frozenRowCount ?? 0, - (this.rowHeaderGroup.children[i].firstChild as Group).row - 1 - ) + column, + (column.firstChild as Group).row > 0 + ? this.table.getRowsHeight(this.table.frozenRowCount ?? 0, (column.firstChild as Group).row - 1) : 0 ); - } - for (let i = 0; i < this.bodyGroup.children.length; i++) { - this.bodyGroup.children[i].firstChild && + }); + this.bodyGroup.forEachChildrenSkipChild((column: Group) => { + column.firstChild && updateContainerChildrenY( - this.bodyGroup.children[i] as Group, - (this.bodyGroup.children[i].firstChild as Group).row > 0 - ? this.table.getRowsHeight( - this.table.frozenRowCount ?? 0, - (this.bodyGroup.children[i].firstChild as Group).row - 1 - ) + column, + (column.firstChild as Group).row > 0 + ? this.table.getRowsHeight(this.table.frozenRowCount ?? 0, (column.firstChild as Group).row - 1) : 0 ); - } - for (let i = 0; i < this.rightFrozenGroup.children.length; i++) { - this.rightFrozenGroup.children[i].firstChild && + }); + this.rightFrozenGroup.forEachChildrenSkipChild((column: Group) => { + column.firstChild && updateContainerChildrenY( - this.rightFrozenGroup.children[i] as Group, - (this.rightFrozenGroup.children[i].firstChild as Group).row > 0 - ? this.table.getRowsHeight( - this.table.frozenRowCount ?? 0, - (this.rightFrozenGroup.children[i].firstChild as Group).row - 1 - ) + column, + (column.firstChild as Group).row > 0 + ? this.table.getRowsHeight(this.table.frozenRowCount ?? 0, (column.firstChild as Group).row - 1) : 0 ); - } - for (let i = 0; i < this.leftBottomCornerGroup.children.length; i++) { - updateContainerChildrenY(this.leftBottomCornerGroup.children[i] as Group, 0); - } - for (let i = 0; i < this.bottomFrozenGroup.children.length; i++) { - updateContainerChildrenY(this.bottomFrozenGroup.children[i] as Group, 0); - } - for (let i = 0; i < this.rightBottomCornerGroup.children.length; i++) { - updateContainerChildrenY(this.rightBottomCornerGroup.children[i] as Group, 0); - } + }); + this.leftBottomCornerGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); + this.bottomFrozenGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); + this.rightBottomCornerGroup.forEachChildrenSkipChild((column: Group) => { + updateContainerChildrenY(column, 0); + }); } updateContainer( updateConfig: { async?: boolean; needUpdateCellY?: boolean } = { async: false, needUpdateCellY: false } diff --git a/packages/vtable/src/scenegraph/utils/text-icon-layout.ts b/packages/vtable/src/scenegraph/utils/text-icon-layout.ts index 3591e1a7c..63b5ca19e 100644 --- a/packages/vtable/src/scenegraph/utils/text-icon-layout.ts +++ b/packages/vtable/src/scenegraph/utils/text-icon-layout.ts @@ -67,8 +67,7 @@ export function createCellContent( // const absoluteLeftIcons: ColumnIconOption[] = []; // const absoluteRightIcons: ColumnIconOption[] = []; - let contentWidth: number; - let contentHeight: number; + let contentWidth = 0; let leftIconWidth = 0; // let leftIconHeight = 0; let rightIconWidth = 0; @@ -167,8 +166,9 @@ export function createCellContent( // cellGroup.appendChild(line); // cellGroup.appendChild(line1); - contentWidth = wrapText.AABBBounds.width(); - contentHeight = wrapText.AABBBounds.height(); + if (autoColWidth) { + contentWidth = wrapText.AABBBounds.width(); + } } } else { // // icon分类 @@ -408,8 +408,9 @@ export function createCellContent( cellGroup.appendChild(cellContent); cellContent.layout(); - contentWidth = cellContent.AABBBounds.width(); - contentHeight = cellContent.AABBBounds.height(); + if (autoColWidth) { + contentWidth = cellContent.AABBBounds.width(); + } } else { // 没有content icon,cellGroup: CellIcons + wrapText/richtext // cellGroup.appendChild(textMark); @@ -418,8 +419,9 @@ export function createCellContent( } else { cellGroup.appendChild(textMark); } - contentWidth = textMark.AABBBounds.width(); - contentHeight = textMark.AABBBounds.height(); + if (autoColWidth) { + contentWidth = textMark.AABBBounds.width(); + } } } diff --git a/packages/vtable/tsconfig.eslint.json b/packages/vtable/tsconfig.eslint.json index cbfa15726..a9ba93009 100644 --- a/packages/vtable/tsconfig.eslint.json +++ b/packages/vtable/tsconfig.eslint.json @@ -14,6 +14,6 @@ ] } }, - "include": ["src", "__tests__", "examples", "site-demo"], + "include": ["src", "__tests__", "examples", "site-demo", "issue-5183-benchmark"], "exclude": ["bugserver-config"] }