diff --git a/.cursor/commands/speckit.prgenerate.md b/.cursor/commands/speckit.prgenerate.md index 5bd34b0114..217960279a 100755 --- a/.cursor/commands/speckit.prgenerate.md +++ b/.cursor/commands/speckit.prgenerate.md @@ -1,5 +1,5 @@ --- -description: 'Generate a PR body using the GitHub pull request template and specs//plan.md; default to the English template and write to specs//PR_BODY.md.' +description: 'Generate a PR body using the GitHub pull request template and specs//plan.md; default to the English template and write to .trae/output/pr.body.local.md.' handoffs: - label: 'Create Pull Request' agent: 'speckit.prcreate' @@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty), but treat - Parse `$ARGUMENTS` only for CLI-style options (for example, `--lang`, `--out`): - If `--lang zh` is present, use the Chinese template at `.github/PULL_REQUEST_TEMPLATE/pr_cn.md`. - Otherwise, use the English template at `.github/PULL_REQUEST_TEMPLATE.md` (the default; **do not** auto-detect language). - - If an explicit output path is provided (for example, `--out specs/001-foo/PR_BODY.md`), respect it; otherwise, default to `specs//PR_BODY.md`. + - If an explicit output path is provided (for example, `--out specs/001-foo/PR_BODY.md`), respect it; otherwise, default to `.trae/output/pr.body.local.md`. - Resolve `specs//` by deriving the numeric prefix from the current branch name and matching it to a directory under `specs/` whose name starts with that prefix. - From that directory, resolve `plan.md` as the primary context source for the PR. @@ -61,7 +61,7 @@ You **MUST** consider the user input before proceeding (if not empty), but treat 6. **Write PR body file without touching templates**: - Render the updated markdown content (with checklist, background, and Changelog injected) into a single PR body string. - - Write this string to the resolved output path (default `specs//PR_BODY.md`), creating parent directories if needed. + - Write this string to the resolved output path (default `.trae/output/pr.body.local.md`), creating parent directories if needed. - Do **not** modify the original template files under `.github/`. 7. **Validation checklist**: diff --git a/.cursor/rules/specify-rules.mdc b/.cursor/rules/specify-rules.mdc index f4448e2042..56092a6035 100644 --- a/.cursor/rules/specify-rules.mdc +++ b/.cursor/rules/specify-rules.mdc @@ -6,6 +6,7 @@ Auto-generated from all feature plans. Last updated: 2026-01-15 - TypeScript 4.x+ (Project uses TS) + `@visactor/vchart` (Core logic), `@visactor/react-vchart` (React wrapper) (007-fix-datazoom-react) - N/A (In-memory chart state) (007-fix-datazoom-react) - Markdown + JSON(文档内容与菜单配置) + `@internal/docs` 文档构建体系、`docs/assets/guide/menu.json` 导航配置、现有教程目录结构 (001-vchart-skill-tutorial) +- TypeScript 4.9.5 + `@visactor/vchart`, `@visactor/vutils`, `@visactor/vrender-components` (010-hide-empty-axes) - TypeScript/React 18 + @visactor/react-vchart, @visactor/vchar (001-react-vchart-demo) @@ -27,9 +28,9 @@ npm test && npm run lint TypeScript 4.9.5: Follow standard conventions ## Recent Changes +- 010-hide-empty-axes: Added TypeScript 4.9.5 + `@visactor/vchart`, `@visactor/vutils`, `@visactor/vrender-components` - 001-vchart-skill-tutorial: Added Markdown + JSON(文档内容与菜单配置) + `@internal/docs` 文档构建体系、`docs/assets/guide/menu.json` 导航配置、现有教程目录结构 - 007-fix-datazoom-react: Added TypeScript 4.x+ (Project uses TS) + `@visactor/vchart` (Core logic), `@visactor/react-vchart` (React wrapper) -- 007-fix-datazoom-react: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/.vscode/launch.json b/.vscode/launch.json index ee7aafab14..22bdbd0d0e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,11 +16,27 @@ "skipFiles": ["/**"], "type": "pwa-node" }, + { + "name": "Debug Jest-Electron Current File", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/packages/vchart", + "program": "${workspaceFolder}/packages/vchart/node_modules/jest/bin/jest.js", + "args": [ + "${file}", + "--watch" + ], + "env": { + "DEBUG_MODE": "1" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, { "name": "unit test", "type": "pwa-node", "request": "launch", - "program": "${workspaceFolder}/packages/vchart/node_modules/.bin/jest", + "program": "${workspaceFolder}/packages/vchart/node_modules/jest/bin/jest.js", "args": ["${file}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" diff --git a/common/changes/@visactor/vchart/010-hide-empty-axes_2026-03-20-06-29.json b/common/changes/@visactor/vchart/010-hide-empty-axes_2026-03-20-06-29.json new file mode 100644 index 0000000000..f540ce9ec1 --- /dev/null +++ b/common/changes/@visactor/vchart/010-hide-empty-axes_2026-03-20-06-29.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "update changes for 010-hide-empty-axes: Add an opt-in hideWhenEmpty axis option for cartesian axes.", + "type": "none", + "packageName": "@visactor/vchart" + } + ], + "packageName": "@visactor/vchart", + "email": "lixuef1313@163.com" +} \ No newline at end of file diff --git a/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts b/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts index 29e8f4b791..cae05123be 100644 --- a/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts +++ b/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts @@ -21,6 +21,7 @@ export type ICartesianZ = { orient: 'z'; }; export type ICartesianAxisCommonSpec = ICommonAxisSpec & { + hideWhenEmpty?: boolean; grid?: IGrid; subGrid?: IGrid; domainLine?: ICartesianDomainLine; diff --git a/packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts b/packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts new file mode 100644 index 0000000000..ce4553e524 --- /dev/null +++ b/packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts @@ -0,0 +1,123 @@ +import { default as VChartConstructor } from '../../../../../src'; +import { createCanvas, removeDom } from '../../../../util/dom'; + +describe('cartesian axis hideWhenEmpty', () => { + let canvasDom: HTMLCanvasElement; + let vchart: any; + + beforeEach(() => { + canvasDom = createCanvas(); + canvasDom.style.position = 'relative'; + canvasDom.style.width = '500px'; + canvasDom.style.height = '500px'; + canvasDom.width = 500; + canvasDom.height = 500; + }); + + afterEach(() => { + removeDom(canvasDom); + vchart?.release(); + }); + + const getAxis = (orient: string) => + vchart.getComponents().find((com: any) => com.layout?.layoutOrient === orient) as any; + + test('should hide axis on initial render when bound series has no collected data', () => { + vchart = new VChartConstructor( + { + type: 'line', + width: 400, + height: 300, + data: [{ id: 'lineData', values: [] }], + axes: [ + { id: 'axis-left', orient: 'left', hideWhenEmpty: true }, + { id: 'axis-bottom', orient: 'bottom', type: 'band' } + ], + series: [{ type: 'line', dataId: 'lineData', xField: 'x', yField: 'y' }] + } as any, + { + renderCanvas: canvasDom, + animation: false + } + ); + + vchart.renderSync(); + + const leftAxis = getAxis('left'); + expect(leftAxis.getVisible()).toBe(false); + expect(leftAxis.getLayoutRect().width).toBe(0); + }); + + test('should keep default behavior when hideWhenEmpty is not enabled', () => { + vchart = new VChartConstructor( + { + type: 'line', + width: 400, + height: 300, + data: [{ id: 'lineData', values: [] }], + axes: [ + { id: 'axis-left', orient: 'left' }, + { id: 'axis-bottom', orient: 'bottom', type: 'band' } + ], + series: [{ type: 'line', dataId: 'lineData', xField: 'x', yField: 'y' }] + } as any, + { + renderCanvas: canvasDom, + animation: false + } + ); + + vchart.renderSync(); + + const leftAxis = getAxis('left'); + expect(leftAxis.getVisible()).toBe(true); + }); + + test('should only hide empty bound axes and show them again after data updates', async () => { + vchart = new VChartConstructor( + { + type: 'common', + width: 400, + height: 300, + data: [ + { id: 'emptyLine', values: [] }, + { + id: 'activeLine', + values: [ + { x: 'Mon', y: 10 }, + { x: 'Tue', y: 20 } + ] + } + ], + axes: [ + { id: 'axis-left', orient: 'left', seriesIndex: [0], hideWhenEmpty: true }, + { id: 'axis-right', orient: 'right', seriesIndex: [1], hideWhenEmpty: true }, + { id: 'axis-bottom', orient: 'bottom', type: 'band' } + ], + series: [ + { type: 'line', dataId: 'emptyLine', xField: 'x', yField: 'y' }, + { type: 'line', dataId: 'activeLine', xField: 'x', yField: 'y' } + ] + } as any, + { + renderCanvas: canvasDom, + animation: false + } + ); + + vchart.renderSync(); + + const leftAxis = getAxis('left'); + const rightAxis = getAxis('right'); + expect(leftAxis.getVisible()).toBe(false); + expect(rightAxis.getVisible()).toBe(true); + + await vchart.updateData('emptyLine', [ + { x: 'Mon', y: 5 }, + { x: 'Tue', y: 15 } + ]); + + expect(leftAxis.getVisible()).toBe(true); + expect(leftAxis.getLayoutRect().width).toBeGreaterThan(0); + }); +}); diff --git a/packages/vchart/src/component/axis/base-axis.ts b/packages/vchart/src/component/axis/base-axis.ts index bf7e7fad78..3e9888bfb5 100644 --- a/packages/vchart/src/component/axis/base-axis.ts +++ b/packages/vchart/src/component/axis/base-axis.ts @@ -1,3 +1,4 @@ +import type { IGroup } from '@visactor/vrender-core'; // eslint-disable-next-line no-duplicate-imports import type { ITickDataOpt, AxisItem } from '@visactor/vrender-components'; import type { IBandLikeScale, IBaseScale, IContinuousScale } from '@visactor/vscale'; @@ -96,10 +97,16 @@ export abstract class AxisComponent { @@ -252,52 +275,53 @@ export abstract class AxisComponent { - let field = this.collectSeriesField(depth, s); - field = (isArray(field) ? (isContinuous(this._scale.type) ? field : [field[0]]) : [field]) as string[]; - if (!depth) { - this._dataFieldText = s.getFieldAlias(field[0]); - } + this._regions && + eachSeries( + this._regions, + s => { + let field = this.collectSeriesField(depth, s); + field = (isArray(field) ? (isContinuous(this._scale.type) ? field : [field[0]]) : [field]) as string[]; + if (!depth) { + this._dataFieldText = s.getFieldAlias(field[0]); + } - if (field) { - const viewData = s.getViewData(); - if (rawData) { - field.forEach(f => { - data.push( - s.getRawDataStatisticsByField(f, !!isContinuous(this._scale.type)) as { - min: number; - max: number; - values: any[]; + if (field) { + const viewData = s.getViewData(); + if (rawData) { + field.forEach(f => { + data.push( + s.getRawDataStatisticsByField(f, !!isContinuous(this._scale.type)) as { + min: number; + max: number; + values: any[]; + } + ); + }); + } else if (viewData && viewData.latestData && viewData.latestData.length) { + const seriesData = s.getViewDataStatistics?.(); + const userSetBreaks = + this.type === ComponentTypeEnum.cartesianLinearAxis && this._spec.breaks && this._spec.breaks.length; + + field.forEach(f => { + if (seriesData?.latestData?.[f]) { + if (userSetBreaks) { + data.push({ + ...seriesData.latestData[f], + values: viewData.latestData.map((obj: Datum) => obj[f]) + }); + } else { + data.push(seriesData.latestData[f]); + } } - ); - }); - } else if (viewData && viewData.latestData && viewData.latestData.length) { - const seriesData = s.getViewDataStatistics?.(); - const userSetBreaks = - this.type === ComponentTypeEnum.cartesianLinearAxis && this._spec.breaks && this._spec.breaks.length; - - field.forEach(f => { - if (seriesData?.latestData?.[f]) { - if (userSetBreaks) { - data.push({ - ...seriesData.latestData[f], - values: viewData.latestData.map((obj: Datum) => obj[f]) - }); - } else { - data.push(seriesData.latestData[f]); - } - } - }); + }); + } } + }, + { + userId: this._seriesUserId, + specIndex: this._seriesIndex } - }, - { - userId: this._seriesUserId, - specIndex: this._seriesIndex - } - ); + ); return data; } @@ -378,6 +402,36 @@ export abstract class AxisComponent 0; + } + + protected _refreshVisibilityByData() { + const nextVisible = this._specVisible && (!this._hideWhenEmpty || this._hasCollectedSeriesData()); + const changed = this._visible !== nextVisible; + this._visible = nextVisible; + + if (this._axisMark || this._gridMark) { + this._syncComponentVisibility(); + } + + if (changed) { + this._forceLayout(); + } + + return changed; + } + + protected _syncComponentVisibility() { + this._axisMark?.setVisible(this._visible); + this._gridMark?.setVisible(this._visible && this._spec.grid?.visible !== false); + + (this._axisMark?.getComponent?.() as IGroup)?.setAttributes?.({ visibleAll: this._visible }); + (this._gridMark?.getComponent?.() as IGroup)?.setAttributes?.({ + visibleAll: this._visible && this._spec.grid?.visible !== false + }); + } + protected _clearRawDomain() { // 留给各个类型的 axis 来 override } diff --git a/packages/vchart/src/component/axis/cartesian/axis.ts b/packages/vchart/src/component/axis/cartesian/axis.ts index d8976623f2..a058a91b11 100644 --- a/packages/vchart/src/component/axis/cartesian/axis.ts +++ b/packages/vchart/src/component/axis/cartesian/axis.ts @@ -302,7 +302,7 @@ export abstract class CartesianAxis getBoundsInRect: update tick attr -> forceLayout -> updateLayoutAttr: update tick attr -> chart layout -> scale update -> mark encode // 问题: chart layout之后, scale发生变化, 导致tick 和 mark position 不同步 // 解决方案: chart layout 之后重新计算tick位置 @@ -805,6 +805,11 @@ export abstract class CartesianAxis { + if (!this._visible) { + this._syncComponentVisibility(); + return; + } + const startPoint = this.getLayoutStartPoint(); const { grid: updateGridAttrs, ...updateAxisAttrs } = this._getUpdateAttribute(false); const axisAttrs = mergeSpec({ x: startPoint.x, y: startPoint.y }, this._axisStyle, updateAxisAttrs); @@ -822,6 +827,8 @@ export abstract class CartesianAxis number; _onTickDataChange: (compilableData: CompilableData) => void; registerTicksTransform: () => string; + _refreshVisibilityByData: () => boolean; } export class BandAxisMixin { @@ -199,6 +200,7 @@ export class BandAxisMixin { } } this.transformScaleDomain(); + this._refreshVisibilityByData(); this.event.emit(ChartEvent.scaleDomainUpdate, { model: this as unknown as IModel }); this.event.emit(ChartEvent.scaleUpdate, { model: this as unknown as IModel, value: 'domain' }); } 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..29b3143cb7 100644 --- a/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts +++ b/packages/vchart/src/component/axis/mixin/linear-axis-mixin.ts @@ -52,6 +52,7 @@ export interface LinearAxisMixin { _orient: IOrientType; _option: IComponentOption; niceLabelFormatter: (value: StringOrNumber) => StringOrNumber; + _refreshVisibilityByData: () => boolean; } export class LinearAxisMixin { @@ -403,6 +404,7 @@ export class LinearAxisMixin { this._updateNiceLabelFormatter(domain); this._domainAfterSpec = this._scale.domain(); + this._refreshVisibilityByData(); this.event.emit(ChartEvent.scaleDomainUpdate, { model: this as any }); this.event.emit(ChartEvent.scaleUpdate, { model: this as any, value: 'domain' }); } diff --git a/packages/vchart/src/component/axis/polar/axis.ts b/packages/vchart/src/component/axis/polar/axis.ts index 9bb5755bf1..6eee0e97ab 100644 --- a/packages/vchart/src/component/axis/polar/axis.ts +++ b/packages/vchart/src/component/axis/polar/axis.ts @@ -201,7 +201,7 @@ export abstract class PolarAxis extends GrammarItem implements IMar this.getVisible() && (!this._skipBeforeLayouted || this.getCompiler().getLayoutState() !== LayoutState.before) ) { + // A mark may lose its product when visibility toggles to false during compile. + // Later data/layout updates can make it visible again without triggering compile, + // so recreate the product lazily before rendering. + if (!this._product) { + this._initProduct(); + } log(`render mark: ${this.getProductId()}, type is ${this.type}`); this.renderInner(); } diff --git a/skills/vchart-development-assistant/references/type-details/ICartesianAxisSpec-Type-Definition.md b/skills/vchart-development-assistant/references/type-details/ICartesianAxisSpec-Type-Definition.md index 219a0b5fef..3e73037f01 100644 --- a/skills/vchart-development-assistant/references/type-details/ICartesianAxisSpec-Type-Definition.md +++ b/skills/vchart-development-assistant/references/type-details/ICartesianAxisSpec-Type-Definition.md @@ -18,6 +18,7 @@ All axis types are based on `ICartesianAxisCommonSpec`: ```typescript type ICartesianAxisCommonSpec = ICommonAxisSpec & { // Core configuration + hideWhenEmpty?: boolean; // Hide axis when bound series collect no axis data grid?: IGrid; // Grid line configuration subGrid?: IGrid; // Sub grid line configuration domainLine?: ICartesianDomainLine; // Axis line configuration @@ -32,6 +33,21 @@ type ICartesianAxisCommonSpec = ICommonAxisSpec & { } & (ICartesianVertical | ICartesianHorizontal | ICartesianZ); ``` +## Runtime Visibility + +`hideWhenEmpty` is a cartesian-axis-only opt-in flag that works together with the existing `visible` property. + +```typescript +type ICartesianAxisCommonSpec = ICommonAxisSpec & { + hideWhenEmpty?: boolean; // @default false +}; +``` + +Behavior notes: +- If `visible` is `false`, the axis stays hidden regardless of `hideWhenEmpty`. +- If `hideWhenEmpty` is `true`, the axis hides when its bound series do not collect any axis data. +- When later data updates make the bound series non-empty, the axis shows again automatically. + ## Orientation Configuration ### Vertical Axis @@ -335,6 +351,7 @@ const linearAxis: ICartesianLinearAxisSpec = { type: 'linear', min: 0, max: 100, + hideWhenEmpty: true, nice: true, grid: { visible: true }, label: { formatMethod: text => `${text}%` }, diff --git a/specs/010-hide-empty-axes/checklists/requirements.md b/specs/010-hide-empty-axes/checklists/requirements.md new file mode 100644 index 0000000000..f78d857b97 --- /dev/null +++ b/specs/010-hide-empty-axes/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Hide Axes For Empty Series + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-20 +**Feature**: [spec.md](/data00/home/lixuefei.1313/github/VChart/specs/010-hide-empty-axes/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 (no implementation details) +- [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 + +- The public option name `hideWhenEmpty` is captured as an assumption because it is explicitly proposed in issue #4490. diff --git a/specs/010-hide-empty-axes/contracts/interface.ts b/specs/010-hide-empty-axes/contracts/interface.ts new file mode 100644 index 0000000000..65d371db44 --- /dev/null +++ b/specs/010-hide-empty-axes/contracts/interface.ts @@ -0,0 +1,7 @@ +export interface ICartesianAxisCommonSpec { + /** + * Hide the axis automatically when its bound series produce no collected axis data. + * Disabled by default. + */ + hideWhenEmpty?: boolean; +} diff --git a/specs/010-hide-empty-axes/data-model.md b/specs/010-hide-empty-axes/data-model.md new file mode 100644 index 0000000000..5960f16ec5 --- /dev/null +++ b/specs/010-hide-empty-axes/data-model.md @@ -0,0 +1,56 @@ +# Data Model: Hide Axes For Empty Series + +## Entities + +### Axis Visibility Spec + +The public axis configuration entry that controls explicit visibility and optional auto-hide behavior. + +| Field | Type | Description | +|-------|------|-------------| +| `visible` | `boolean` | Existing explicit axis visibility control. | +| `hideWhenEmpty` | `boolean` | New opt-in flag that hides the axis when no bound series data can be collected for the axis. | + +### Axis Runtime Visibility State + +The effective axis visibility derived from both explicit spec visibility and runtime data collection state. + +| Field | Type | Description | +|-------|------|-------------| +| `specVisible` | `boolean` | Whether the user explicitly allows the axis to render. | +| `runtimeVisible` | `boolean` | Whether the axis should currently render after evaluating `hideWhenEmpty`. | +| `supportsRuntimeToggle` | `boolean` | Whether axis marks/tick data must stay available for future data-driven visibility changes. | + +### Bound Series Collection Result + +The aggregated result of axis-side collection from the series bound to a single axis. + +| Field | Type | Description | +|-------|------|-------------| +| `seriesIndexes` | `number[]` | Bound series linked to the axis through spec info. | +| `collectedDataCount` | `number` | Number of collected axis statistics entries across bound series. | +| `isEmpty` | `boolean` | True when no bound series contributed collected axis data. | + +## Validation Rules + +- `hideWhenEmpty` is optional and defaults to disabled. +- If `visible === false`, the axis remains hidden regardless of `hideWhenEmpty`. +- If `hideWhenEmpty === true`, the axis is hidden only when the bound series collection result is empty. +- Visibility must be recalculated after data updates and spec updates that affect bound series or axis visibility. + +## State Transitions + +- **Initial Render**: + - Input: axis spec + bound series collection result + - Logic: `runtimeVisible = specVisible && (!hideWhenEmpty || collectedDataCount > 0)` + - Output: axis renders or collapses from layout + +- **Data Update**: + - Input: updated series view statistics + - Logic: recompute bound series collection result, then recompute `runtimeVisible` + - Output: axis hides or reappears without chart remake + +- **Spec Update**: + - Input: changed `visible` or `hideWhenEmpty` + - Logic: refresh runtime visibility and axis layout/render state + - Output: axis reflects the updated visibility rules diff --git a/specs/010-hide-empty-axes/plan.md b/specs/010-hide-empty-axes/plan.md new file mode 100644 index 0000000000..1050070875 --- /dev/null +++ b/specs/010-hide-empty-axes/plan.md @@ -0,0 +1,85 @@ +# Implementation Plan: Hide Axes For Empty Series + +**Branch**: `010-hide-empty-axes` | **Date**: 2026-03-20 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/010-hide-empty-axes/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +Add an opt-in `hideWhenEmpty` axis option for cartesian axes. The axis should hide when its bound series produce no collected axis data, while preserving existing behavior by default. The implementation will separate user-configured visibility from runtime empty-data visibility so the axis can auto-hide and reappear after `updateData` or `updateSpec` without requiring chart remake. + +## Technical Context + + + +**Language/Version**: TypeScript 4.9.5 +**Primary Dependencies**: `@visactor/vchart`, `@visactor/vutils`, `@visactor/vrender-components` +**Storage**: N/A +**Testing**: Jest +**Target Platform**: Web and cross-terminal chart runtimes supported by VChart +**Project Type**: Monorepo / Chart library +**Performance Goals**: No noticeable render or update regression; axis visibility recalculation should stay within existing axis domain update flow +**Constraints**: Cartesian axes only for this scope; must support runtime auto hide/show after data updates; must preserve existing behavior when option is unset +**Scale/Scope**: Public axis spec in `packages/vchart` and `packages/vchart-types`, axis runtime behavior in cartesian axis base flow, targeted unit regression coverage + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **Quality First**: The change is localized to existing axis infrastructure and includes regression tests. +- [x] **UX Driven**: Empty axes are hidden only when explicitly requested, reducing visual noise without breaking defaults. +- [x] **SDD**: Spec, plan, research, data model, contracts, and tasks are captured before implementation. +- [x] **Monorepo Boundaries**: Public type changes stay in `packages/vchart` and `packages/vchart-types`; runtime logic stays in `packages/vchart`. +- [x] **Testing & Regression**: Add unit coverage for initial empty render, mixed axis visibility, and runtime data updates. + +## Project Structure + +### Documentation (this feature) + +```text +specs/010-hide-empty-axes/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── tasks.md +``` + +### Source Code (repository root) + + +```text +packages/vchart/src/component/axis/ +├── base-axis.ts # Common axis runtime behavior +├── interface/spec.ts # Shared public axis spec source +├── cartesian/interface/spec.ts # Cartesian axis spec source +└── cartesian/axis.ts # Cartesian axis layout/update behavior + +packages/vchart-types/types/component/axis/ +├── interface/spec.d.ts # Shared public axis type declarations +└── cartesian/interface/spec.d.ts # Cartesian axis declaration exports + +packages/vchart/__tests__/unit/ +└── component/cartesian/axis/ # Axis runtime regression tests +``` + +**Structure Decision**: Extend the existing cartesian axis runtime instead of introducing a new chart-level visibility mechanism. This keeps the behavior close to axis data collection and minimizes cross-package impact. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| N/A | | | diff --git a/specs/010-hide-empty-axes/quickstart.md b/specs/010-hide-empty-axes/quickstart.md new file mode 100644 index 0000000000..046b197b5f --- /dev/null +++ b/specs/010-hide-empty-axes/quickstart.md @@ -0,0 +1,54 @@ +# Quickstart: Hide Axes For Empty Series + +## Overview + +This feature adds an opt-in `hideWhenEmpty` flag for cartesian axes. When enabled, the axis hides if its bound series do not contribute any collected axis data. When data appears later, the axis shows again automatically. + +## Usage + +### 1. Hide a value axis when its series is empty + +```ts +const spec = { + type: 'line', + data: [{ id: 'line', values: [] }], + axes: [ + { orient: 'left', hideWhenEmpty: true }, + { orient: 'bottom', type: 'band' } + ], + series: [{ type: 'line', dataId: 'line', xField: 'x', yField: 'y' }] +}; +``` + +Expected result: +- The left axis is hidden on first render because no axis data can be collected from the bound series. +- The bottom axis keeps its normal behavior unless it also enables `hideWhenEmpty`. + +### 2. Mixed axes + +```ts +const spec = { + type: 'common', + axes: [ + { id: 'left-empty', orient: 'left', hideWhenEmpty: true }, + { id: 'right-active', orient: 'right', hideWhenEmpty: true }, + { orient: 'bottom', type: 'band' } + ] +}; +``` + +Expected result: +- Only the axis whose bound series collect no data is hidden. +- Axes with collected bound-series data remain visible. + +### 3. Runtime updates + +```ts +vchart.updateData('line', [ + { x: 'Mon', y: 10 }, + { x: 'Tue', y: 20 } +]); +``` + +Expected result: +- A previously hidden axis with `hideWhenEmpty: true` becomes visible again after data update. diff --git a/specs/010-hide-empty-axes/research.md b/specs/010-hide-empty-axes/research.md new file mode 100644 index 0000000000..fcb1330911 --- /dev/null +++ b/specs/010-hide-empty-axes/research.md @@ -0,0 +1,40 @@ +# Research: Hide Axes For Empty Series + +**Feature**: Hide Axes For Empty Series +**Date**: 2026-03-20 + +## Unknowns & Resolutions + +### 1. What should define an "empty" axis? +- **Analysis**: Axis visibility should be derived from the data actually collected by the axis from its bound series, not from chart-level series count or raw spec presence. +- **Decision**: Treat an axis as empty when the axis data collection step cannot collect any series statistics for the bound series. +- **Rationale**: This matches the requested behavior and aligns with existing axis domain computation, which already filters out empty `viewData`. +- **Alternatives Considered**: + - Use chart-level "all series empty": rejected because multi-axis charts would over-hide unrelated axes. + - Use raw series array existence: rejected because a series can exist in spec but still have no collected axis data after transforms or filtering. + +### 2. How should runtime auto hide/show work? +- **Analysis**: Current axis `visible` is effectively a creation-time decision. Hiding by only mutating spec visibility would require remake to show the axis again after data updates. +- **Decision**: Separate explicit spec visibility from runtime empty-data visibility inside the axis component. Keep axis marks/tick data available when `hideWhenEmpty` is enabled, and update effective visibility as data changes. +- **Rationale**: This supports `updateData` and `updateSpec` driven reappearance without remaking the whole chart. +- **Alternatives Considered**: + - Recompute chart spec and remake chart on every visibility change: rejected because it is heavier and less aligned with the existing axis update flow. + - Hide only rendered graphics while keeping layout size: rejected because the axis would still occupy space, which does not solve the empty-axis problem. + +### 3. What is the correct implementation surface for this issue? +- **Analysis**: The request scope was clarified to cartesian axes only. Public API changes still need to be reflected in both source specs and published declaration files. +- **Decision**: Add `hideWhenEmpty?: boolean` to the common axis spec used by cartesian axes, then implement runtime visibility checks in the axis base/cartesian axis pipeline. +- **Rationale**: This exposes one consistent API while keeping implementation scope limited to cartesian axes. + +## Technology Choices + +- **Public API**: `hideWhenEmpty?: boolean` on axis spec. +- **Runtime Visibility Source**: Axis data collection result from bound series. +- **Behavior Scope**: Cartesian axes only in this change. +- **Validation**: Unit tests covering empty initial render, mixed-axis cases, and runtime updates. + +## Best Practices + +- Preserve backward compatibility by defaulting the new option to disabled. +- Reuse the existing axis data collection flow instead of introducing separate emptiness scans. +- Avoid tying runtime visibility to only raw data presence; use collected series statistics so filtered or non-renderable data is handled consistently. diff --git a/specs/010-hide-empty-axes/spec.md b/specs/010-hide-empty-axes/spec.md new file mode 100644 index 0000000000..f768cf873a --- /dev/null +++ b/specs/010-hide-empty-axes/spec.md @@ -0,0 +1,89 @@ +# Feature Specification: Hide Axes For Empty Series + +**Feature Branch**: `010-hide-empty-axes` +**Created**: 2026-03-20 +**Status**: Draft +**Input**: User description: "Add hideWhenEmpty axis option to hide axes when series is empty" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Suppress Empty Axes (Priority: P1) + +As a chart author, I want to opt in to hiding an axis when every series bound to that axis has no visible data, so charts do not show empty scaffolding that suggests missing content. + +**Why this priority**: This is the core request from issue #4490 and provides immediate visual clarity in empty-state charts. + +**Independent Test**: Configure a chart with one or more axes and no usable series data, enable the new empty-axis behavior, and verify the affected axes do not render while the rest of the chart remains stable. + +**Acceptance Scenarios**: + +1. **Given** a chart axis with the empty-axis behavior enabled and all bound series have no usable data, **When** the chart renders, **Then** that axis is hidden. +2. **Given** a chart axis with the empty-axis behavior enabled and at least one bound series has usable data, **When** the chart renders, **Then** that axis remains visible. + +--- + +### User Story 2 - Preserve Existing Defaults (Priority: P2) + +As an existing VChart user, I want charts to keep their current axis visibility behavior unless I explicitly opt in, so upgrading does not change existing layouts unexpectedly. + +**Why this priority**: Backward compatibility is required for a safe feature release in the core chart library. + +**Independent Test**: Render an existing chart configuration without the new option and verify axis visibility matches current behavior for both empty and non-empty series. + +**Acceptance Scenarios**: + +1. **Given** a chart axis without the empty-axis behavior enabled, **When** all bound series are empty, **Then** the axis visibility matches the current default behavior. + +--- + +### User Story 3 - Mixed Axis Visibility (Priority: P3) + +As a chart author using multiple axes, I want only axes whose own bound series are empty to be hidden, so charts can still show the axes that have valid data. + +**Why this priority**: Multi-axis charts are common in VChart, and partial hiding is needed to avoid over-hiding unrelated axes. + +**Independent Test**: Render a chart with multiple axes where one axis has empty bound series and another has non-empty bound series, enable the new behavior, and verify only the empty axis is hidden. + +**Acceptance Scenarios**: + +1. **Given** a chart with multiple axes and the empty-axis behavior enabled per axis, **When** only some axes have bound series with usable data, **Then** only the axes whose bound series are empty are hidden. + +### Edge Cases + +- When an axis is configured with the empty-axis behavior but the chart has no series bound to that axis, the axis should be treated as empty and hidden. +- When series exist but all values are filtered out, invalid, or otherwise unavailable for rendering, the axis should be treated as empty. +- When one direction of axes is hidden in a multi-axis chart, layout and remaining axes should still render without overlap or misalignment. +- When the chart updates from empty to non-empty data, the axis should reappear on the next render/update cycle. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST provide an opt-in axis configuration that hides an axis when all series bound to that axis are empty at render time. +- **FR-002**: The system MUST keep current axis visibility behavior unchanged when the new empty-axis configuration is not enabled. +- **FR-003**: The system MUST evaluate emptiness per axis, based on the series bound to that specific axis, rather than using chart-wide emptiness. +- **FR-004**: The system MUST continue rendering any axis that has at least one bound series with usable data, even if other axes in the same chart are hidden. +- **FR-005**: The system MUST apply the empty-axis visibility rule consistently during initial render and after data or spec updates. +- **FR-006**: The system MUST make the new axis configuration available through the public chart specification for axes. +- **FR-007**: The system MUST preserve chart stability when axes are hidden, including layout, region sizing, and rendering of remaining components. + +### Key Entities *(include if feature involves data)* + +- **Axis Visibility Rule**: An axis-level display setting that determines whether the axis should stay visible when its bound series are empty. +- **Bound Series Set**: The collection of series associated with a specific axis and used to determine whether that axis has usable data. +- **Usable Data State**: The render-time status describing whether a series contributes visible data points for its bound axis. + +## Assumptions + +- The feature applies to chart axes exposed through the public VChart axis specification. +- "Empty" includes the absence of renderable series data after any chart preprocessing or filtering. +- The requested API name is `hideWhenEmpty`, as proposed in issue #4490. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In empty-data charts with the new axis option enabled, affected axes are hidden in 100% of verified regression scenarios. +- **SC-002**: In non-empty charts, enabling the new option does not hide any axis that still has at least one bound series with usable data in 100% of verified regression scenarios. +- **SC-003**: Existing chart specifications that do not enable the new option show no axis-visibility regression in the targeted validation scenarios for this release. +- **SC-004**: Multi-axis validation scenarios correctly hide only the empty axes and preserve the visible axes in 100% of targeted regression checks. diff --git a/specs/010-hide-empty-axes/tasks.md b/specs/010-hide-empty-axes/tasks.md new file mode 100644 index 0000000000..e90ea93ea8 --- /dev/null +++ b/specs/010-hide-empty-axes/tasks.md @@ -0,0 +1,43 @@ +# Tasks: Hide Axes For Empty Series + +## Phase 1: Setup + +- [x] T001 Review existing cartesian axis visibility and data collection flow in `packages/vchart/src/component/axis/base-axis.ts` and `packages/vchart/src/component/axis/cartesian/axis.ts` + +## Phase 2: Foundational + +- [x] T002 Add the public `hideWhenEmpty` axis option in `packages/vchart/src/component/axis/interface/spec.ts` +- [x] T003 Add the declaration for `hideWhenEmpty` in `packages/vchart-types/types/component/axis/interface/spec.d.ts` + +## Phase 3: User Story 1 - Suppress Empty Axes + +**Goal**: Hide a cartesian axis when its bound series do not produce any collected axis data. + +**Independent Test**: Render a chart with `hideWhenEmpty: true` on a cartesian axis and empty bound series data, then verify the axis does not render or consume layout space. + +- [x] T004 [US1] Implement runtime empty-axis visibility evaluation in `packages/vchart/src/component/axis/base-axis.ts` +- [x] T005 [US1] Update cartesian axis layout/render flow to honor runtime empty-axis visibility in `packages/vchart/src/component/axis/cartesian/axis.ts` +- [x] T006 [US1] Add regression coverage for initial empty-axis hiding in `packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts` + +## Phase 4: User Story 2 - Preserve Existing Defaults + +**Goal**: Keep current behavior unchanged when `hideWhenEmpty` is not enabled. + +**Independent Test**: Render an existing chart without `hideWhenEmpty` and verify the axis behavior matches current output. + +- [x] T007 [US2] Ensure default axis behavior remains unchanged when `hideWhenEmpty` is unset in `packages/vchart/src/component/axis/base-axis.ts` +- [x] T008 [US2] Add regression coverage for unchanged default behavior in `packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts` + +## Phase 5: User Story 3 - Mixed Axis Visibility And Runtime Updates + +**Goal**: Hide only empty axes and restore visibility automatically when data appears later. + +**Independent Test**: Render a chart with multiple cartesian axes, confirm only empty axes are hidden, then update data and verify hidden axes reappear automatically. + +- [x] T009 [US3] Support runtime axis visibility toggling after data/spec updates in `packages/vchart/src/component/axis/base-axis.ts` +- [x] T010 [US3] [P] Add mixed-axis and runtime update coverage in `packages/vchart/__tests__/unit/component/cartesian/axis/hide-when-empty.test.ts` + +## Final Phase: Polish & Cross-Cutting Concerns + +- [ ] T011 Run targeted unit tests for axis visibility regressions +- [x] T012 Mark completed tasks and confirm implementation matches `specs/010-hide-empty-axes/spec.md`