diff --git a/common/changes/@visactor/vchart/feat-boxplot-enhancement_2025-11-25-09-21.json b/common/changes/@visactor/vchart/feat-boxplot-enhancement_2025-11-25-09-21.json new file mode 100644 index 0000000000..42fca97ec8 --- /dev/null +++ b/common/changes/@visactor/vchart/feat-boxplot-enhancement_2025-11-25-09-21.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: add `outlier` to boxplot, close #4301\n\n", + "type": "none", + "packageName": "@visactor/vchart" + } + ], + "packageName": "@visactor/vchart", + "email": "dingling112@gmail.com" +} \ No newline at end of file diff --git a/packages/vchart/src/chart/box-plot/box-plot-transformer.ts b/packages/vchart/src/chart/box-plot/box-plot-transformer.ts index 1e70e5a5bf..3f1aefd891 100644 --- a/packages/vchart/src/chart/box-plot/box-plot-transformer.ts +++ b/packages/vchart/src/chart/box-plot/box-plot-transformer.ts @@ -10,13 +10,19 @@ export class BoxPlotChartSpecTransformer< const dataFields = [spec.maxField, spec.medianField, spec.q1Field, spec.q3Field, spec.minField, spec.outliersField]; const seriesSpec = super._getDefaultSeriesSpec(spec, [ 'boxPlot', + 'outlier', 'minField', 'maxField', 'q1Field', 'medianField', 'q3Field', 'outliersField', - 'outliersStyle' + 'outliersStyle', + + 'boxWidth', + 'boxMaxWidth', + 'boxMinWidth', + 'boxGapInGroup' ]); seriesSpec.direction = spec.direction ?? Direction.vertical; seriesSpec[seriesSpec.direction === Direction.horizontal ? 'xField' : 'yField'] = dataFields; diff --git a/packages/vchart/src/series/box-plot/box-plot.ts b/packages/vchart/src/series/box-plot/box-plot.ts index 97f1e2b7c3..1555360b6c 100644 --- a/packages/vchart/src/series/box-plot/box-plot.ts +++ b/packages/vchart/src/series/box-plot/box-plot.ts @@ -3,7 +3,7 @@ import { AttributeLevel } from '../../constant/attribute'; import { DEFAULT_DATA_INDEX } from '../../constant/data'; import { PREFIX } from '../../constant/base'; import type { IModelEvaluateOption, IModelInitOption } from '../../model/interface'; -import type { BoxPlotShaftShape, IOutlierMarkSpec, Datum } from '../../typings'; +import type { BoxPlotShaftShape, IOutlierMarkSpec, Datum, DirectionType } from '../../typings'; import { Direction } from '../../typings/space'; import { valueInScaleRange } from '../../util/scale'; import { CartesianSeries } from '../cartesian/cartesian'; @@ -26,13 +26,15 @@ import { registerSymbolMark } from '../../mark/symbol'; import { boxPlotSeriesMark } from './constant'; import { Factory } from '../../core/factory'; import type { IBoxPlotMark, IGlyphMark, IMark, ISymbolMark } from '../../mark/interface'; -import { merge, isNumber } from '@visactor/vutils'; +import { merge, isNumber, isValid, isNil, array, last } from '@visactor/vutils'; import { getGroupAnimationParams } from '../util/utils'; import { registerCartesianLinearAxis, registerCartesianBandAxis } from '../../component/axis/cartesian'; import type { ICompilableData } from '../../compile/data'; import { CompilableData } from '../../compile/data'; import { registeBoxPlotScaleAnimation } from './animation'; import { boxPlot } from '../../theme/builtin/common/series/box-plot'; +import { getActualNumValue } from '../../util/space'; +import { isContinuous } from '@visactor/vscale'; const DEFAULT_STROKE_WIDTH = 2; const DEFAULT_SHAFT_FILL_OPACITY = 0.5; @@ -49,6 +51,7 @@ export class BoxPlotSeries ex static readonly builtInTheme = { boxPlot }; static readonly mark: SeriesMarkMap = boxPlotSeriesMark; + protected _bandPosition = 0; protected _minField: string; getMinField() { return this._minField; @@ -119,7 +122,7 @@ export class BoxPlotSeries ex this._shaftFillOpacity = this._shaftShape === 'bar' ? boxPlotStyle.shaftFillOpacity ?? DEFAULT_SHAFT_FILL_OPACITY : undefined; - this._outliersStyle = this._spec.outliersStyle; + this._outliersStyle = (this._spec.outliersStyle ?? this._spec.outlier?.style) as IOutlierMarkSpec; } private _boxPlotMark?: IBoxPlotMark; @@ -145,9 +148,9 @@ export class BoxPlotSeries ex if (boxPlotMark) { const commonBoxplotStyles = { lineWidth: this._lineWidth, - fill: this._boxFillColor ?? (this._shaftShape === 'line' ? DEFAULT_FILL_COLOR : this.getColorAttribute()), + fill: this._boxFillColor ?? (this._shaftShape !== 'line' ? this.getColorAttribute() : DEFAULT_FILL_COLOR), minMaxFillOpacity: this._shaftFillOpacity, - stroke: this._strokeColor ?? (this._shaftShape === 'line' ? this.getColorAttribute() : DEFAULT_STROKE_COLOR) + stroke: this._strokeColor ?? (this._shaftShape !== 'line' ? DEFAULT_STROKE_COLOR : this.getColorAttribute()) }; (boxPlotMark as IGlyphMark).setGlyphConfig({ @@ -158,22 +161,22 @@ export class BoxPlotSeries ex const boxPlotMarkStyles = this._direction === Direction.horizontal ? { - y: this.dataToPositionY.bind(this), - ...commonBoxplotStyles, - boxHeight: () => this._boxWidth ?? this._getMarkWidth(), - ruleHeight: () => this._shaftWidth ?? this._getMarkWidth(), - q1q3Height: () => this._boxWidth ?? this._getMarkWidth(), - minMaxHeight: () => this._shaftWidth ?? this._getMarkWidth() + y: (datum: Datum) => this._getPosition(this.direction, datum), + boxHeight: () => getActualNumValue(this._boxWidth ?? '100%', this._getMarkWidth()), + ruleHeight: () => getActualNumValue(this._shaftWidth ?? '100%', this._getMarkWidth()), + q1q3Height: () => getActualNumValue(this._boxWidth ?? '100%', this._getMarkWidth()), + minMaxHeight: () => getActualNumValue(this._shaftWidth ?? '100%', this._getMarkWidth()) } : { - x: this.dataToPositionX.bind(this), - ...commonBoxplotStyles, - boxWidth: () => this._boxWidth ?? this._getMarkWidth(), - ruleWidth: () => this._shaftWidth ?? this._getMarkWidth(), - q1q3Width: () => this._boxWidth ?? this._getMarkWidth(), - minMaxWidth: () => this._shaftWidth ?? this._getMarkWidth() + x: (datum: Datum) => this._getPosition(this.direction, datum), + boxWidth: () => getActualNumValue(this._boxWidth ?? '100%', this._getMarkWidth()), + ruleWidth: () => getActualNumValue(this._shaftWidth ?? '100%', this._getMarkWidth()), + q1q3Width: () => getActualNumValue(this._boxWidth ?? '100%', this._getMarkWidth()), + minMaxWidth: () => getActualNumValue(this._shaftWidth ?? '100%', this._getMarkWidth()) }; - this.setMarkStyle(boxPlotMark, boxPlotMarkStyles, STATE_VALUE_ENUM.STATE_NORMAL, AttributeLevel.Series); + + this.setMarkStyle(boxPlotMark, commonBoxplotStyles, STATE_VALUE_ENUM.STATE_NORMAL, AttributeLevel.Series); + this.setMarkStyle(boxPlotMark, boxPlotMarkStyles, STATE_VALUE_ENUM.STATE_NORMAL, AttributeLevel.Built_In); } const outlierMark = this._outlierMark; @@ -182,8 +185,7 @@ export class BoxPlotSeries ex outlierMark, { fill: this._outliersStyle?.fill ?? this.getColorAttribute(), - size: isNumber(this._outliersStyle?.size) ? this._outliersStyle.size : DEFAULT_OUTLIER_SIZE, - symbolType: 'circle' + size: isNumber(this._outliersStyle?.size) ? this._outliersStyle.size : DEFAULT_OUTLIER_SIZE }, STATE_VALUE_ENUM.STATE_NORMAL, AttributeLevel.Series @@ -247,7 +249,7 @@ export class BoxPlotSeries ex const outlierMarkPositionChannel = this._direction === Direction.horizontal ? { - y: this.dataToPositionY.bind(this), + y: (datum: Datum) => this._getPosition(this.direction, datum), x: (datum: Datum) => valueInScaleRange( dataToPosition(this.getDatumPositionValues(datum, BOX_PLOT_OUTLIER_VALUE_FIELD), { @@ -257,7 +259,7 @@ export class BoxPlotSeries ex ) } : { - x: this.dataToPositionX.bind(this), + x: (datum: Datum) => this._getPosition(this.direction, datum), y: (datum: Datum) => valueInScaleRange( dataToPosition(this.getDatumPositionValues(datum, BOX_PLOT_OUTLIER_VALUE_FIELD), { @@ -323,15 +325,79 @@ export class BoxPlotSeries ex } //获取自适应的图元宽度 const bandAxisHelper = this._direction === Direction.horizontal ? this._yAxisHelper : this._xAxisHelper; - const xField = this._direction === Direction.horizontal ? this._fieldY : this._fieldX; + const depthFromSpec = this._groups ? this._groups.fields.length : 1; + const bandWidth = bandAxisHelper.getBandwidth?.(depthFromSpec - 1); - const innerBandWidth = bandAxisHelper.getBandwidth(xField.length - 1); - const autoBoxWidth = innerBandWidth / xField.length; - this._autoBoxWidth = autoBoxWidth; + let width = bandWidth; + if (isValid(this._spec.boxWidth)) { + width = getActualNumValue(this._spec.boxWidth, bandWidth); + } + if (isValid(this._spec.boxMinWidth)) { + width = Math.max(width, getActualNumValue(this._spec.boxMinWidth, bandWidth)); + } + if (isValid(this._spec.boxMaxWidth)) { + width = Math.min(width, getActualNumValue(this._spec.boxMaxWidth, bandWidth)); + } + + this._autoBoxWidth = width; return this._autoBoxWidth; } + protected _getPosition(direction: DirectionType, datum: Datum) { + let axisHelper; + let sizeAttribute; + let dataToPosition; + if (direction === Direction.horizontal) { + axisHelper = this.getYAxisHelper(); + sizeAttribute = 'boxHeight'; + dataToPosition = this.dataToPositionY.bind(this); + } else { + axisHelper = this.getXAxisHelper(); + sizeAttribute = 'boxWidth'; + dataToPosition = this.dataToPositionX.bind(this); + } + const scale = axisHelper.getScale(0); + + const depthFromSpec = this._groups ? this._groups.fields.length : 1; + const depth = depthFromSpec; + + const bandWidth = axisHelper.getBandwidth?.(depth - 1); + const size = this._boxPlotMark.getAttribute(sizeAttribute, datum) as number; + + if (depth > 1 && isValid(this._spec.boxGapInGroup)) { + // 自里向外计算,沿着第一层分组的中心点进行位置调整 + const groupFields = this._groups.fields; + const boxGapInGroup = array(this._spec.boxGapInGroup); + let totalWidth: number = 0; + let offSet: number = 0; + + for (let index = groupFields.length - 1; index >= 1; index--) { + const groupField = groupFields[index]; + // const groupValues = this.getViewDataStatistics()?.latestData?.[groupField]?.values ?? []; + const groupValues = axisHelper.getScale(index)?.domain() ?? []; + const groupCount = groupValues.length; + const gap = getActualNumValue(boxGapInGroup[index - 1] ?? last(boxGapInGroup), bandWidth); + const i = groupValues.indexOf(datum[groupField]); + if (index === groupFields.length - 1) { + totalWidth += groupCount * size + (groupCount - 1) * gap; + offSet += i * (size + gap); + } else { + offSet += i * (totalWidth + gap); + totalWidth += totalWidth + (groupCount - 1) * gap; + } + } + + const center = scale.scale(datum[groupFields[0]]) + axisHelper.getBandwidth(0) / 2; + return center - totalWidth / 2 + offSet + size / 2; + } + + const continuous = isContinuous(scale.type || 'band'); + const pos = dataToPosition(datum); + + return pos + bandWidth * 0.5 + (continuous ? -bandWidth / 2 : 0); + } + onLayoutEnd() { super.onLayoutEnd(); //每次布局结束,清除自适应宽度缓存 @@ -343,9 +409,9 @@ export class BoxPlotSeries ex const newConfig = merge({}, config); ['appear', 'enter', 'update', 'exit', 'disappear'].forEach(state => { if (newConfig[state] && newConfig[state].type === 'scaleIn') { - newConfig[state].type = this._shaftShape === 'line' ? 'boxplotScaleIn' : 'barBoxplotScaleIn'; + newConfig[state].type = this._shaftShape === 'bar' ? 'barBoxplotScaleIn' : 'boxplotScaleIn'; } else if (newConfig[state] && newConfig[state].type === 'scaleOut') { - newConfig[state].type = this._shaftShape === 'line' ? 'boxplotScaleOut' : 'barBoxplotScaleOut'; + newConfig[state].type = this._shaftShape === 'bar' ? 'barBoxplotScaleOut' : 'boxplotScaleOut'; } }); return newConfig; diff --git a/packages/vchart/src/series/box-plot/interface.ts b/packages/vchart/src/series/box-plot/interface.ts index d0ffdf996b..0139f331d8 100644 --- a/packages/vchart/src/series/box-plot/interface.ts +++ b/packages/vchart/src/series/box-plot/interface.ts @@ -1,6 +1,13 @@ import type { IAnimationSpec } from '../../animation/spec'; import type { IMarkProgressiveConfig } from '../../mark/interface'; -import type { DirectionType, IBoxPlotMarkSpec, IOutlierMarkSpec, IMarkSpec, IMarkTheme } from '../../typings'; +import type { + DirectionType, + IBoxPlotMarkSpec, + IOutlierMarkSpec, + IMarkSpec, + IMarkTheme, + ISymbolMarkSpec +} from '../../typings'; import type { ICartesianSeriesSpec, ICartesianSeriesTheme } from '../cartesian/interface'; import type { SeriesMarkNameEnum } from '../interface/type'; @@ -41,20 +48,53 @@ export interface IBoxPlotSeriesSpec */ q3Field?: string; /** - *异常值字段 + * 异常值字段 */ + outliersField?: string; /** * 图元配置 */ [SeriesMarkNameEnum.boxPlot]?: IMarkSpec; /** - * 异常值字段 + * 异常值图元配置 + * @since 2.0.10 */ - outliersField?: string; + [SeriesMarkNameEnum.outlier]?: IMarkSpec; /** * 异常值样式配置 + * @todo 将在未来版本中废弃,请使用 outlier 配置项代替 */ outliersStyle?: IOutlierMarkSpec; + + /** + * 宽度,可以设置绝对的像素值,也可以使用百分比(如 '10%') + * 1. number 类型,表示像素值 + * 2. string 类型,百分比用法,如 '10%',该值为对应最后一个分组字段对应的 scale 的 bandWidth 占比 + * @since 2.0.10 + */ + boxWidth?: number | string; + /** + * 最小宽度,可以设置绝对的像素值,也可以使用百分比(如 '10%') + * 1. number 类型,表示像素值 + * 2. string 类型,百分比用法,如 '10%',该值为对应最后一个分组字段对应的 scale 的 bandWidth 占比 + * @since 2.0.10 + */ + boxMinWidth?: number | string; + /** + * 最大宽度,可以设置绝对的像素值,也可以使用百分比(如 '10%') + * 1. number 类型,表示像素值 + * 2. string 类型,百分比用法,如 '10%',该值为对应最后一个分组字段对应的 scale 的 bandWidth 占比 + * @since 2.0.10 + */ + boxMaxWidth?: number | string; + + /** + * 分组箱线图中各个分组内的间距,可以设置绝对的像素值,也可以使用百分比(如 '10%')。 + * 1. number 类型,表示像素值 + * 2. string 类型,百分比用法,如 '10%',该值为对应最后一个分组字段对应的 scale 的 bandWidth 占比 + * @since 2.0.10 + */ + boxGapInGroup?: number | string | (number | string)[]; } export interface IBoxPlotSeriesTheme extends ICartesianSeriesTheme { diff --git a/packages/vchart/src/typings/visual.ts b/packages/vchart/src/typings/visual.ts index 6eb4ec5619..8fc79a58fa 100644 --- a/packages/vchart/src/typings/visual.ts +++ b/packages/vchart/src/typings/visual.ts @@ -544,11 +544,11 @@ export interface IBoxPlotMarkSpec extends ICommonSpec { /** * box宽度 */ - boxWidth?: number; + boxWidth?: number | string; /** * 最大最小值宽度 */ - shaftWidth?: number; + shaftWidth?: number | string; /** * 中轴线类型 */ @@ -912,7 +912,7 @@ export type IGradient = IGradientLinear | IGradientRadial | IGradientConical; export type LineStrokeCap = 'butt' | 'round' | 'square'; export type LineStrokeJoin = 'arcs' | 'bevel' | 'miter' | 'miter-clip' | 'round'; -export type BoxPlotShaftShape = 'line' | 'bar'; +export type BoxPlotShaftShape = 'line' | 'bar' | 'filled-line'; /** * threshold */