feat: support area enlargement (linear axis custom distribution)#4441
feat: support area enlargement (linear axis custom distribution)#4441xuefei1313 wants to merge 3 commits intodevelopfrom
Conversation
d443a1f to
eee1aa3
Compare
|
|
||
| export interface IIntervalRatio { | ||
| domain: [number, number]; | ||
| ratio: number; |
There was a problem hiding this comment.
Pull request overview
This PR implements “area enlargement” for linear axes by allowing users to specify a custom distribution of domain intervals onto the axis range, so important value ranges (like 7–9) can visually occupy more space while preserving global context.
Changes:
- Add the
IIntervalRatiotype andcustomDistributionoption to the linear axis spec and public documentation. - Implement
customDistributionhandling inLinearAxisMixin.computeLinearDomainandCartesianLinearAxis.getNewScaleRangeto build piecewise domains/ranges from user-defined intervals and ratios. - Add unit tests, internal specs/plan/checklists, and a demo chart illustrating area enlargement, plus a changefile entry for
@visactor/vchart.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| specs/003-area-enlargement/spec.md | Defines the feature’s user stories, requirements, and success criteria for area-enlargement on linear axes. |
| specs/003-area-enlargement/plan.md | Records the implementation plan to reuse LinearScale with axis-level customDistribution instead of a new scale type. |
| specs/003-area-enlargement/research.md | Captures earlier research and design decisions (now partially superseded) around a dedicated LinearIntervalScale. |
| specs/003-area-enlargement/tasks.md | Lists implementation tasks (type additions, axis logic, tests, docs) for the area-enlargement feature. |
| specs/003-area-enlargement/data-model.md | Documents the data model for customDistribution and IIntervalRatio with example configuration. |
| specs/003-area-enlargement/quickstart.md | Provides an internal quickstart example showing how to configure customDistribution on a linear axis. |
| specs/003-area-enlargement/checklists/requirements.md | Adds a requirements checklist asserting spec quality and readiness for planning. |
| packages/vchart/src/typings/scale.ts | Introduces the IIntervalRatio interface for interval [domain, ratio] pairs used by linear axes. |
| packages/vchart/src/component/axis/interface/spec.ts | Extends ILinearAxisSpec with the customDistribution?: IIntervalRatio[] configuration. |
| packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts | Updates computeLinearDomain and niceDomain to construct a piecewise domain from customDistribution and to disable nicifying when this option is used. |
| packages/vchart/src/component/axis/cartesian/linear-axis.ts | Extends getNewScaleRange to compute a piecewise range array according to customDistribution ratios, including handling of uncovered domain gaps. |
| packages/vchart/tests/unit/component/cartesian/axis/linear-axis-distribution.test.ts | Adds unit tests verifying that customDistribution produces the expected piecewise domain and basic range behavior. |
| docs/assets/option/zh/component/axis-common/linear-axis.md | Documents the new customDistribution option and its domain/ratio fields in the Chinese linear-axis API docs. |
| docs/assets/option/en/component/axis-common/linear-axis.md | Documents the new customDistribution option and its domain/ratio fields in the English linear-axis API docs. |
| docs/assets/demos/builtin-theme/charts/area-enlargement.ts | Adds a demo line chart that uses customDistribution to visually emphasize the [7, 9] range on a linear Y-axis. |
| common/changes/@visactor/vchart/feat-area-enlargement.json | Registers the changefile entry announcing the new area-enlargement feature as a minor release for @visactor/vchart. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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]); |
There was a problem hiding this comment.
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.
| - [ ] 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). |
There was a problem hiding this comment.
The implementation tasks T002–T004 (defining IIntervalRatio, adding customDistribution to the axis spec, and updating computeLinearDomain/getNewScaleRange) appear to be completed in this PR, but the checklist still shows them as unchecked. To keep the task tracking document reliable, consider marking the finished tasks as done (and optionally adding any follow-up items, such as additional tests or docs) so that it reflects the current implementation status.
| - [ ] 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). | |
| - [x] 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) | |
| - [x] T003 Implement `computeLinearDomain` update in `packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts` to handle `customDistribution` (construct piecewise domain). | |
| - [x] T004 Implement `getNewScaleRange` update in `packages/vchart/src/component/axis/cartesian/linear-axis.ts` to handle `customDistribution` (construct piecewise range based on ratios). |
| { | ||
| orient: 'left', | ||
| type: 'linear', | ||
| domain: [0, 10], // Optional if customDistribution covers the data |
There was a problem hiding this comment.
This quickstart example configures the linear axis with a top-level domain: [0, 10], but the actual ILinearAxisSpec API only exposes min/max (and the deprecated nested range) rather than a domain array. To avoid confusing users and to keep the sample aligned with the real API, consider switching this example to use min and max (or clarifying that domain is just shorthand/pseudocode if you intend something else).
| domain: [0, 10], // Optional if customDistribution covers the data | |
| min: 0, | |
| max: 10, // Optional if customDistribution covers the data |
| // handle customDistribution | ||
| if ((this._spec as any).customDistribution?.length) { | ||
| const customDistribution = (this._spec as any).customDistribution; | ||
| const domainSet = new Set<number>(); | ||
| 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; | ||
| } |
There was a problem hiding this comment.
Because this customDistribution branch returns early, any configured breaks on the same axis are effectively ignored: the code below that computes breakRanges and populates this._break never runs, so the break-specific range transformation in CartesianLinearAxis.getNewScaleRange is skipped. If breaks and customDistribution are meant to be mutually exclusive, it would be safer to validate and surface that conflict (or document it explicitly); otherwise, consider composing the two features instead of returning before the break-handling logic.
| // Calculate total defined ratio and identify gaps | ||
| let totalDefinedRatio = 0; | ||
| customDistribution.forEach(item => (totalDefinedRatio += item.ratio)); | ||
| const remainingRatio = 1 - totalDefinedRatio; |
There was a problem hiding this comment.
The new getNewScaleRange logic assumes that the sum of all ratio values in customDistribution is well-formed (typically ≤ 1) and does not normalize or validate these values. If a user accidentally specifies ratios that sum to more than 1, remainingRatio becomes negative and the computed gap segments will be assigned negative visual span, which will distort the range mapping in a non-obvious way. It would be safer to either validate and reject/clip invalid configurations, or normalize the ratios so that the behavior is predictable even when the user provides inconsistent values.
| - **LinearIntervalScale**: A new or extended scale class that handles the piecewise linear mapping. | ||
| - **ScaleSpec**: The configuration interface extending the standard linear scale spec. |
There was a problem hiding this comment.
The spec currently introduces LinearIntervalScale as a new scale class and describes extending the scale spec, but the actual implementation and the plan/tasks for this PR have switched to using customDistribution on the existing linear axis (without a new scale type). To avoid confusing future readers, consider updating this "Key Entities" section (or adding a note) so that it reflects the current design choice of reusing LinearScale with axis-level piecewise domain/range configuration instead of a dedicated LinearIntervalScale class.
| - **LinearIntervalScale**: A new or extended scale class that handles the piecewise linear mapping. | |
| - **ScaleSpec**: The configuration interface extending the standard linear scale spec. | |
| - **LinearScale with `customDistribution`**: Reuses the existing linear scale type; piecewise linear mapping is configured via an axis-level `customDistribution` (or equivalent) option instead of a new scale class. | |
| - **Axis / LinearScale config**: The configuration interface on the axis (or linear scale config) that exposes `customDistribution` while keeping `type: 'linear'`. |
| - [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 |
There was a problem hiding this comment.
This checklist marks both "No implementation details (languages, frameworks, APIs)" and "No implementation details leak into specification" as satisfied, but the referenced spec file already contains concrete implementation choices such as a named LinearIntervalScale class and file paths. To keep the SDD artifacts consistent, consider either moving those implementation details into research.md/plan.md or unchecking these items (and clarifying that the spec is still at "Draft" status) so that the checklist accurately reflects the state of the specification.
| ### 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. |
There was a problem hiding this comment.
This research document records a final "Decision" to implement a new LinearIntervalScale class, but the current implementation and plan.md indicate that the chosen approach is to reuse LinearScale with axis-level customDistribution instead. It would be helpful to update this section (for example by marking the custom-scale approach as an evaluated-but-rejected option and referencing the final plan) so that readers are not misled into thinking a LinearIntervalScale class still exists in the codebase.
| return domain; | ||
| } | ||
|
|
||
| if (data.length) { |
There was a problem hiding this comment.
When customDistribution is present, computeLinearDomain returns immediately after constructing the set of interval endpoints, so the usual domain post-processing (setSoftDomainMinMax, expandDomain, includeZero, setDomainMinMax) is completely skipped for this path. This means options like softMin/softMax and expand on the linear axis spec have no effect whenever customDistribution is configured, which is inconsistent with the behavior of other linear axis configurations (including breaks). Consider restructuring this method so that the custom-distribution domain is still passed through the same domain adjustment pipeline (while keeping niceDomain disabled for customDistribution as you already do), rather than returning early.
| return domain; | |
| } | |
| if (data.length) { | |
| } else if (data.length) { |
|
|
||
| currentPos += segmentRatio * totalRange; | ||
| resultRange.push(currentPos); | ||
| } |
There was a problem hiding this comment.
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.
| } | |
| } | |
| // 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; | |
| } | |
| } | |
| } |
| 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); |
| newRange = combineDomains(this._break.scope).map(val => newRange[0] + (last(newRange) - newRange[0]) * val); | ||
| } | ||
|
|
||
| if ((this._spec as any).customDistribution?.length && this._scale) { |
There was a problem hiding this comment.
customDistribution 和 break 不能同时使用吧
| } else { | ||
| // Gap | ||
| if (totalGapDomain > 0) { | ||
| segmentRatio = remainingRatio * (Math.abs(dSpan) / totalGapDomain); |
There was a problem hiding this comment.
有点不懂这里的逻辑,对于gap 区间的话,range 不应该折叠吗,也就是说这个ratio 不应该是0吗
| if (config) { | ||
| const configSpan = config.domain[1] - config.domain[0]; | ||
| if (configSpan !== 0) { | ||
| segmentRatio = config.ratio * (Math.abs(dSpan) / Math.abs(configSpan)); |
There was a problem hiding this comment.
这里为什么是两个比例相乘,感觉有问题啊,不应该按照ratio来计算range吗
ef08412 to
6ccc014
Compare
🤔 这个分支是...
🔗 相关 issue 链接
close #4413
🔗 相关的 PR 链接
🐞 Bugserver 用例 id
💡 问题的背景&解决方案
支持线性轴的区域放大(Area Enlargement)功能。
通过在
linear轴配置中增加customDistribution属性,允许用户自定义 domain 区间在 range 上的分布比例,从而实现对特定数据区间的视觉放大。使用示例:
📝 Changelog
☑️ 自测
🚀 Summary
copilot:summary
🔍 Walkthrough
copilot:walkthrough