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,11 @@
{
"changes": [
{
"comment": "fix: normalize funnel range mapping\n\n",
"type": "none",
"packageName": "@visactor/vchart"
}
],
"packageName": "@visactor/vchart",
"email": "dingling112@gmail.com"
}
10 changes: 10 additions & 0 deletions docs/assets/option/en/series/funnel.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ Whether the bottom of the funnel chart is sharp. It doesn't work when shape is `
#${prefix} gap(number) = 0
The pixel gap between the funnel layers.

#${prefix} range(Object)
Specify the value mapping range. `range.min` maps to `minSize`, `range.max` maps to `maxSize`, and out-of-range values are clamped to the corresponding minimum or maximum width.
When `range.min` is not configured, it defaults to `0`. When `range.max` is not configured, it defaults to the maximum data value.

##${prefix} min(number)
Minimum value of the mapping range. Defaults to `0`.

##${prefix} max(number)
Maximum value of the mapping range. Defaults to the maximum data value.

#${prefix} maxSize(number|string) = '80%'
The maximum width of the funnel chart, supports pixel values and percentage strings.

Expand Down
10 changes: 10 additions & 0 deletions docs/assets/option/zh/series/funnel.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@
#${prefix} gap(number) = 0
漏斗层之间的像素间隔。

#${prefix} range(Object)
指定数值映射范围。`range.min` 映射到 `minSize`,`range.max` 映射到 `maxSize`,超出范围的值会被截断到对应的最小或最大宽度。
未配置 `range.min` 时默认为 `0`,未配置 `range.max` 时默认为数据最大值。

##${prefix} min(number)
数值映射范围的最小值,默认为 `0`。

##${prefix} max(number)
数值映射范围的最大值,默认为数据最大值。

#${prefix} maxSize(number|string) = '80%'
漏斗图最大宽度,支持配置像素值和百分比字符串。

Expand Down
134 changes: 134 additions & 0 deletions packages/vchart/__tests__/unit/data/funnel-transform-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,138 @@ describe('funnel transform options', () => {
expect(second[0][FUNNEL_CURRENT_VALUE]).toBe(20);
expect(second[0][FUNNEL_VALUE_RATIO]).toBe(1);
});

test('keeps single zero value as the maximum funnel level', () => {
const options = {
valueField: 'value',
isCone: true,
asCurrentValue: FUNNEL_CURRENT_VALUE,
asTransformRatio: FUNNEL_TRANSFORM_RATIO,
asReachRatio: FUNNEL_REACH_RATIO,
asHeightRatio: FUNNEL_HEIGHT_RATIO,
asValueRatio: FUNNEL_VALUE_RATIO,
asNextValueRatio: FUNNEL_NEXT_VALUE_RATIO,
asLastValueRatio: FUNNEL_LAST_VALUE_RATIO,
asLastValue: FUNNEL_LAST_VALUE,
asNextValue: FUNNEL_NEXT_VALUE
};
const data = [{ name: 'Step1', value: 0 }];

const result = funnel(data as unknown as Parameters<typeof funnel>[0], options as unknown as IFunnelOpt);

expect(result[0][FUNNEL_CURRENT_VALUE]).toBe(0);
expect(result[0][FUNNEL_VALUE_RATIO]).toBe(1);
expect(result[0][FUNNEL_NEXT_VALUE_RATIO]).toBe(0);
});

test('maps configured range to value ratio and clamps out-of-range values', () => {
const options = {
valueField: 'value',
isCone: true,
range: { min: 10, max: 20 },
asCurrentValue: FUNNEL_CURRENT_VALUE,
asTransformRatio: FUNNEL_TRANSFORM_RATIO,
asReachRatio: FUNNEL_REACH_RATIO,
asHeightRatio: FUNNEL_HEIGHT_RATIO,
asValueRatio: FUNNEL_VALUE_RATIO,
asNextValueRatio: FUNNEL_NEXT_VALUE_RATIO,
asLastValueRatio: FUNNEL_LAST_VALUE_RATIO,
asLastValue: FUNNEL_LAST_VALUE,
asNextValue: FUNNEL_NEXT_VALUE
};
const data = [
{ name: 'below', value: 5 },
{ name: 'middle', value: 15 },
{ name: 'above', value: 25 }
];

const result = funnel(data as unknown as Parameters<typeof funnel>[0], options as unknown as IFunnelOpt);

expect(result[0][FUNNEL_VALUE_RATIO]).toBe(0);
expect(result[0][FUNNEL_NEXT_VALUE_RATIO]).toBe(0.5);
expect(result[1][FUNNEL_VALUE_RATIO]).toBe(0.5);
expect(result[1][FUNNEL_NEXT_VALUE_RATIO]).toBe(1);
expect(result[1][FUNNEL_LAST_VALUE_RATIO]).toBe(0);
expect(result[2][FUNNEL_VALUE_RATIO]).toBe(1);
expect(result[2][FUNNEL_LAST_VALUE_RATIO]).toBe(0.5);
});

test('uses zero as default range min for backward compatibility', () => {
const options = {
valueField: 'value',
isCone: true,
asCurrentValue: FUNNEL_CURRENT_VALUE,
asTransformRatio: FUNNEL_TRANSFORM_RATIO,
asReachRatio: FUNNEL_REACH_RATIO,
asHeightRatio: FUNNEL_HEIGHT_RATIO,
asValueRatio: FUNNEL_VALUE_RATIO,
asNextValueRatio: FUNNEL_NEXT_VALUE_RATIO,
asLastValueRatio: FUNNEL_LAST_VALUE_RATIO,
asLastValue: FUNNEL_LAST_VALUE,
asNextValue: FUNNEL_NEXT_VALUE
};
const data = [
{ name: 'min', value: 10 },
{ name: 'max', value: 20 }
];

const result = funnel(data as unknown as Parameters<typeof funnel>[0], options as unknown as IFunnelOpt);

expect(result[0][FUNNEL_VALUE_RATIO]).toBe(0.5);
expect(result[1][FUNNEL_VALUE_RATIO]).toBe(1);
});

test('uses zero as default range min when only range max is configured', () => {
const options = {
valueField: 'value',
isCone: true,
range: { max: 40 },
asCurrentValue: FUNNEL_CURRENT_VALUE,
asTransformRatio: FUNNEL_TRANSFORM_RATIO,
asReachRatio: FUNNEL_REACH_RATIO,
asHeightRatio: FUNNEL_HEIGHT_RATIO,
asValueRatio: FUNNEL_VALUE_RATIO,
asNextValueRatio: FUNNEL_NEXT_VALUE_RATIO,
asLastValueRatio: FUNNEL_LAST_VALUE_RATIO,
asLastValue: FUNNEL_LAST_VALUE,
asNextValue: FUNNEL_NEXT_VALUE
};
const data = [
{ name: 'middle', value: 20 },
{ name: 'max', value: 40 }
];

const result = funnel(data as unknown as Parameters<typeof funnel>[0], options as unknown as IFunnelOpt);

expect(result[0][FUNNEL_VALUE_RATIO]).toBe(0.5);
expect(result[1][FUNNEL_VALUE_RATIO]).toBe(1);
});

test('clamps values when range min equals max', () => {
const options = {
valueField: 'value',
isCone: true,
range: { min: 10, max: 10 },
asCurrentValue: FUNNEL_CURRENT_VALUE,
asTransformRatio: FUNNEL_TRANSFORM_RATIO,
asReachRatio: FUNNEL_REACH_RATIO,
asHeightRatio: FUNNEL_HEIGHT_RATIO,
asValueRatio: FUNNEL_VALUE_RATIO,
asNextValueRatio: FUNNEL_NEXT_VALUE_RATIO,
asLastValueRatio: FUNNEL_LAST_VALUE_RATIO,
asLastValue: FUNNEL_LAST_VALUE,
asNextValue: FUNNEL_NEXT_VALUE
};
const data = [
{ name: 'below', value: 5 },
{ name: 'equal', value: 10 },
{ name: 'above', value: 15 }
];

const result = funnel(data as unknown as Parameters<typeof funnel>[0], options as unknown as IFunnelOpt);

expect(result[0][FUNNEL_VALUE_RATIO]).toBe(0);
expect(result[1][FUNNEL_VALUE_RATIO]).toBe(1);
expect(result[2][FUNNEL_VALUE_RATIO]).toBe(1);
});
});
33 changes: 33 additions & 0 deletions packages/vchart/__tests__/unit/series/funnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,37 @@ describe('[Domain-Series-Funnel] Funnel Series', () => {
funnelData0[FUNNEL_TRANSFORM_LEVEL] = true;
expect(get(funnelPolygon.stateStyle.normal, 'points.style')(funnelData0)[0].y).toBe(400);
});

test('single zero value keeps visible funnel width', () => {
const singleZeroDataView = new DataView(dataSet);
singleZeroDataView.parse('name,value\nStep1,0', {
type: 'csv'
});

const funnel = new FunnelSeries<any>(
{
data: singleZeroDataView,
maxSize: 400,
categoryField: 'name',
valueField: 'value'
},
ctx
);
funnel.created();
funnel.init({});
funnel.fillData();
funnel.setLayoutRect({ width: 500, height: 500 });
funnel.getLayoutRect = () => {
return { width: 500, height: 500 };
};

const funnelData0 = funnel.getViewData()?.latestData?.[0];
const points = funnel.getPoints(funnelData0);

expect(funnelData0[FUNNEL_VALUE_RATIO]).toBe(1);
expect(points[0].x).toBe(50);
expect(points[1].x).toBe(450);
expect(points[2].x).toBe(250);
expect(points[3].x).toBe(250);
});
});
31 changes: 23 additions & 8 deletions packages/vchart/src/data/transforms/funnel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DataView } from '@visactor/vdataset';
import { isFunction, isValidNumber } from '@visactor/vutils';
import { clamp, isFunction, isValidNumber } from '@visactor/vutils';

type FunnelOptionValue<T> = T | (() => T);

Expand Down Expand Up @@ -49,16 +49,30 @@ export const funnel = (originData: Array<DataView>, op: IFunnelOpt) => {
asLastValueRatio,
asLastValue,
asCurrentValue,
asNextValue,
asNextValue
} = op;
const valueField = resolveOptionValue(op.valueField);
const heightVisual = resolveOptionValue(op.heightVisual) ?? false;
const isCone = resolveOptionValue(op.isCone) ?? true;
const range = resolveOptionValue(op.range);

const max = data.reduce((m, d) => Math.max(m, Number.parseFloat(d[valueField]) || -Infinity), -Infinity);
const min = data.reduce((m, d) => Math.min(m, Number.parseFloat(d[valueField]) || Infinity), Infinity);
const rangeArr = [range?.min ?? min, range?.max ?? max];
const max = data.reduce((m, d) => {
const value = Number.parseFloat(d[valueField]);
return isValidNumber(value) ? Math.max(m, value) : m;
}, -Infinity);
const rangeMin = range?.min ?? 0;
const rangeMax = range?.max ?? max;
const getValueRatio = (value: number) => {
if (!isValidNumber(value) || !isValidNumber(rangeMin) || !isValidNumber(rangeMax)) {
return 0;
}

if (rangeMin === rangeMax) {
return value < rangeMin ? 0 : 1;
}

return clamp((value - rangeMin) / (rangeMax - rangeMin), 0, 1);
};

data.forEach((d, i) => {
const currentValue: number = Number.parseFloat(d[valueField]);
Expand All @@ -68,16 +82,17 @@ export const funnel = (originData: Array<DataView>, op: IFunnelOpt) => {
const transformRatio =
!isValidNumber(nextValue * currentValue) || currentValue === 0 ? 0 : nextValue / currentValue;
const reachRatio = !isValidNumber(currentValue * lastValue) || lastValue === 0 ? 0 : currentValue / lastValue;
const valueRatio = getValueRatio(currentValue);

asLastValue && (d[asLastValue] = lastValue);
asNextValue && (d[asNextValue] = nextValue);
asTransformRatio && (d[asTransformRatio] = transformRatio);
asReachRatio && (d[asReachRatio] = i === 0 ? 1 : reachRatio);
asHeightRatio && (d[asHeightRatio] = heightVisual === true ? transformRatio : 1 / data.length);
asValueRatio && (d[asValueRatio] = currentValue / rangeArr[1]);
asValueRatio && (d[asValueRatio] = valueRatio);
asNextValueRatio &&
(d[asNextValueRatio] = i === data.length - 1 ? (isCone ? 0 : d[asValueRatio]) : nextValue / rangeArr[1]);
asLastValueRatio && (d[asLastValueRatio] = i === 0 ? 1 : lastValue / rangeArr[1]);
(d[asNextValueRatio] = i === data.length - 1 ? (isCone ? 0 : d[asValueRatio]) : getValueRatio(nextValue));
asLastValueRatio && (d[asLastValueRatio] = i === 0 ? 1 : getValueRatio(lastValue));
asCurrentValue && (d[asCurrentValue] = currentValue);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/vchart/src/series/funnel/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export interface IFunnelSeriesSpec extends ISeriesSpec, IAnimationSpec<FunnelMar
*/
gap?: number;
/**
* 指定数据项的最大值和最小值
* 指定数值映射范围,range.min 映射到 minSize,range.max 映射到 maxSize。
* range.min 默认为 0,range.max 默认为数据最大值。
*/
range?: {
min?: number;
Expand Down
Loading