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..a05d5cb66e --- /dev/null +++ b/docs/assets/demos/builtin-theme/charts/area-enlargement.ts @@ -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; 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。 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..f46cba13fd --- /dev/null +++ b/packages/vchart/__tests__/unit/component/cartesian/axis/linear-axis-distribution.test.ts @@ -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]); + }); +}); 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 4400d7bbef..1793766052 100644 --- a/packages/vchart/src/component/axis/cartesian/linear-axis.ts +++ b/packages/vchart/src/component/axis/cartesian/linear-axis.ts @@ -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); + } + } + + // 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..0378d837e1 100644 --- a/packages/vchart/src/component/axis/interface/spec.ts +++ b/packages/vchart/src/component/axis/interface/spec.ts @@ -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 { 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..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,12 +153,14 @@ export class LinearAxisMixin { computeLinearDomain(data: { min: number; max: number; values: any[] }[]): number[] { let domain: number[] = []; + 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) { - const userSetBreaks = this._spec.breaks && this._spec.breaks.length; - let values: any[] = []; - let minDomain: number; - let maxDomain: number; data.forEach(d => { const { min, max } = d; minDomain = minDomain === undefined ? min : Math.min(minDomain, min as number); @@ -167,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; } @@ -241,9 +266,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/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..aa5944d8a8 --- /dev/null +++ b/specs/003-area-enlargement/spec.md @@ -0,0 +1,116 @@ +# 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. + +## 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 + +- **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`.