diff --git a/packages/vtable/__tests__/chart-graphic.test.ts b/packages/vtable/__tests__/chart-graphic.test.ts index 0e0d859437..0f0984fdc1 100644 --- a/packages/vtable/__tests__/chart-graphic.test.ts +++ b/packages/vtable/__tests__/chart-graphic.test.ts @@ -1,4 +1,11 @@ import { Chart } from '../src/scenegraph/graphic/chart'; +import { createChartCellGroup } from '../src/scenegraph/group-creater/cell-type/chart-cell'; +import { Group } from '../src/scenegraph/graphic/group'; +import * as register from '../src/register'; +import { chartTypes } from '../src/chartModule'; + +const GET_CELL_ADDRESS_ERROR_MESSAGE = + "Cannot destructure property 'col' of 'getCellAddressByRecord(...)' as it is undefined."; class MockChart { static globalConfig = { uniqueTooltip: false }; @@ -24,7 +31,46 @@ class MockChart { } } +class ThrowOnceChart extends MockChart { + renderCount = 0; + dirtyBoundsCount = 0; + + renderSync() { + this.renderCount++; + if (this.renderCount === 1) { + throw new TypeError(GET_CELL_ADDRESS_ERROR_MESSAGE); + } + } + + getStage() { + return { + enableDirtyBounds: () => { + this.dirtyBoundsCount++; + } + }; + } +} + +class InvalidSpecChart extends ThrowOnceChart { + renderSync() { + this.renderCount++; + throw new Error('invalid chart spec'); + } +} + +class MissingCellAddressChart extends ThrowOnceChart { + renderSync() { + this.renderCount++; + throw new TypeError(GET_CELL_ADDRESS_ERROR_MESSAGE); + } +} + describe('Chart graphic', () => { + afterEach(() => { + jest.useRealTimers(); + delete chartTypes['mock-chart']; + }); + test('keeps runtime refs when VRender builds static state snapshots', () => { const canvas = document.createElement('canvas') as HTMLCanvasElement & { __vtable__?: unknown }; const tableRef: { internalProps?: unknown } = {}; @@ -62,4 +108,255 @@ describe('Chart graphic', () => { expect(chart.attribute.chartInstance).toBe(chart.chartInstance); expect(chart.attribute.chartInstance).toBe((chart as any).baseAttributes.chartInstance); }); + + test('defers constructor render errors so chart instances can be assigned before retrying', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + + let chart: Chart | undefined; + expect(() => { + chart = new Chart(false, { + stroke: false, + x: 0, + y: 0, + width: 100, + height: 80, + canvas, + mode: 'desktop-browser', + modeParams: {}, + spec: { type: 'bar' }, + ClassType: ThrowOnceChart, + chartInstance: undefined, + dataId: 'data', + data: [], + cellPadding: [0, 0, 0, 0], + dpr: 1, + axes: [], + tableChartOption: {}, + detectPickChartItem: false, + shouldDeferRenderError: () => true + } as any); + }).not.toThrow(); + + const chartInstance = chart?.chartInstance as ThrowOnceChart; + expect(chartInstance).toBeInstanceOf(ThrowOnceChart); + expect(chartInstance.renderCount).toBe(1); + expect(chartInstance.dirtyBoundsCount).toBe(0); + + jest.runOnlyPendingTimers(); + + expect(chartInstance.renderCount).toBe(2); + expect(chartInstance.dirtyBoundsCount).toBe(1); + expect(chart?.renderRetryTimer).toBeUndefined(); + }); + + test('clears deferred constructor render retry on release', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + + const chart = new Chart(false, { + stroke: false, + x: 0, + y: 0, + width: 100, + height: 80, + canvas, + mode: 'desktop-browser', + modeParams: {}, + spec: { type: 'bar' }, + ClassType: ThrowOnceChart, + chartInstance: undefined, + dataId: 'data', + data: [], + cellPadding: [0, 0, 0, 0], + dpr: 1, + axes: [], + tableChartOption: {}, + detectPickChartItem: false, + shouldDeferRenderError: () => true + } as any); + + const chartInstance = chart.chartInstance as ThrowOnceChart; + expect(chartInstance.renderCount).toBe(1); + expect(jest.getTimerCount()).toBe(1); + + chart.release(); + + expect(jest.getTimerCount()).toBe(0); + jest.runOnlyPendingTimers(); + expect(chartInstance.renderCount).toBe(1); + }); + + test('throws non-recoverable constructor render errors synchronously', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + + let chart: Chart | undefined; + expect(() => { + chart = new Chart(false, { + stroke: false, + x: 0, + y: 0, + width: 100, + height: 80, + canvas, + mode: 'desktop-browser', + modeParams: {}, + spec: { type: 'bar' }, + ClassType: InvalidSpecChart, + chartInstance: undefined, + dataId: 'data', + data: [], + cellPadding: [0, 0, 0, 0], + dpr: 1, + axes: [], + tableChartOption: {}, + detectPickChartItem: false + } as any); + }).toThrow('invalid chart spec'); + + expect(chart).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + test('throws unmatched getCellAddressByRecord errors synchronously', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + + let chart: Chart | undefined; + expect(() => { + chart = new Chart(false, { + stroke: false, + x: 0, + y: 0, + width: 100, + height: 80, + canvas, + mode: 'desktop-browser', + modeParams: {}, + spec: { type: 'bar' }, + ClassType: MissingCellAddressChart, + chartInstance: undefined, + dataId: 'data', + data: [], + cellPadding: [0, 0, 0, 0], + dpr: 1, + axes: [], + tableChartOption: {}, + detectPickChartItem: false + } as any); + }).toThrow(GET_CELL_ADDRESS_ERROR_MESSAGE); + + expect(chart).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + test('throws constructor render errors synchronously when the defer predicate rejects them', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + + let chart: Chart | undefined; + expect(() => { + chart = new Chart(false, { + stroke: false, + x: 0, + y: 0, + width: 100, + height: 80, + canvas, + mode: 'desktop-browser', + modeParams: {}, + spec: { type: 'bar' }, + ClassType: MissingCellAddressChart, + chartInstance: undefined, + dataId: 'data', + data: [], + cellPadding: [0, 0, 0, 0], + dpr: 1, + axes: [], + tableChartOption: {}, + detectPickChartItem: false, + shouldDeferRenderError: () => false + } as any); + }).toThrow(GET_CELL_ADDRESS_ERROR_MESSAGE); + + expect(chart).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + test('uses pivot chart records before deferring constructor render errors', () => { + jest.useFakeTimers(); + const canvas = document.createElement('canvas'); + const record = { indicator: 1 }; + const columnGroup = new Group({}); + register.chartModule('mock-chart', ThrowOnceChart); + const table = { + canvas, + colCount: 2, + rowCount: 2, + theme: { + cellInnerBorder: true, + frameStyle: {} + }, + options: { + mode: 'desktop-browser', + modeParams: {}, + chartOption: {} + }, + internalProps: { + pixelRatio: 1, + layoutMap: { + getChartAxes: (): any[] => [], + setChartInstance: jest.fn() + } + }, + scenegraph: { + stage: { + window: { + getContext: () => ({ canvas }) + } + } + }, + _isConstructingPivotChart: true, + _getCellStyle: () => ({}), + records: [record], + getCellValue: (): any[] => [], + getCellAddressByRecord: jest.fn(value => (value === record ? { col: 1, row: 1 } : undefined)), + isPivotChart: () => true + }; + const cellTheme = { group: {} }; + + const cellGroup = createChartCellGroup( + null, + columnGroup, + 0, + 0, + 1, + 1, + 100, + 80, + [0, 0, 0, 0], + '', + 'mock-chart', + { type: 'bar', label: { dataFilter: (): any[] => [] } }, + undefined, + 'data', + table as any, + cellTheme as any, + true, + false, + false + ); + + const chart = cellGroup.lastChild as Chart; + const chartInstance = chart.chartInstance as ThrowOnceChart; + expect(chartInstance.renderCount).toBe(1); + expect(jest.getTimerCount()).toBe(1); + + jest.runOnlyPendingTimers(); + + expect(chartInstance.renderCount).toBe(2); + expect(table.getCellAddressByRecord).toHaveBeenCalledWith(record); + expect(table.internalProps.layoutMap.setChartInstance).toHaveBeenCalledWith(1, 1, chartInstance); + }); }); diff --git a/packages/vtable/examples/debug/bugserver-6a7b16f-pivot-chart.ts b/packages/vtable/examples/debug/bugserver-6a7b16f-pivot-chart.ts new file mode 100644 index 0000000000..9bb61b42e2 --- /dev/null +++ b/packages/vtable/examples/debug/bugserver-6a7b16f-pivot-chart.ts @@ -0,0 +1,181 @@ +import * as VTable from '../../src'; +import { bindDebugTool } from '../../src/scenegraph/debug-tool'; + +const CONTAINER_ID = 'vTable'; +const GET_CELL_ADDRESS_ERROR_MESSAGE = + "Cannot destructure property 'col' of 'getCellAddressByRecord(...)' as it is undefined."; + +class BugserverChart { + spec: any; + stage = { + viewWidth: 600, + viewHeight: 371, + window: { + dpr: 1, + getContext() { + return {}; + }, + getViewBoxTransform() { + return { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + clone() { + return { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + multiply() { + return this; + } + }; + } + }; + }, + setViewBoxTransform() { + // noop + } + }, + enableDirtyBounds() { + // noop + }, + renderTo() { + // noop + } + }; + + constructor(spec: any) { + this.spec = spec; + } + + renderSync() { + this.spec?.label?.dataFilter?.([{ data: this.spec.data.values[0] }]); + } + + getStage() { + return this.stage; + } + + getChart() { + return { + setLayoutTag() { + // noop + } + }; + } + + getSpec() { + return this.spec; + } + + updateViewBox() { + // noop + } + + updateDataSync() { + // noop + } + + updateFullDataSync() { + // noop + } + + updateSpecSync(spec: any) { + this.spec = spec; + } + + updateModelSpec() { + // noop + } + + updateModelSpecSync() { + // noop + } + + updateState() { + // noop + } + + on() { + // noop + } + + disableTooltip() { + // noop + } + + release() { + // noop + } +} + +VTable.register.chartModule('bugserver-chart', BugserverChart); + +export function createTable() { + const width = 600; + const height = width / 1.618; + const instanceRef: { current?: VTable.PivotChart } = {}; + + const records = [ + { + '10001': '求和(qps)的总额百分比', + '10002': '0.001031658322960409623206456697', + '10003': '260622161148168', + '20001': '求和(qps)的总额百分比', + '260622161148168': '0.001031658322960409623206456697', + '260624121706060': '05_[0.5]', + '260811200516176': '直播' + } + ]; + + const option: VTable.PivotChartConstructorOptions = { + records, + disableInteraction: true, + rows: [], + columns: [{ dimensionKey: '260811200516176', title: '体裁' }], + indicatorsAsCol: false, + rowTree: [{ indicatorKey: '10002', value: '' }], + columnTree: [{ dimensionKey: '260811200516176', value: '直播' }], + indicators: [ + { + indicatorKey: '10002', + cellType: 'chart', + chartModule: 'bugserver-chart', + chartSpec: { + type: 'bar', + xField: '260811200516176', + yField: '10002', + data: { + values: records + }, + label: { + dataFilter(labels: { data: Record }[]) { + const cellAddress = instanceRef.current?.getCellAddressByRecord(labels[0].data); + if (!cellAddress) { + throw new TypeError(GET_CELL_ADDRESS_ERROR_MESSAGE); + } + return instanceRef.current?.getCellValue(cellAddress.col, cellAddress.row) ?? labels; + } + } + } + } + ] + }; + + const dom = document.getElementById(CONTAINER_ID)!; + dom.style.width = `${width}px`; + dom.style.height = `${height}px`; + + const instance = new VTable.PivotChart(dom, option); + instanceRef.current = instance; + window.tableInstance = instance; + instance.updateOption(option); + + bindDebugTool(instance.scenegraph.stage, { customGrapicKeys: ['col', 'row'] }); +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5ea0fb6253..6aa95225fa 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -10,6 +10,10 @@ export const menus = [ path: 'debug', name: 'bugserver' }, + { + path: 'debug', + name: 'bugserver-6a7b16f-pivot-chart' + }, { path: 'debug', name: 'site' diff --git a/packages/vtable/src/PivotChart.ts b/packages/vtable/src/PivotChart.ts index 0fdb4dc221..9792d7f8c9 100644 --- a/packages/vtable/src/PivotChart.ts +++ b/packages/vtable/src/PivotChart.ts @@ -268,23 +268,28 @@ export class PivotChart extends BaseTable implements PivotChartAPI { this.internalProps.useOneRowHeightFillAll = false; // this.internalProps.frozenColCount = this.options.frozenColCount || this.rowHeaderLevelCount; // 生成单元格场景树 - this.scenegraph.createSceneGraph(); - if (options.title) { - const Title = Factory.getComponent('title') as ITitleComponent; - this.internalProps.title = new Title(options.title, this); - // this.scenegraph.resize();//下面有个resize了 所以这个可以去掉 - } - if (this.options.emptyTip) { - if (this.internalProps.emptyTip) { - this.internalProps.emptyTip?.resetVisible(); - } else { - const EmptyTip = Factory.getComponent('emptyTip') as IEmptyTipComponent; - this.internalProps.emptyTip = new EmptyTip(this.options.emptyTip, this); - this.internalProps.emptyTip?.resetVisible(); + (this as any)._isConstructingPivotChart = true; + try { + this.scenegraph.createSceneGraph(); + if (options.title) { + const Title = Factory.getComponent('title') as ITitleComponent; + this.internalProps.title = new Title(options.title, this); + // this.scenegraph.resize();//下面有个resize了 所以这个可以去掉 } + if (this.options.emptyTip) { + if (this.internalProps.emptyTip) { + this.internalProps.emptyTip?.resetVisible(); + } else { + const EmptyTip = Factory.getComponent('emptyTip') as IEmptyTipComponent; + this.internalProps.emptyTip = new EmptyTip(this.options.emptyTip, this); + this.internalProps.emptyTip?.resetVisible(); + } + } + // 首次布局同样通过 BaseTable.resize() 完成,遵循 componentLayoutOrder 中的 title/legend 优先级 + this.resize(); + } finally { + (this as any)._isConstructingPivotChart = false; } - // 首次布局同样通过 BaseTable.resize() 完成,遵循 componentLayoutOrder 中的 title/legend 优先级 - this.resize(); //为了确保用户监听得到这个事件 这里做了异步 确保vtable实例已经初始化完成 setTimeout(() => { if (this.isReleased) { diff --git a/packages/vtable/src/scenegraph/graphic/chart.ts b/packages/vtable/src/scenegraph/graphic/chart.ts index 651b1a5f97..a6fe189c8a 100644 --- a/packages/vtable/src/scenegraph/graphic/chart.ts +++ b/packages/vtable/src/scenegraph/graphic/chart.ts @@ -43,6 +43,7 @@ interface IChartGraphicAttribute extends IGroupGraphicAttribute { col?: number; row?: number; detectPickChartItem?: boolean; + shouldDeferRenderError?: (error: unknown) => boolean; } const CHART_RUNTIME_ATTRIBUTE_KEYS: (keyof IChartGraphicAttribute)[] = [ @@ -56,11 +57,41 @@ const CHART_RUNTIME_ATTRIBUTE_KEYS: (keyof IChartGraphicAttribute)[] = [ 'cellPadding', 'axes', 'tableChartOption', - 'detectPickChartItem' + 'detectPickChartItem', + 'shouldDeferRenderError' ]; export const CHART_NUMBER_TYPE = genNumberType(); +function renderChartInstanceInConstructor( + chartInstance: any, + shouldDeferRenderError?: (error: unknown) => boolean, + onRenderRetryFinish?: () => void +): ReturnType | undefined { + try { + chartInstance.renderSync(); + chartInstance.getStage().enableDirtyBounds(); + return undefined; + } catch (error) { + if (!shouldDeferRenderError?.(error)) { + throw error; + } + + if (typeof setTimeout !== 'function') { + throw error; + } + + return setTimeout(() => { + try { + chartInstance.renderSync(); + chartInstance.getStage().enableDirtyBounds(); + } finally { + onRenderRetryFinish?.(); + } + }, 0); + } +} + export class Chart extends Rect { type: GraphicType = 'chart' as any; declare attribute: IChartGraphicAttribute; @@ -71,6 +102,7 @@ export class Chart extends Rect { justShowMarkTooltip: boolean = undefined; justShowMarkTooltipTimer: number = Date.now(); delayRunDimensionHoverTimer: any = undefined; + renderRetryTimer: any = undefined; cacheCanvas: HTMLCanvasElement | { x: number; y: number; width: number; height: number; canvas: HTMLCanvasElement }[]; // HTMLCanvasElement isShareChartSpec: boolean; //针对chartSpec用户配置成函数形式的话 就不需要存储chartInstance了 会太占内存,使用这个变量 当渲染出缓存图表会就删除chartInstance实例 constructor(isShareChartSpec: boolean, params: IChartGraphicAttribute) { @@ -102,8 +134,9 @@ export class Chart extends Rect { autoFit: false }) )); - chartInstance.renderSync(); - chartInstance.getStage().enableDirtyBounds(); + this.renderRetryTimer = renderChartInstanceInConstructor(chartInstance, params.shouldDeferRenderError, () => { + this.renderRetryTimer = undefined; + }); params.chartInstance = this.chartInstance = chartInstance; this.syncRuntimeAttributes({ chartInstance } as Partial); } else { @@ -602,6 +635,17 @@ export class Chart extends Rect { clearTimeout(this.delayRunDimensionHoverTimer); this.delayRunDimensionHoverTimer = undefined; } + + clearRenderRetryTimer() { + clearTimeout(this.renderRetryTimer); + this.renderRetryTimer = undefined; + } + + release(...args: any[]) { + this.clearRenderRetryTimer(); + return (super.release as any)(...args); + } + /** * 图表失去焦点 * @param table @@ -627,6 +671,7 @@ export class Chart extends Rect { this.justShowMarkTooltip = undefined; this.justShowMarkTooltipTimer = Date.now(); this.clearDelayRunDimensionHoverTimer(); + this.clearRenderRetryTimer(); if (releaseChartInstance) { // move active chart view box out of browser view // to avoid async render when chart is releasd diff --git a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts index 27383d378b..61c05c99d6 100644 --- a/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts +++ b/packages/vtable/src/scenegraph/group-creater/cell-type/chart-cell.ts @@ -88,6 +88,36 @@ export function createChartCellGroup( cellGroup.AABBBounds.width(); // TODO 需要底层VRender修改 // chart if ((isNoChartDataRenderNothing && Array.isArray(table.getCellValue(col, row))) || !isNoChartDataRenderNothing) { + const spec = table.options.specTransformInCell ? table.options.specTransformInCell(chartSpec, col, row) : chartSpec; + const cellValue = table.getCellValue(col, row); + const data: any[] = Array.isArray(cellValue) ? (cellValue as any[]) : []; + const records = data.length + ? data + : Array.isArray((table as any).records) + ? ((table as any).records as any[]) + : Array.isArray((table.options as any)?.records) + ? ((table.options as any).records as any[]) + : []; + const allowConstructorRenderRetry = + table.isPivotChart() && + (table as any)._isConstructingPivotChart === true && + typeof spec?.label?.dataFilter === 'function'; + const shouldDeferRenderError = allowConstructorRenderRetry + ? (error: unknown) => { + const errorLike = error as { name?: string; message?: string }; + if ( + errorLike?.name !== 'TypeError' || + typeof errorLike.message !== 'string' || + !errorLike.message.includes('getCellAddressByRecord') || + !errorLike.message.includes('undefined') || + !records.length + ) { + return false; + } + + return records.some(record => !!(table as any).getCellAddressByRecord?.(record)); + } + : undefined; const chartGroup = new Chart(isShareChartSpec, { stroke: false, x: padding[3], @@ -96,13 +126,13 @@ export function createChartCellGroup( canvas: table.canvas ?? (table.scenegraph.stage.window.getContext().canvas as unknown as HTMLCanvasElement), mode: table.options.mode, modeParams: table.options.modeParams, - spec: table.options.specTransformInCell ? table.options.specTransformInCell(chartSpec, col, row) : chartSpec, + spec, ClassType, width: width - padding[3] - padding[1], height: height - padding[2] - padding[0], chartInstance, dataId, - data: table.getCellValue(col, row) || [], + data, cellPadding: padding, dpr: table.internalProps.pixelRatio, detectPickChartItem: table.options.customConfig?.detectPickChartItem, @@ -121,7 +151,8 @@ export function createChartCellGroup( // }, tableChartOption: table.options.chartOption, col, - row + row, + shouldDeferRenderError }); cellGroup.appendChild(chartGroup); // 将生成的实例存到layoutMap中 共享