From 72db65d18f4eb4d946ead854a8d2baec477d6096 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 02:15:37 +0000 Subject: [PATCH 1/2] fix: ensure boxPlot outliersField statistics after empty-init updateDataSync Register array-min/array-max for outliersField even when empty initial data omits that key, so updateDataSync can render outlier points. Co-authored-by: David --- ...tliers-updateDataSync-4270_2026-09-20.json | 10 + .../data/box-plot-transform-options.test.ts | 24 +++ .../unit/series/box-plot-outliers.test.ts | 199 ++++++++++++++++++ .../vchart/src/series/box-plot/box-plot.ts | 8 + 4 files changed, 241 insertions(+) create mode 100644 common/changes/@visactor/vchart/fix-boxplot-outliers-updateDataSync-4270_2026-09-20.json create mode 100644 packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts diff --git a/common/changes/@visactor/vchart/fix-boxplot-outliers-updateDataSync-4270_2026-09-20.json b/common/changes/@visactor/vchart/fix-boxplot-outliers-updateDataSync-4270_2026-09-20.json new file mode 100644 index 0000000000..a0cd0562cc --- /dev/null +++ b/common/changes/@visactor/vchart/fix-boxplot-outliers-updateDataSync-4270_2026-09-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: register boxPlot outliersField statistics so updateDataSync from empty data still renders outlier points", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts b/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts index 329c0d9459..a8db62050b 100644 --- a/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts +++ b/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts @@ -34,4 +34,28 @@ describe('box plot transform options', () => { ); expect(second[0]).toMatchObject({ [BOX_PLOT_OUTLIER_VALUE_FIELD]: 2, y: 'B', nextSeries: 'T' }); }); + + test('folds each numeric value from an outliersField array', () => { + const rows = foldOutlierData( + [ + { + latestData: [ + { x: 'Sub-Saharan Africa', y6: [12.01, 12.02, 14.03] }, + { x: 'South Asia', y1: 9.4 } + ] + } + ] as unknown as Parameters[0], + { + dimensionField: ['x'], + outliersField: 'y6' + } + ); + + expect( + rows + .map(row => row[BOX_PLOT_OUTLIER_VALUE_FIELD]) + .filter(value => typeof value === 'number') + ).toEqual([12.01, 12.02, 14.03]); + expect(rows[0]).toMatchObject({ [BOX_PLOT_OUTLIER_VALUE_FIELD]: 12.01, x: 'Sub-Saharan Africa' }); + }); }); diff --git a/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts b/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts new file mode 100644 index 0000000000..7e9affcd53 --- /dev/null +++ b/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts @@ -0,0 +1,199 @@ +import { DataSet } from '@visactor/vdataset'; +import { EventDispatcher } from '../../../src/event/event-dispatcher'; +import { GlobalScale } from '../../../src/scale/global-scale'; +import { BoxPlotChart, registerBoxplotChart } from '../../../src/chart/box-plot'; +import { BoxPlotSeries } from '../../../src/series/box-plot/box-plot'; +import { BOX_PLOT_OUTLIER_VALUE_FIELD } from '../../../src/constant/box-plot'; +import { getTestCompiler } from '../../util/factory/compiler'; +import { getTheme, initChartDataSet, seriesOption } from '../../util/context'; + +registerBoxplotChart(); + +const dataSet = new DataSet(); +initChartDataSet(dataSet); + +const filledValues = [ + { + x: 'Sub-Saharan Africa', + y1: 8.72, + y2: 9.73, + y3: 10.17, + y4: 10.51, + y5: 11.64, + y6: [12.01, 12.02, 14.03] + }, + { + x: 'South Asia', + y1: 9.4, + y2: 10.06, + y3: 10.75, + y4: 11.56, + y5: 12.5 + } +]; + +const createBoxPlotSeries = () => { + const series = new BoxPlotSeries( + { + type: 'boxPlot', + xField: 'x', + minField: 'y1', + q1Field: 'y2', + medianField: 'y3', + q3Field: 'y4', + maxField: 'y5', + outliersField: 'y6' + }, + seriesOption({ dataSet }) + ); + (series as any)._outliersField = 'y6'; + (series as any)._fieldX = ['x']; + (series as any)._fieldY = ['y5', 'y3', 'y2', 'y4', 'y1']; + (series as any)._xAxisHelper = { + getScale: () => ({ type: 'band' }) + }; + (series as any)._yAxisHelper = { + getScale: () => ({ type: 'linear' }) + }; + return series; +}; + +const createBoxPlotChart = (values?: Record[]) => { + const spec = { + type: 'boxPlot', + data: [ + { + id: 'boxPlot', + values: values ?? [] + } + ], + xField: 'x', + minField: 'y1', + q1Field: 'y2', + medianField: 'y3', + q3Field: 'y4', + maxField: 'y5', + outliersField: 'y6', + direction: 'vertical', + animation: false + } as any; + const transformer = new BoxPlotChart.transformerConstructor({ + type: 'boxPlot', + seriesType: 'boxPlot', + getTheme, + mode: 'desktop-browser' + }); + const info = transformer.initChartSpec(spec); + const chartDataSet = new DataSet(); + initChartDataSet(chartDataSet); + const chart = new BoxPlotChart(spec, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + eventDispatcher: new EventDispatcher({} as any, { addEventListener: () => {} } as any), + globalInstance: { + isAnimationEnable: () => false, + getContainer: () => ({}), + getTooltipHandlerByUser: (() => undefined) as () => undefined + }, + render: {} as any, + dataSet: chartDataSet, + map: new Map(), + container: null, + mode: 'desktop-browser', + getCompiler: getTestCompiler, + globalScale: new GlobalScale([], { getAllSeries: () => [] as any[] } as any), + getTheme, + onError: () => {}, + getSpecInfo: () => info + } as any); + chart.created(transformer); + chart.init(); + return chart; +}; + +const getOutlierStatistic = (series: { getStatisticFields: () => { key: string; operations: string[] }[] }) => + series.getStatisticFields().find(field => field.key === 'y6'); + +const getFoldedOutlierValues = (series: any): number[] => { + const rows = series._outlierData?.getLatestData?.() ?? series._outlierData?.getDataView?.()?.latestData ?? []; + return rows + .map((row: Record) => row[BOX_PLOT_OUTLIER_VALUE_FIELD]) + .filter((value: unknown): value is number => typeof value === 'number'); +}; + +describe('BoxPlotSeries getStatisticFields outliersField', () => { + test('adds array-min/array-max when outliersField is omitted from super fields', () => { + const series = createBoxPlotSeries(); + + expect(getOutlierStatistic(series)).toEqual({ + key: 'y6', + operations: ['array-min', 'array-max'] + }); + }); + + test('replaces min/max with array operations when outliersField is already present', () => { + const series = createBoxPlotSeries(); + (series as any)._fieldY = ['y5', 'y3', 'y2', 'y4', 'y1', 'y6']; + + expect(getOutlierStatistic(series)).toEqual({ + key: 'y6', + operations: ['array-min', 'array-max'] + }); + expect(series.getStatisticFields().filter(field => field.key === 'y6')).toHaveLength(1); + }); + + test('still registers outliersField when axis helpers are not ready', () => { + const series = createBoxPlotSeries(); + (series as any)._xAxisHelper = undefined; + (series as any)._yAxisHelper = undefined; + + expect(getOutlierStatistic(series)).toEqual({ + key: 'y6', + operations: ['array-min', 'array-max'] + }); + }); + + test('does not invent a statistic field when outliersField is unset', () => { + const series = createBoxPlotSeries(); + (series as any)._outliersField = undefined; + + expect(getOutlierStatistic(series)).toBeUndefined(); + }); +}); + +describe('boxPlot outliersField after empty-init updateData', () => { + test('folds y6 array rows after updateData from empty values', () => { + const chart = createBoxPlotChart([]); + const series = chart.getAllSeries()[0] as any; + + expect(getOutlierStatistic(series)).toEqual({ + key: 'y6', + operations: ['array-min', 'array-max'] + }); + expect(getFoldedOutlierValues(series)).toEqual([]); + + chart.updateData('boxPlot', filledValues); + + expect(series.getViewData()?.latestData).toHaveLength(2); + expect(getFoldedOutlierValues(series)).toEqual([12.01, 12.02, 14.03]); + expect(series.getViewDataStatistics()?.latestData?.y6).toMatchObject({ + min: 12.01, + max: 14.03 + }); + }); + + test('non-empty init with outliersField still folds outlier rows', () => { + const chart = createBoxPlotChart(filledValues); + const series = chart.getAllSeries()[0] as any; + + expect(getOutlierStatistic(series)).toEqual({ + key: 'y6', + operations: ['array-min', 'array-max'] + }); + expect(getFoldedOutlierValues(series)).toEqual([12.01, 12.02, 14.03]); + expect(series.getViewDataStatistics()?.latestData?.y6).toMatchObject({ + min: 12.01, + max: 14.03 + }); + }); +}); diff --git a/packages/vchart/src/series/box-plot/box-plot.ts b/packages/vchart/src/series/box-plot/box-plot.ts index 9fd29c7056..1231245794 100644 --- a/packages/vchart/src/series/box-plot/box-plot.ts +++ b/packages/vchart/src/series/box-plot/box-plot.ts @@ -499,9 +499,17 @@ export class BoxPlotSeries ex getStatisticFields() { const fields = super.getStatisticFields(); + if (!this._outliersField) { + return fields; + } const outliersField = fields.find(f => f.key === this._outliersField); if (outliersField) { outliersField.operations = ['array-min', 'array-max']; + } else { + fields.push({ + key: this._outliersField, + operations: ['array-min', 'array-max'] + }); } return fields; } From 5227b8caababa2f6ef1f8d224bceb053ad27a159 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 02:20:51 +0000 Subject: [PATCH 2/2] test: cover boxPlot updateDataSync outlier graphics and empty-init fold Add the issue-shaped VChart render cases and keep the node-runner unit coverage for empty-init updateData plus foldOutlierData. Co-authored-by: David --- .../data/box-plot-transform-options.test.ts | 8 +- .../unit/series/box-plot-outliers.test.ts | 74 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts b/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts index a8db62050b..76f7102f48 100644 --- a/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts +++ b/packages/vchart/__tests__/unit/data/box-plot-transform-options.test.ts @@ -51,11 +51,9 @@ describe('box plot transform options', () => { } ); - expect( - rows - .map(row => row[BOX_PLOT_OUTLIER_VALUE_FIELD]) - .filter(value => typeof value === 'number') - ).toEqual([12.01, 12.02, 14.03]); + expect(rows.map(row => row[BOX_PLOT_OUTLIER_VALUE_FIELD]).filter(value => typeof value === 'number')).toEqual([ + 12.01, 12.02, 14.03 + ]); expect(rows[0]).toMatchObject({ [BOX_PLOT_OUTLIER_VALUE_FIELD]: 12.01, x: 'Sub-Saharan Africa' }); }); }); diff --git a/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts b/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts index 7e9affcd53..8ac3b65c4b 100644 --- a/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts +++ b/packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts @@ -1,11 +1,14 @@ import { DataSet } from '@visactor/vdataset'; import { EventDispatcher } from '../../../src/event/event-dispatcher'; import { GlobalScale } from '../../../src/scale/global-scale'; +import VChart from '../../../src'; import { BoxPlotChart, registerBoxplotChart } from '../../../src/chart/box-plot'; import { BoxPlotSeries } from '../../../src/series/box-plot/box-plot'; import { BOX_PLOT_OUTLIER_VALUE_FIELD } from '../../../src/constant/box-plot'; +import { SeriesMarkNameEnum } from '../../../src/series/interface/type'; import { getTestCompiler } from '../../util/factory/compiler'; import { getTheme, initChartDataSet, seriesOption } from '../../util/context'; +import { createDiv, removeDom } from '../../util/dom'; registerBoxplotChart(); @@ -197,3 +200,74 @@ describe('boxPlot outliersField after empty-init updateData', () => { }); }); }); + +const describeRender = typeof document === 'undefined' ? describe.skip : describe; + +const createIssueSpec = (values?: Record[]) => + ({ + type: 'boxPlot', + width: 500, + height: 400, + data: values + ? [{ id: 'boxPlot', values }] + : [ + { + id: 'boxPlot' + } + ], + xField: 'x', + minField: 'y1', + q1Field: 'y2', + medianField: 'y3', + q3Field: 'y4', + maxField: 'y5', + outliersField: 'y6', + direction: 'vertical', + animation: false + } as any); + +const getOutlierGraphics = (chart: VChart) => { + const series = chart.getChart()?.getAllSeries()[0] as any; + const outlierMark = series?.getMarks()?.find((mark: { name?: string }) => mark.name === SeriesMarkNameEnum.outlier); + return { + series, + graphics: outlierMark?.getGraphics?.() ?? [] + }; +}; + +describeRender('VChart boxPlot outliersField updateDataSync', () => { + let dom: HTMLElement; + let chart: VChart; + + beforeEach(() => { + dom = createDiv(); + dom.style.width = '500px'; + dom.style.height = '400px'; + }); + + afterEach(() => { + chart?.release(); + removeDom(dom); + }); + + test('renders outlier points after updateDataSync from empty data', () => { + chart = new VChart(createIssueSpec(), { dom, animation: false }); + chart.renderSync(); + expect(getFoldedOutlierValues(getOutlierGraphics(chart).series)).toEqual([]); + + chart.updateDataSync('boxPlot', filledValues); + + const { series, graphics } = getOutlierGraphics(chart); + expect(getFoldedOutlierValues(series)).toEqual([12.01, 12.02, 14.03]); + expect(graphics.length).toBeGreaterThanOrEqual(3); + }); + + test('non-empty init with outliersField still renders outlier points', () => { + chart = new VChart(createIssueSpec(filledValues), { dom, animation: false }); + chart.renderSync(); + + const { series, graphics } = getOutlierGraphics(chart); + expect(getFoldedOutlierValues(series)).toEqual([12.01, 12.02, 14.03]); + expect(graphics.length).toBeGreaterThanOrEqual(3); + }); +});