diff --git a/common/changes/@visactor/vchart/fix-funnel-range_2026-07-02-06-46.json b/common/changes/@visactor/vchart/fix-funnel-range_2026-07-02-06-46.json new file mode 100644 index 0000000000..db9fe10e45 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-funnel-range_2026-07-02-06-46.json @@ -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" +} \ No newline at end of file diff --git a/docs/assets/option/en/series/funnel.md b/docs/assets/option/en/series/funnel.md index 2a0c9a53e9..377de9612d 100644 --- a/docs/assets/option/en/series/funnel.md +++ b/docs/assets/option/en/series/funnel.md @@ -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. diff --git a/docs/assets/option/zh/series/funnel.md b/docs/assets/option/zh/series/funnel.md index c6347491b4..4b24c30d99 100644 --- a/docs/assets/option/zh/series/funnel.md +++ b/docs/assets/option/zh/series/funnel.md @@ -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%' 漏斗图最大宽度,支持配置像素值和百分比字符串。 diff --git a/packages/vchart/__tests__/unit/data/funnel-transform-options.test.ts b/packages/vchart/__tests__/unit/data/funnel-transform-options.test.ts index 2804433452..1c6a78d294 100644 --- a/packages/vchart/__tests__/unit/data/funnel-transform-options.test.ts +++ b/packages/vchart/__tests__/unit/data/funnel-transform-options.test.ts @@ -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[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[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[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[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[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); + }); }); diff --git a/packages/vchart/__tests__/unit/series/funnel.test.ts b/packages/vchart/__tests__/unit/series/funnel.test.ts index b79af2ac7b..622bb2d564 100644 --- a/packages/vchart/__tests__/unit/series/funnel.test.ts +++ b/packages/vchart/__tests__/unit/series/funnel.test.ts @@ -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( + { + 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); + }); }); diff --git a/packages/vchart/src/data/transforms/funnel.ts b/packages/vchart/src/data/transforms/funnel.ts index 3cd62ca83c..3587383b6f 100644 --- a/packages/vchart/src/data/transforms/funnel.ts +++ b/packages/vchart/src/data/transforms/funnel.ts @@ -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); @@ -49,16 +49,30 @@ export const funnel = (originData: Array, 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]); @@ -68,16 +82,17 @@ export const funnel = (originData: Array, 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); }); diff --git a/packages/vchart/src/series/funnel/interface.ts b/packages/vchart/src/series/funnel/interface.ts index 02300078c0..d059b3ac52 100644 --- a/packages/vchart/src/series/funnel/interface.ts +++ b/packages/vchart/src/series/funnel/interface.ts @@ -60,7 +60,8 @@ export interface IFunnelSeriesSpec extends ISeriesSpec, IAnimationSpec