From 231fe751fc4610cc89ea6796a10033c295378cce Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Mon, 2 Feb 2026 15:53:06 +0800 Subject: [PATCH 1/5] feat: support area enlargement (linear axis custom distribution) --- .../vchart/feat-area-enlargement.json | 11 ++ .../builtin-theme/charts/area-enlargement.ts | 40 +++++ .../axis/linear-axis-distribution.test.ts | 143 ++++++++++++++++++ .../component/axis/cartesian/linear-axis.ts | 64 ++++++++ .../src/component/axis/interface/spec.ts | 8 +- .../component/axis/mixin/linear-axis-mixin.ts | 20 ++- packages/vchart/src/typings/scale.ts | 5 + .../checklists/requirements.md | 34 +++++ specs/003-area-enlargement/data-model.md | 48 ++++++ specs/003-area-enlargement/plan.md | 68 +++++++++ specs/003-area-enlargement/quickstart.md | 29 ++++ specs/003-area-enlargement/research.md | 52 +++++++ specs/003-area-enlargement/spec.md | 69 +++++++++ specs/003-area-enlargement/tasks.md | 35 +++++ 14 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 common/changes/@visactor/vchart/feat-area-enlargement.json create mode 100644 docs/assets/demos/builtin-theme/charts/area-enlargement.ts create mode 100644 packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts create mode 100644 specs/003-area-enlargement/checklists/requirements.md create mode 100644 specs/003-area-enlargement/data-model.md create mode 100644 specs/003-area-enlargement/plan.md create mode 100644 specs/003-area-enlargement/quickstart.md create mode 100644 specs/003-area-enlargement/research.md create mode 100644 specs/003-area-enlargement/spec.md create mode 100644 specs/003-area-enlargement/tasks.md diff --git a/common/changes/@visactor/vchart/feat-area-enlargement.json b/common/changes/@visactor/vchart/feat-area-enlargement.json new file mode 100644 index 0000000000..8451b13856 --- /dev/null +++ b/common/changes/@visactor/vchart/feat-area-enlargement.json @@ -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" +} \ No newline at end of file diff --git a/docs/assets/demos/builtin-theme/charts/area-enlargement.ts b/docs/assets/demos/builtin-theme/charts/area-enlargement.ts new file mode 100644 index 0000000000..6f76b4857e --- /dev/null +++ b/docs/assets/demos/builtin-theme/charts/area-enlargement.ts @@ -0,0 +1,40 @@ +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], ratio: 0.2 }, + { domain: [7, 9], ratio: 0.6 }, + { domain: [9, 10], ratio: 0.2 } + ] + } + ] +}; + +const areaEnlargement: IChartInfo = { + name: 'Area Enlargement', + spec +}; + +export default areaEnlargement; diff --git a/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts b/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts new file mode 100644 index 0000000000..c985ffa60a --- /dev/null +++ b/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts @@ -0,0 +1,143 @@ +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], ratio: 0.8 }, + { domain: [5, 10], ratio: 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], ratio: 0.4 }, + // Gap 5-8 + { domain: [8, 10], ratio: 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]); + }); +}); diff --git a/packages/vchart/src/component/axis/cartesian/linear-axis.ts b/packages/vchart/src/component/axis/cartesian/linear-axis.ts index 4400d7bbef..14b1495ba3 100644 --- a/packages/vchart/src/component/axis/cartesian/linear-axis.ts +++ b/packages/vchart/src/component/axis/cartesian/linear-axis.ts @@ -131,6 +131,70 @@ export class CartesianLinearAxis< newRange = combineDomains(this._break.scope).map(val => newRange[0] + (last(newRange) - newRange[0]) * val); } + if ((this._spec as any).customDistribution?.length && this._scale) { + const customDistribution = (this._spec as any).customDistribution as { + domain: [number, 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; + + // Calculate total defined ratio and identify gaps + let totalDefinedRatio = 0; + customDistribution.forEach(item => (totalDefinedRatio += item.ratio)); + const remainingRatio = 1 - totalDefinedRatio; + + // Calculate total domain length of gaps (segments not covered by customDistribution) + let totalGapDomain = 0; + for (let i = 0; i < domain.length - 1; i++) { + const dStart = domain[i]; + const dEnd = domain[i + 1]; + const mid = (dStart + dEnd) / 2; + const covered = customDistribution.some(item => mid >= item.domain[0] && mid <= item.domain[1]); + if (!covered) { + totalGapDomain += Math.abs(dEnd - dStart); + } + } + + for (let i = 0; i < domain.length - 1; i++) { + const dStart = domain[i]; + const dEnd = domain[i + 1]; + const dSpan = dEnd - dStart; // Can be negative if domain is reversed? Usually domain is sorted. + // LinearAxisMixin.computeLinearDomain sorts it. + + // Find matching config + // We check intersection or containment. + // Since domain points are derived from config endpoints, dStart/dEnd should align with config or be sub-segments. + const mid = (dStart + dEnd) / 2; + const config = customDistribution.find(item => mid >= item.domain[0] && mid <= item.domain[1]); + + let segmentRatio = 0; + if (config) { + const configSpan = config.domain[1] - config.domain[0]; + if (configSpan !== 0) { + segmentRatio = config.ratio * (Math.abs(dSpan) / Math.abs(configSpan)); + } + } else { + // Gap + if (totalGapDomain > 0) { + segmentRatio = remainingRatio * (Math.abs(dSpan) / totalGapDomain); + } + } + + currentPos += segmentRatio * totalRange; + resultRange.push(currentPos); + } + // Ensure last point is exactly end to avoid float errors + resultRange[resultRange.length - 1] = end; + newRange = resultRange; + } + } + return newRange; } diff --git a/packages/vchart/src/component/axis/interface/spec.ts b/packages/vchart/src/component/axis/interface/spec.ts index db26c9ad06..cce3983613 100644 --- a/packages/vchart/src/component/axis/interface/spec.ts +++ b/packages/vchart/src/component/axis/interface/spec.ts @@ -9,7 +9,8 @@ import type { IRuleMarkSpec, ISymbolMarkSpec, ITextMarkSpec, - StringOrNumber + StringOrNumber, + IIntervalRatio } from '../../../typings'; import type { IComponentSpec } from '../../base/interface'; import type { AxisType, IAxisItem, IBandAxisLayer, ITickCalculationCfg, StyleCallback } from './common'; @@ -164,6 +165,11 @@ export interface ILinearAxisSpec { * @since 1.12.4 */ breaks?: ILinearAxisBreakSpec[]; + /** + * 自定义区间分布配置 + * @since 2.0.16 + */ + customDistribution?: IIntervalRatio[]; } export interface IBandAxisSpec { diff --git a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts index a73a417db3..bcc97f39f4 100644 --- a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts +++ b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts @@ -154,6 +154,18 @@ export class LinearAxisMixin { computeLinearDomain(data: { min: number; max: number; values: any[] }[]): number[] { let domain: number[] = []; + // handle customDistribution + if ((this._spec as any).customDistribution?.length) { + const customDistribution = (this._spec as any).customDistribution; + const domainSet = new Set(); + customDistribution.forEach((item: any) => { + domainSet.add(item.domain[0]); + domainSet.add(item.domain[1]); + }); + domain = Array.from(domainSet).sort((a, b) => a - b); + return domain; + } + if (data.length) { const userSetBreaks = this._spec.breaks && this._spec.breaks.length; let values: any[] = []; @@ -241,9 +253,15 @@ export class LinearAxisMixin { protected niceDomain(domain: number[]) { const { min: userMin, max: userMax } = getLinearAxisSpecDomain(this._spec); - if (isValid(userMin) || isValid(userMax) || this._spec.type !== 'linear') { + if ( + isValid(userMin) || + isValid(userMax) || + this._spec.type !== 'linear' || + (this._spec as any).customDistribution + ) { // 如果用户设置了 min 或者 max 则按照用户设置的为准 // 如果是非 linear 类型也不处理 + // 如果有 customDistribution 也不处理 return domain; } if (Math.abs(minInArr(domain) - maxInArr(domain)) <= 1e-12) { diff --git a/packages/vchart/src/typings/scale.ts b/packages/vchart/src/typings/scale.ts index 8d49de21d9..98cd3a6098 100644 --- a/packages/vchart/src/typings/scale.ts +++ b/packages/vchart/src/typings/scale.ts @@ -45,6 +45,11 @@ export interface ILinearScaleSpec extends INumericScaleSpec { type: 'linear'; } +export interface IIntervalRatio { + domain: [number, number]; + ratio: number; +} + export interface IPointScaleSpec extends IBaseBandScaleSpec { type: 'point'; } diff --git a/specs/003-area-enlargement/checklists/requirements.md b/specs/003-area-enlargement/checklists/requirements.md new file mode 100644 index 0000000000..9703993b81 --- /dev/null +++ b/specs/003-area-enlargement/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Area Enlargement Line Chart + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec looks good and ready for planning. diff --git a/specs/003-area-enlargement/data-model.md b/specs/003-area-enlargement/data-model.md new file mode 100644 index 0000000000..b1c90a92cb --- /dev/null +++ b/specs/003-area-enlargement/data-model.md @@ -0,0 +1,48 @@ +# Data Model: Area Enlargement (Linear Scale) + +## Scale Specification + +### ILinearScaleSpec + +Extended to support non-uniform interval distribution via `customDistribution`. + +```typescript +import type { ILinearScaleSpec } from './scale'; + +export interface ILinearScaleSpec { + type: 'linear'; + + /** + * Custom interval distribution. + * Defines how domain intervals map to range proportions. + */ + customDistribution?: IIntervalRatio[]; +} + +export interface IIntervalRatio { + /** + * The sub-domain interval [min, max]. + */ + domain: [number, number]; + + /** + * The proportion of the visual range this interval should occupy. + * Value between 0 and 1. + */ + ratio: number; +} +``` + +## Usage Example + +```json +{ + "type": "linear", + "customDistribution": [ + { "domain": [0, 6], "ratio": 0.2 }, + { "domain": [6, 7], "ratio": 0.1 }, + { "domain": [7, 9], "ratio": 0.5 }, + { "domain": [9, 10], "ratio": 0.2 } + ] +} +``` diff --git a/specs/003-area-enlargement/plan.md b/specs/003-area-enlargement/plan.md new file mode 100644 index 0000000000..d19bcdc86f --- /dev/null +++ b/specs/003-area-enlargement/plan.md @@ -0,0 +1,68 @@ +# Implementation Plan: Area Enlargement Line Chart + +**Branch**: `003-area-enlargement` | **Date**: 2026-02-02 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/003-area-enlargement/spec.md` + +## Summary + +Implement Area Enlargement (non-uniform linear axis) by leveraging the native piecewise domain/range capability of `LinearScale` (in `@visactor/vscale`). We will allow users to define `customDistribution` in the axis spec, which will be converted into multi-segment domain and range arrays for the scale. + +## Technical Context + +**Language/Version**: TypeScript +**Primary Dependencies**: `@visactor/vscale` (LinearScale) +**Target Platform**: Web/Mobile (VChart standard) +**Performance Goals**: Negligible impact. +**Constraints**: Must work within existing VChart axis architecture. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Quality First**: Comprehensive unit tests. +- [x] **UX Driven**: Simple configuration. +- [x] **SDD**: Following the Spec-Plan-Task process. +- [x] **Monorepo**: Changes localized to `packages/vchart`. +- [x] **TypeScript**: Strict typing. + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-area-enlargement/ +├── plan.md # This file +├── research.md # Implementation decisions +├── data-model.md # Scale Spec definitions +└── tasks.md # Implementation tasks +``` + +### Source Code (packages/vchart) + +```text +packages/vchart/src/ +├── component/ +│ └── axis/ +│ ├── cartesian/ +│ │ └── linear-axis.ts # Update: Calculate piecewise range +│ ├── mixin/ +│ │ └── linear-axis-mixin.ts # Update: Calculate piecewise domain +│ └── interface/ +│ └── spec.ts # Update: Add customDistribution to spec +└── typings/ + └── scale.ts # Update: Add IIntervalRatio +``` + +**Structure Decision**: No new scale class. Modify existing Axis components to utilize `LinearScale`'s piecewise capabilities. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| Piecewise Domain/Range logic in Axis | To support non-uniform scaling | Creating a custom scale class was rejected as LinearScale already supports this. | + +## Phase 1: Design + +### Data Model + +See `data-model.md` for `customDistribution` definition. diff --git a/specs/003-area-enlargement/quickstart.md b/specs/003-area-enlargement/quickstart.md new file mode 100644 index 0000000000..98f7a894e1 --- /dev/null +++ b/specs/003-area-enlargement/quickstart.md @@ -0,0 +1,29 @@ +# Quickstart: Area Enlargement + +## Introduction + +Area Enlargement (Linear Interval Scale) allows you to emphasize specific ranges on a linear axis by allocating more visual space to them. + +## Usage + +Configure your axis with `type: 'linear'` (default) and provide `customDistribution`. + +```javascript +const spec = { + type: 'line', + data: [ ... ], + axes: [ + { + orient: 'left', + type: 'linear', + domain: [0, 10], // Optional if customDistribution covers the data + customDistribution: [ + { domain: [0, 7], ratio: 0.2 }, + { domain: [7, 9], ratio: 0.6 }, // Focus on 7-9 + { domain: [9, 10], ratio: 0.2 } + ] + } + ], + ... +}; +``` diff --git a/specs/003-area-enlargement/research.md b/specs/003-area-enlargement/research.md new file mode 100644 index 0000000000..78781dac65 --- /dev/null +++ b/specs/003-area-enlargement/research.md @@ -0,0 +1,52 @@ +# Research: Area Enlargement Implementation + +**Date**: 2026-02-02 +**Feature**: Area Enlargement Line Chart +**Status**: Completed + +## Decisions + +### 1. Implementation Strategy: Custom Scale Class + +**Decision**: Implement a new `LinearIntervalScale` class within VChart (`packages/vchart/src/scale/linear-interval-scale.ts`) instead of modifying `@visactor/vscale`. + +**Rationale**: +- `@visactor/vscale` is an external dependency. Modifying it requires a separate release cycle and might not be feasible if I don't have write access or if it's a shared library. +- A custom scale in VChart allows rapid iteration and specific logic for this feature. +- The scale will implement the necessary interface to be used by `CartesianLinearAxis`. + +**Alternatives Considered**: +- **Modify `LinearAxisMixin`**: Implement the mapping logic directly in the axis. + - *Pros*: No new scale class. + - *Cons*: Axis logic is already complex. Coupling scale logic into axis makes it harder to reuse (e.g., for legends or other components). +- **Subclass `LinearScale`**: + - *Pros*: Inherit existing methods. + - *Cons*: `LinearScale` might have private members or strict behavior that is hard to override for piecewise logic. Composition (implementing interface and delegating if needed) is safer. + +### 2. Configuration API + +**Decision**: Add `customDistribution` (or similar) to the scale spec. + +**Schema**: +```typescript +interface ILinearIntervalScaleSpec extends ILinearScaleSpec { + type: 'linear-interval'; // or keep 'linear' and check for distribution? Better to use explicit type. + intervals: { + domain: [number, number]; // Sub-domain + range: [number, number]; // Proportion of the visual range (0-1) + }[]; +} +``` + +**Rationale**: Explicit mapping of domain intervals to range proportions gives full control. + +### 3. Axis Integration + +**Decision**: Update `CartesianLinearAxis` to support `linear-interval` scale type. + +**Rationale**: The axis component checks for `type`. We need to register the new scale and ensure the axis accepts it. + +## Open Questions + +- **Ticks Generation**: `LinearScale.ticks()` returns uniformly spaced ticks. `LinearIntervalScale.ticks()` needs to return ticks that are appropriate for each interval. + - *Solution*: The scale will iterate over intervals and generate ticks for each, then combine them. diff --git a/specs/003-area-enlargement/spec.md b/specs/003-area-enlargement/spec.md new file mode 100644 index 0000000000..8d7f25b68d --- /dev/null +++ b/specs/003-area-enlargement/spec.md @@ -0,0 +1,69 @@ +# Feature Specification: Area Enlargement Line Chart + +**Feature Branch**: `003-area-enlargement` +**Created**: 2026-02-02 +**Status**: Draft +**Input**: User description: "实现这个线性区间可以分配的需求 https://github.com/VisActor/VChart/issues/4413" + +## User Scenarios & Testing + +### User Story 1 - Focus on Specific Data Range (Priority: P1) + +As a data analyst, I want to expand the visual space of a specific data range (e.g., 7-9) on the Y-axis while keeping the rest of the axis (0-6, 9-10) visible but compressed, so that I can analyze the subtle fluctuations in the important range without losing the global context. + +**Why this priority**: This is the core requirement of the feature. Users need to highlight specific data intervals. + +**Independent Test**: +- Create a line chart with data in range 0-10. +- Configure the axis to expand the 7-9 range. +- Verify that the visual height of 7-9 is significantly larger than 0-6 and 9-10. + +**Acceptance Scenarios**: + +1. **Given** a line chart with Y-axis domain [0, 10], **When** I configure the scale to allocate 50% of the range to [7, 9], **Then** the visual segment for [7, 9] occupies half the axis height. +2. **Given** the same chart, **When** I render it, **Then** the axis ticks are correctly positioned (ticks in 7-9 are more spaced out visually than in 0-6). + +--- + +### User Story 2 - Multiple Intervals (Priority: P2) + +As a user, I want to define multiple custom intervals with different visual weights, so that I can handle complex distribution requirements. + +**Why this priority**: Provides flexibility for more complex data patterns. + +**Independent Test**: Configure 3+ intervals with different weights and verify rendering. + +**Acceptance Scenarios**: + +1. **Given** a chart with domain [0, 100], **When** I define 3 intervals with ratio 1:2:1, **Then** the axis is divided visually according to these ratios. + +--- + +### Edge Cases + +- **Domain Mismatch**: What happens if the defined intervals do not cover the entire domain? (Assumption: The scale should auto-fill or throw a warning, or the intervals define the whole domain). +- **Overlapping Intervals**: How does the system handle overlapping interval definitions? (Assumption: Should be disallowed or priority defined). +- **Zero Range**: What if an interval has 0 weight? (Assumption: It should be hidden or have minimal visibility). + +## Requirements + +### Functional Requirements + +- **FR-001**: The system MUST support a new scale capability (or new scale type) to map continuous domain intervals to specific range proportions. +- **FR-002**: Users MUST be able to define the distribution of the domain via a configuration (e.g., `linearDistribution` or similar) in the axis/scale spec. +- **FR-003**: The scale MUST strictly respect the configured mapping for coordinate conversion (data -> position). +- **FR-004**: The scale MUST correctly support `invert` (position -> data) for interaction (tooltip, etc.). +- **FR-005**: Axis ticks generation MUST adapt to the non-uniform scale (ticks should be generated based on the value, not just visual spacing, or adapted to show more density in expanded areas). + +### Key Entities + +- **LinearIntervalScale**: A new or extended scale class that handles the piecewise linear mapping. +- **ScaleSpec**: The configuration interface extending the standard linear scale spec. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: Users can configure a chart where a 20% domain interval (e.g., 7-9 in 0-10) occupies at least 50% of the visual range. +- **SC-002**: Tooltip interaction works correctly (hovering over the expanded area shows correct data values). +- **SC-003**: Ticks are rendered without overlapping and reflect the scale distortion. diff --git a/specs/003-area-enlargement/tasks.md b/specs/003-area-enlargement/tasks.md new file mode 100644 index 0000000000..644c29408a --- /dev/null +++ b/specs/003-area-enlargement/tasks.md @@ -0,0 +1,35 @@ +# Implementation Tasks: Area Enlargement Line Chart + +**Feature**: Area Enlargement Line Chart +**Branch**: `003-area-enlargement` +**Spec**: [spec.md](./spec.md) +**Plan**: [plan.md](./plan.md) + +## Implementation Strategy + +We will revert the custom scale implementation and instead implement logic in `LinearAxisMixin` and `CartesianLinearAxis` to construct piecewise domain and range arrays for the standard `LinearScale`. + +## Dependencies + +- US1 (Single Interval) -> US2 (Multiple Intervals) + +## Phase 1: Cleanup & Setup + +- [ ] T001 Revert `LinearIntervalScale` related changes (delete file, unregister). +- [ ] T002 Ensure `IIntervalRatio` is defined in `packages/vchart/src/typings/scale.ts` and `customDistribution` in `packages/vchart/src/component/axis/interface/spec.ts`. + +## Phase 2: Foundational (Axis Logic) + +- [ ] T003 Implement `computeLinearDomain` update in `packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts` to handle `customDistribution` (construct piecewise domain). +- [ ] T004 Implement `getNewScaleRange` update in `packages/vchart/src/component/axis/cartesian/linear-axis.ts` to handle `customDistribution` (construct piecewise range based on ratios). + +## Phase 3: Verification (P1 & P2) + +**Goal**: Verify area enlargement works. + +- [ ] T005 Verify `computeLinearDomain` logic via unit test in `packages/vchart/src/component/axis/mixin/__tests__/linear-axis-mixin.test.ts` (create/update test). +- [ ] T006 Verify `getNewScaleRange` logic (or end-to-end axis behavior) via demo or integration test. + +## Phase 4: Polish + +- [ ] T007 [Polish] Add documentation/comments for `customDistribution`. From 401ced3387d43a4a822d7b6e88252e020cc6be2b Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Mon, 2 Feb 2026 16:25:29 +0800 Subject: [PATCH 2/5] feat: add documents of customDistribution --- .../option/en/component/axis-common/linear-axis.md | 14 ++++++++++++++ .../option/zh/component/axis-common/linear-axis.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/assets/option/en/component/axis-common/linear-axis.md b/docs/assets/option/en/component/axis-common/linear-axis.md index 8c4b178d06..49ba36ad43 100644 --- a/docs/assets/option/en/component/axis-common/linear-axis.md +++ b/docs/assets/option/en/component/axis-common/linear-axis.md @@ -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. diff --git a/docs/assets/option/zh/component/axis-common/linear-axis.md b/docs/assets/option/zh/component/axis-common/linear-axis.md index 502b0fd492..aeca06f01f 100644 --- a/docs/assets/option/zh/component/axis-common/linear-axis.md +++ b/docs/assets/option/zh/component/axis-common/linear-axis.md @@ -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。 From b8361fc5a04a97a9301145a2b63857d3465c2a56 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Mon, 2 Feb 2026 17:05:41 +0800 Subject: [PATCH 3/5] feat: optimiz with comment --- .../vchart/src/component/axis/mixin/linear-axis-mixin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts index bcc97f39f4..6b7ab68324 100644 --- a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts +++ b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts @@ -163,10 +163,7 @@ export class LinearAxisMixin { domainSet.add(item.domain[1]); }); domain = Array.from(domainSet).sort((a, b) => a - b); - return domain; - } - - if (data.length) { + } else if (data.length) { const userSetBreaks = this._spec.breaks && this._spec.breaks.length; let values: any[] = []; let minDomain: number; From 3c7c04dd6952646f83cc9401159a2c6d44b473f5 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 25 Feb 2026 16:54:11 +0800 Subject: [PATCH 4/5] feat: support area enlargement (linear axis custom distribution) --- .../axis/cartesian/interface/spec.ts | 9 ++ .../component/axis/cartesian/linear-axis.ts | 59 +++------- .../component/axis/mixin/linear-axis-mixin.ts | 108 ++++++++++-------- specs/003-area-enlargement/spec.md | 47 ++++++++ 4 files changed, 131 insertions(+), 92 deletions(-) diff --git a/packages/vchart/src/component/axis/cartesian/interface/spec.ts b/packages/vchart/src/component/axis/cartesian/interface/spec.ts index e444dca917..04b973a77a 100644 --- a/packages/vchart/src/component/axis/cartesian/interface/spec.ts +++ b/packages/vchart/src/component/axis/cartesian/interface/spec.ts @@ -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 & { diff --git a/packages/vchart/src/component/axis/cartesian/linear-axis.ts b/packages/vchart/src/component/axis/cartesian/linear-axis.ts index 14b1495ba3..1793766052 100644 --- a/packages/vchart/src/component/axis/cartesian/linear-axis.ts +++ b/packages/vchart/src/component/axis/cartesian/linear-axis.ts @@ -131,11 +131,11 @@ export class CartesianLinearAxis< newRange = combineDomains(this._break.scope).map(val => newRange[0] + (last(newRange) - newRange[0]) * val); } - if ((this._spec as any).customDistribution?.length && this._scale) { + if ((this._spec as any).customDistribution?.domain?.length && this._scale) { const customDistribution = (this._spec as any).customDistribution as { - domain: [number, number]; - ratio: number; - }[]; + domain: number[]; + ratio: number[]; + }; const domain = this._scale.domain(); if (domain.length > 2) { const start = newRange[0]; @@ -144,51 +144,18 @@ export class CartesianLinearAxis< const resultRange = [start]; let currentPos = start; - // Calculate total defined ratio and identify gaps - let totalDefinedRatio = 0; - customDistribution.forEach(item => (totalDefinedRatio += item.ratio)); - const remainingRatio = 1 - totalDefinedRatio; - - // Calculate total domain length of gaps (segments not covered by customDistribution) - let totalGapDomain = 0; - for (let i = 0; i < domain.length - 1; i++) { - const dStart = domain[i]; - const dEnd = domain[i + 1]; - const mid = (dStart + dEnd) / 2; - const covered = customDistribution.some(item => mid >= item.domain[0] && mid <= item.domain[1]); - if (!covered) { - totalGapDomain += Math.abs(dEnd - dStart); - } - } + const segmentWeights: number[] = customDistribution.ratio; + const totalWeight = segmentWeights.reduce((acc, cur) => acc + cur, 0); - for (let i = 0; i < domain.length - 1; i++) { - const dStart = domain[i]; - const dEnd = domain[i + 1]; - const dSpan = dEnd - dStart; // Can be negative if domain is reversed? Usually domain is sorted. - // LinearAxisMixin.computeLinearDomain sorts it. - - // Find matching config - // We check intersection or containment. - // Since domain points are derived from config endpoints, dStart/dEnd should align with config or be sub-segments. - const mid = (dStart + dEnd) / 2; - const config = customDistribution.find(item => mid >= item.domain[0] && mid <= item.domain[1]); - - let segmentRatio = 0; - if (config) { - const configSpan = config.domain[1] - config.domain[0]; - if (configSpan !== 0) { - segmentRatio = config.ratio * (Math.abs(dSpan) / Math.abs(configSpan)); - } - } else { - // Gap - if (totalGapDomain > 0) { - segmentRatio = remainingRatio * (Math.abs(dSpan) / totalGapDomain); - } + 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); } - - currentPos += segmentRatio * totalRange; - resultRange.push(currentPos); } + // Ensure last point is exactly end to avoid float errors resultRange[resultRange.length - 1] = end; newRange = resultRange; diff --git a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts index 6b7ab68324..0efebf9688 100644 --- a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts +++ b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts @@ -153,21 +153,14 @@ export class LinearAxisMixin { computeLinearDomain(data: { min: number; max: number; values: any[] }[]): number[] { let domain: number[] = []; + let minDomain: number; + let maxDomain: number; - // handle customDistribution - if ((this._spec as any).customDistribution?.length) { - const customDistribution = (this._spec as any).customDistribution; - const domainSet = new Set(); - customDistribution.forEach((item: any) => { - domainSet.add(item.domain[0]); - domainSet.add(item.domain[1]); - }); - domain = Array.from(domainSet).sort((a, b) => a - b); - } else if (data.length) { - const userSetBreaks = this._spec.breaks && this._spec.breaks.length; - let values: any[] = []; - let minDomain: number; - let maxDomain: number; + const userSetBreaks = this._spec.breaks && this._spec.breaks.length; + let values: any[] = []; + + // Calculate data min/max first + if (data.length) { data.forEach(d => { const { min, max } = d; minDomain = minDomain === undefined ? min : Math.min(minDomain, min as number); @@ -176,49 +169,72 @@ export class LinearAxisMixin { values = values.concat(d.values); } }); + } else { + minDomain = 0; + maxDomain = 0; + } - if (userSetBreaks) { - const breakRanges = []; - const breaks = []; - // 如果用户手动的手指了max,可以将break的最大值限制在用户设置的最大值范围内 - const breakMaxLimit = isNil(this._domain.max) ? maxDomain : this._domain.max; - for (let index = 0; index < this._spec.breaks.length; index++) { - const { range } = this._spec.breaks[index]; - if (range[0] <= range[1] && range[1] <= breakMaxLimit) { - breakRanges.push(range); - breaks.push(this._spec.breaks[index]); - } - } - breakRanges.sort((a: [number, number], b: [number, number]) => a[0] - b[0]); - if (breakRanges.length) { - const { domain: breakDomains, scope: breakScopes } = breakData( - values, - combineDomains(breakRanges), - this._spec.breaks[0].scopeType - ); - - domain = combineDomains(breakDomains); - this._break = { - domain: breakDomains, - scope: breakScopes, - breakDomains: breakRanges, - breaks - }; - } else { - domain = [minDomain, maxDomain]; + if (userSetBreaks) { + const breakRanges = []; + const breaks = []; + // 如果用户手动的手指了max,可以将break的最大值限制在用户设置的最大值范围内 + const breakMaxLimit = isNil(this._domain.max) ? maxDomain : this._domain.max; + for (let index = 0; index < this._spec.breaks.length; index++) { + const { range } = this._spec.breaks[index]; + if (range[0] <= range[1] && range[1] <= breakMaxLimit) { + breakRanges.push(range); + breaks.push(this._spec.breaks[index]); } + } + breakRanges.sort((a: [number, number], b: [number, number]) => a[0] - b[0]); + if (breakRanges.length) { + const { domain: breakDomains, scope: breakScopes } = breakData( + values, + combineDomains(breakRanges), + this._spec.breaks[0].scopeType + ); + + domain = combineDomains(breakDomains); + this._break = { + domain: breakDomains, + scope: breakScopes, + breakDomains: breakRanges, + breaks + }; } else { domain = [minDomain, maxDomain]; } } else { - // default value for linear axis - domain[0] = 0; - domain[1] = 0; + domain = [minDomain, maxDomain]; } this.setSoftDomainMinMax(domain); this.expandDomain(domain); this.includeZero(domain); this.setDomainMinMax(domain); + let min = domain[0]; + let max = domain[0]; + domain.forEach(val => { + if (val < min) { + min = val; + } + if (val > max) { + max = val; + } + }); + + if ((this._spec as any).customDistribution?.domain?.length) { + // handle customDistribution + const customDistribution = (this._spec as any).customDistribution; + const domainSet = new Set(); + domain.forEach(val => domainSet.add(val)); + + customDistribution.domain.forEach((val: number) => { + if (val > min && val < max) { + domainSet.add(val); + } + }); + domain = Array.from(domainSet).sort((a, b) => a - b); + } return domain; } diff --git a/specs/003-area-enlargement/spec.md b/specs/003-area-enlargement/spec.md index 8d7f25b68d..aa5944d8a8 100644 --- a/specs/003-area-enlargement/spec.md +++ b/specs/003-area-enlargement/spec.md @@ -60,6 +60,53 @@ As a user, I want to define multiple custom intervals with different visual weig - **LinearIntervalScale**: A new or extended scale class that handles the piecewise linear mapping. - **ScaleSpec**: The configuration interface extending the standard linear scale spec. +## Configuration Specification (New) + +The `customDistribution` configuration adopts a segmented approach to eliminate ambiguity and overlapping issues. + +```typescript +interface ICustomDistribution { + /** + * The cut points defining the segments. + * e.g. [50] defines two segments: (-inf, 50] and (50, +inf). + */ + domain: number[]; + /** + * The visual ratio for each segment. + * ratio[i] corresponds to the segment ending at domain[i] (or extending to infinity for the last one). + * - ratio[0]: Proportion for range [min, domain[0]] + * - ratio[k]: Proportion for range [domain[k-1], domain[k]] + * - ratio[last]: Proportion for range [domain[last], max] + */ + ratio: number[]; +} +``` + +### Logic Rules + +1. **Dynamic Domain Integration**: + - The configured `domain` points are combined with the current scale's `min` and `max` (derived from data or user spec). + - Points outside `(min, max)` are ignored unless `min/max` are explicitly extended. + - The final scale domain is constructed as: `[min, ...valid_cut_points, max]`. + +2. **Ratio Allocation**: + - Ratios are assigned to the segments defined by the cut points. + - If a segment is fully or partially outside the current `[min, max]`, its visual space is reallocated or removed. + - The system normalizes the ratios of the *active* segments to ensure the full axis range is utilized. + +3. **Example**: + - Config: `domain: [50]`, `ratio: [0.2, 0.8]` + - Data Range: `[0, 100]` + - Result: + - Segment 1 `[0, 50]`: 20% visual space. + - Segment 2 `[50, 100]`: 80% visual space. + + - Data Range: `[25, 75]` + - Result: + - Segment 1 `[25, 50]`: Inherits ratio weight from `ratio[0]` (0.2). + - Segment 2 `[50, 75]`: Inherits ratio weight from `ratio[1]` (0.8). + - Visual Ratio: `0.2 / (0.2 + 0.8)` vs `0.8 / (0.2 + 0.8)` -> Still 20% vs 80% relative to each other. + ## Success Criteria ### Measurable Outcomes From 27dd0ac39f95ec92fd13d00ffc44438d4ae6f322 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 25 Feb 2026 16:59:29 +0800 Subject: [PATCH 5/5] feat: support area enlargement (linear axis custom distribution) --- .../builtin-theme/charts/area-enlargement.ts | 9 ++++----- .../axis/linear-axis-distribution.test.ts | 17 ++++++++--------- .../vchart/src/component/axis/interface/spec.ts | 8 +++++--- packages/vchart/src/typings/scale.ts | 5 ----- 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/docs/assets/demos/builtin-theme/charts/area-enlargement.ts b/docs/assets/demos/builtin-theme/charts/area-enlargement.ts index 6f76b4857e..a05d5cb66e 100644 --- a/docs/assets/demos/builtin-theme/charts/area-enlargement.ts +++ b/docs/assets/demos/builtin-theme/charts/area-enlargement.ts @@ -23,11 +23,10 @@ const spec = { { orient: 'left', type: 'linear', - customDistribution: [ - { domain: [0, 7], ratio: 0.2 }, - { domain: [7, 9], ratio: 0.6 }, - { domain: [9, 10], ratio: 0.2 } - ] + customDistribution: { + domain: [0, 7, 9, 10], + ratio: [0.2, 0.6, 0.2] + } } ] }; diff --git a/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts b/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts index c985ffa60a..f46cba13fd 100644 --- a/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts +++ b/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts @@ -70,10 +70,10 @@ describe('LinearAxis customDistribution', () => { let spec = getAxisSpec({ orient: 'left', - customDistribution: [ - { domain: [0, 5], ratio: 0.8 }, - { domain: [5, 10], ratio: 0.2 } - ] + customDistribution: { + domain: [0, 5, 10], + ratio: [0.8, 0.2] + } }); const transformer = new CartesianAxis.transformerConstructor({ @@ -111,11 +111,10 @@ describe('LinearAxis customDistribution', () => { test('should handle gaps in customDistribution', () => { let spec = getAxisSpec({ orient: 'left', - customDistribution: [ - { domain: [0, 5], ratio: 0.4 }, - // Gap 5-8 - { domain: [8, 10], ratio: 0.4 } - ] + customDistribution: { + domain: [0, 5, 8, 10], + ratio: [0.4, 0.4] + } }); const transformer = new CartesianAxis.transformerConstructor({ diff --git a/packages/vchart/src/component/axis/interface/spec.ts b/packages/vchart/src/component/axis/interface/spec.ts index cce3983613..0378d837e1 100644 --- a/packages/vchart/src/component/axis/interface/spec.ts +++ b/packages/vchart/src/component/axis/interface/spec.ts @@ -9,8 +9,7 @@ import type { IRuleMarkSpec, ISymbolMarkSpec, ITextMarkSpec, - StringOrNumber, - IIntervalRatio + StringOrNumber } from '../../../typings'; import type { IComponentSpec } from '../../base/interface'; import type { AxisType, IAxisItem, IBandAxisLayer, ITickCalculationCfg, StyleCallback } from './common'; @@ -169,7 +168,10 @@ export interface ILinearAxisSpec { * 自定义区间分布配置 * @since 2.0.16 */ - customDistribution?: IIntervalRatio[]; + customDistribution?: { + domain: number[]; + ratio: number[]; + }; } export interface IBandAxisSpec { diff --git a/packages/vchart/src/typings/scale.ts b/packages/vchart/src/typings/scale.ts index 98cd3a6098..8d49de21d9 100644 --- a/packages/vchart/src/typings/scale.ts +++ b/packages/vchart/src/typings/scale.ts @@ -45,11 +45,6 @@ export interface ILinearScaleSpec extends INumericScaleSpec { type: 'linear'; } -export interface IIntervalRatio { - domain: [number, number]; - ratio: number; -} - export interface IPointScaleSpec extends IBaseBandScaleSpec { type: 'point'; }