Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,26 @@ 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<typeof foldOutlierData>[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' });
});
});
273 changes: 273 additions & 0 deletions packages/vchart/__tests__/unit/series/box-plot-outliers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
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();

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<any>(
{
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'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 请用有效 spec 复现字段缺失,避免手动构造内部状态作为修复依据

这里手动将 _fieldY 设为不含 y6,但真实 BoxPlotChartSpecTransformer._getDefaultSeriesSpec() 会把 outliersField 放入对应的数据轴字段,且这一过程不依赖初始 data 是否为空;CartesianSeries.getStatisticFields() 也根据配置字段和轴 helper 生成统计项,而不是根据初始数据的键生成。因此这个用例绕开了 #4270 的实际初始化路径。撤回本 PR 的生产代码改动后,原 issue 的浏览器复现以及纵向、横向、清空再填充的真实 VChart 用例仍然通过,只有这里和手动清空 axis helpers 的用例失败。请改为通过有效 spec / 公共 API 触发、基线失败而 PR 通过的回归测试,再据此决定是否需要新增统计字段兜底。

(series as any)._xAxisHelper = {
getScale: () => ({ type: 'band' })
};
(series as any)._yAxisHelper = {
getScale: () => ({ type: 'linear' })
};
return series;
};

const createBoxPlotChart = (values?: Record<string, unknown>[]) => {
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<string, unknown>) => 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
});
});
});

const describeRender = typeof document === 'undefined' ? describe.skip : describe;

const createIssueSpec = (values?: Record<string, unknown>[]) =>
({
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);
});
});
8 changes: 8 additions & 0 deletions packages/vchart/src/series/box-plot/box-plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,9 +499,17 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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;
}
Expand Down
Loading