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
11 changes: 11 additions & 0 deletions common/changes/@visactor/vchart/feat-area-enlargement.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@visactor/vchart",
"comment": "feat: support area enlargement (linear axis custom distribution)",
"type": "minor"
}
],
"packageName": "@visactor/vchart",
"email": "lixuef1313@163.com"
}
39 changes: 39 additions & 0 deletions docs/assets/demos/builtin-theme/charts/area-enlargement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { IChartInfo } from './interface';

const spec = {
type: 'line',
data: [
{
id: 'line',
values: [
{ x: '1', y: 1 },
{ x: '2', y: 5 },
{ x: '3', y: 7 },
{ x: '4', y: 8 },
{ x: '5', y: 8.5 },
{ x: '6', y: 9 },
{ x: '7', y: 9.5 },
{ x: '8', y: 10 }
]
}
],
xField: 'x',
yField: 'y',
axes: [
{
orient: 'left',
type: 'linear',
customDistribution: {
domain: [0, 7, 9, 10],
ratio: [0.2, 0.6, 0.2]
}
}
]
};

const areaEnlargement: IChartInfo = {
name: 'Area Enlargement',
spec
};

export default areaEnlargement;
14 changes: 14 additions & 0 deletions docs/assets/option/en/component/axis-common/linear-axis.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,17 @@ Truncation graphic rotation angle configuration.
###${prefix} style(Object)

The style configuration of the truncation graphic, you can configure the line width (`lineWidth`), color (`stroke`), etc.

#${prefix} customDistribution(Array)

Supported since version **2.0.16**

Applies only when the axis is a linear axis. Custom interval distribution configuration, used to define the visual proportion of specific data intervals on the axis.

##${prefix} domain(number[])

The data interval [min, max].

##${prefix} ratio(number)

The proportion of the visual range this interval should occupy, value between 0 and 1.
14 changes: 14 additions & 0 deletions docs/assets/option/zh/component/axis-common/linear-axis.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,17 @@
###${prefix} style(Object)

截断图形的样式配置,可以配置线宽(`lineWidth`)、颜色(`stroke`)等。

#${prefix} customDistribution(Array)

自**2.0.16**版本开始支持

仅当轴为线性轴时生效。自定义区间分布配置,用于定义特定数据区间在轴上的视觉占比。

##${prefix} domain(number[])

数据区间 [min, max]。

##${prefix} ratio(number)

该区间在视觉范围内所占的比例,取值范围 0 到 1。
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { GlobalScale } from '../../../../../src/scale/global-scale';
import { DataSet, csvParser } from '@visactor/vdataset';
import { dimensionStatistics } from '../../../../../src/data/transforms/dimension-statistics';
import type { CartesianLinearAxis } from '../../../../../src/index';
// eslint-disable-next-line no-duplicate-imports
import { CartesianAxis } from '../../../../../src/index';
import { ComponentTypeEnum, type IComponent, type IComponentOption } from '../../../../../src/component/interface';
import { EventDispatcher } from '../../../../../src/event/event-dispatcher';
import { getTestCompiler } from '../../../../util/factory/compiler';
import { getTheme, initChartDataSet } from '../../../../util/context';
import { getCartesianAxisInfo } from '../../../../../src/component/axis/cartesian/util';

const dataSet = new DataSet();
initChartDataSet(dataSet);
dataSet.registerParser('csv', csvParser);
dataSet.registerTransform('dimensionStatistics', dimensionStatistics);

const ctx: IComponentOption = {
type: ComponentTypeEnum.cartesianLinearAxis,
eventDispatcher: new EventDispatcher({} as any, { addEventListener: () => {} } as any) as any,
dataSet,
map: new Map(),
mode: 'desktop-browser',
globalInstance: {
isAnimationEnable: () => true,
getContainer: () => ({}),
getTooltipHandlerByUser: (() => undefined) as () => undefined
} as any,
getCompiler: getTestCompiler,
getAllRegions: () => [],
getRegionsInIndex: () => [],
getChart: () => ({ getSpec: () => ({}) } as any),
getRegionsInIds: () => [],
getRegionsInUserIdOrIndex: () => [],
getAllSeries: () => [],
getSeriesInIndex: () => [],
getSeriesInIds: () => [],
getSeriesInUserIdOrIndex: () => [],
getAllComponents: () => [],
getComponentByIndex: () => undefined,
getComponentsByKey: () => [],
getComponentsByType: () => [],
getChartLayoutRect: () => ({ width: 0, height: 0, x: 0, y: 0 }),
getChartViewRect: () => ({ width: 500, height: 500 } as any),
globalScale: new GlobalScale([], { getAllSeries: () => [] as any[] } as any),
getTheme: getTheme,
getComponentByUserId: () => undefined,
animation: false,
onError: () => {},
getSeriesData: () => undefined
};

const getAxisSpec = (spec: any) => ({
sampling: 'simple',
...spec
});

describe('LinearAxis customDistribution', () => {
beforeAll(() => {
// @ts-ignore
jest.spyOn(CartesianAxis.prototype, 'collectData').mockImplementation(() => {
return [{ min: 0, max: 10 }];
});
});

test('should create piecewise domain and range from customDistribution', () => {
// Mock getNewScaleRange to return [0, 100]
// @ts-ignore
jest.spyOn(CartesianAxis.prototype, 'getNewScaleRange').mockReturnValue([0, 100]);

let spec = getAxisSpec({
orient: 'left',
customDistribution: {
domain: [0, 5, 10],
ratio: [0.8, 0.2]
}
});

const transformer = new CartesianAxis.transformerConstructor({
type: 'cartesianAxis-linear',
getTheme: getTheme,
mode: 'desktop-browser'
});
spec = transformer.transformSpec(spec, {}).spec;
const linearAxis = CartesianAxis.createComponent(
{
type: getCartesianAxisInfo(spec).componentName,
spec
},
ctx
) as CartesianLinearAxis;

linearAxis.created();
linearAxis.init({});

// Test Domain
// @ts-ignore
linearAxis.updateScaleDomain();
const scale = linearAxis.getScale();
expect(scale.domain()).toEqual([0, 5, 10]);

// Test Range
// @ts-ignore
const newRange = linearAxis.getNewScaleRange();
// 0 -> 0
// 5 -> 0 + 0.8 * 100 = 80
// 10 -> 80 + 0.2 * 100 = 100
expect(newRange).toEqual([0, 80, 100]);
});

test('should handle gaps in customDistribution', () => {
let spec = getAxisSpec({
orient: 'left',
customDistribution: {
domain: [0, 5, 8, 10],
ratio: [0.4, 0.4]
}
});

const transformer = new CartesianAxis.transformerConstructor({
type: 'cartesianAxis-linear',
getTheme: getTheme,
mode: 'desktop-browser'
});
spec = transformer.transformSpec(spec, {}).spec;
const linearAxis = CartesianAxis.createComponent(
{
type: getCartesianAxisInfo(spec).componentName,
spec
},
ctx
) as CartesianLinearAxis;

linearAxis.created();
linearAxis.init({});

// @ts-ignore
linearAxis.updateScaleDomain();
const scale = linearAxis.getScale();
expect(scale.domain()).toEqual([0, 5, 8, 10]);
Comment on lines 111 to 140
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

The "should handle gaps in customDistribution" test verifies that the computed domain includes the gap boundary ([0, 5, 8, 10]), but it does not assert anything about the resulting range, so the gap allocation logic in getNewScaleRange (which is relatively complex) is effectively untested here. To strengthen coverage of the new behavior, consider extending this test (or adding a new one) to stub the base getNewScaleRange and assert the expected piecewise range values for a configuration with a gap, similar to how the first test asserts the range for the no-gap case.

Copilot uses AI. Check for mistakes.
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ export type ICartesianBandAxisSpec = ICartesianAxisCommonSpec &
* @since 1.4.0
*/
autoRegionSize?: boolean;

/**
* 自定义区间分布配置
* @since 2.0.16
*/
customDistribution?: {
domain: number[];
ratio: number[];
};
};

export type ICartesianTimeAxisSpec = Omit<ICartesianAxisCommonSpec, 'inverse'> & {
Expand Down
31 changes: 31 additions & 0 deletions packages/vchart/src/component/axis/cartesian/linear-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,37 @@ export class CartesianLinearAxis<
newRange = combineDomains(this._break.scope).map(val => newRange[0] + (last(newRange) - newRange[0]) * val);
}

if ((this._spec as any).customDistribution?.domain?.length && this._scale) {
const customDistribution = (this._spec as any).customDistribution as {
domain: number[];
ratio: number[];
};
const domain = this._scale.domain();
if (domain.length > 2) {
const start = newRange[0];
const end = last(newRange);
const totalRange = end - start;
const resultRange = [start];
let currentPos = start;

const segmentWeights: number[] = customDistribution.ratio;
const totalWeight = segmentWeights.reduce((acc, cur) => acc + cur, 0);

if (totalWeight > 0) {
for (let i = 0; i < segmentWeights.length; i++) {
const weight = segmentWeights[i];
const segmentLen = totalRange * (weight / totalWeight);
currentPos += segmentLen;
resultRange.push(currentPos);
}
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

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

In the gap-handling path for customDistribution, when the configured intervals exactly cover the domain but the sum of their ratio values is less than 1, totalGapDomain stays 0, so all segments get only their configured share and currentPos ends before end; the subsequent correction that forces the last point to end implicitly inflates only the final segment. This makes the effective visual ratio of the last interval larger than its configured ratio, which can be surprising. Consider either normalizing ratios to sum to 1 when there are no gaps, or distributing the leftover proportion across all segments instead of only the last one, so that the effective behavior matches user expectations more closely.

Suggested change
}
}
// If there are no gaps but the defined ratios sum to less than 1,
// proportionally stretch all segments so that the final position reaches `end`.
if (totalGapDomain === 0 && totalDefinedRatio > 0 && totalDefinedRatio < 1) {
const occupiedRange = currentPos - start;
if (occupiedRange !== 0) {
const scale = totalRange / occupiedRange;
for (let i = 1; i < resultRange.length; i++) {
resultRange[i] = start + (resultRange[i] - start) * scale;
}
}
}

Copilot uses AI. Check for mistakes.

// Ensure last point is exactly end to avoid float errors
resultRange[resultRange.length - 1] = end;
newRange = resultRange;
}
}

return newRange;
}

Expand Down
8 changes: 8 additions & 0 deletions packages/vchart/src/component/axis/interface/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ export interface ILinearAxisSpec {
* @since 1.12.4
*/
breaks?: ILinearAxisBreakSpec[];
/**
* 自定义区间分布配置
* @since 2.0.16
*/
customDistribution?: {
domain: number[];
ratio: number[];
};
}

export interface IBandAxisSpec {
Expand Down
Loading
Loading