Skip to content
Merged
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,11 @@
{
"changes": [
{
"comment": "feat: add `outlier` to boxplot, close #4301\n\n",
"type": "none",
"packageName": "@visactor/vchart"
}
],
"packageName": "@visactor/vchart",
"email": "dingling112@gmail.com"
}
8 changes: 7 additions & 1 deletion packages/vchart/src/chart/box-plot/box-plot-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
122 changes: 94 additions & 28 deletions packages/vchart/src/series/box-plot/box-plot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -49,6 +51,7 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> ex
static readonly builtInTheme = { boxPlot };
static readonly mark: SeriesMarkMap = boxPlotSeriesMark;

protected _bandPosition = 0;
protected _minField: string;
getMinField() {
return this._minField;
Expand Down Expand Up @@ -119,7 +122,7 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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;
Expand All @@ -145,9 +148,9 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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({
Expand All @@ -158,22 +161,22 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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;
Expand All @@ -182,8 +185,7 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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
Expand Down Expand Up @@ -247,7 +249,7 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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), {
Expand All @@ -257,7 +259,7 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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), {
Expand Down Expand Up @@ -323,15 +325,79 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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();
//每次布局结束,清除自适应宽度缓存
Expand All @@ -343,9 +409,9 @@ export class BoxPlotSeries<T extends IBoxPlotSeriesSpec = IBoxPlotSeriesSpec> 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;
Expand Down
48 changes: 44 additions & 4 deletions packages/vchart/src/series/box-plot/interface.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -41,20 +48,53 @@ export interface IBoxPlotSeriesSpec
*/
q3Field?: string;
/**
*异常值字段
* 异常值字段
*/
outliersField?: string;
/**
* 图元配置
*/
[SeriesMarkNameEnum.boxPlot]?: IMarkSpec<IBoxPlotMarkSpec>;
/**
* 异常值字段
* 异常值图元配置
* @since 2.0.10
*/
outliersField?: string;
[SeriesMarkNameEnum.outlier]?: IMarkSpec<ISymbolMarkSpec>;
/**
* 异常值样式配置
* @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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/vchart/src/typings/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,11 @@ export interface IBoxPlotMarkSpec extends ICommonSpec {
/**
* box宽度
*/
boxWidth?: number;
boxWidth?: number | string;
/**
* 最大最小值宽度
*/
shaftWidth?: number;
shaftWidth?: number | string;
/**
* 中轴线类型
*/
Expand Down Expand Up @@ -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
*/
Expand Down
Loading