diff --git a/common/changes/@visactor/vchart/fix-legend-filter-after-updatespec_20260421.json b/common/changes/@visactor/vchart/fix-legend-filter-after-updatespec_20260421.json new file mode 100644 index 0000000000..05cfdb91f7 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-legend-filter-after-updatespec_20260421.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: preserve discrete legend filtering after updateSpec when `legends.data` is a callback (Issue #4566)", + "type": "patch" + } + ], + "packageName": "@visactor/vchart", + "email": "openai@example.com" +} diff --git a/packages/vchart/__tests__/unit/core/vchart-event.test.ts b/packages/vchart/__tests__/unit/core/vchart-event.test.ts index 993e3c13d8..536b33e0c5 100644 --- a/packages/vchart/__tests__/unit/core/vchart-event.test.ts +++ b/packages/vchart/__tests__/unit/core/vchart-event.test.ts @@ -193,3 +193,133 @@ describe('vchart event test', () => { expect(handleTooltipRelease).toBeCalledTimes(1); }); }); + +describe('discrete legend with custom data after updateSpec', () => { + let container: HTMLElement; + let dom: HTMLElement; + let vchart: VChart; + + const data1 = [ + { x: '周一', type: '早餐', y: 15 }, + { x: '周一', type: '午餐', y: 25 }, + { x: '周二', type: '早餐', y: 12 }, + { x: '周二', type: '午餐', y: 30 } + ]; + + const data2 = [ + { x: '周一', type: '早餐2', y: 15 }, + { x: '周一', type: '午餐2', y: 25 }, + { x: '周二', type: '早餐2', y: 12 }, + { x: '周二', type: '午餐2', y: 30 }, + { x: '周一', type: '饮料2', y: 22 }, + { x: '周二', type: '饮料2', y: 43 } + ]; + + beforeEach(() => { + container = createDiv(); + dom = createDiv(container); + dom.id = 'container'; + container.style.position = 'fixed'; + container.style.width = '500px'; + container.style.height = '500px'; + container.style.top = '0px'; + container.style.left = '0px'; + + const spec = { + type: 'common', + animation: false, + data: [ + { + id: 'id0', + values: data1 + } + ], + seriesField: 'type', + legends: [ + { + orient: 'right', + visible: true, + data: (items: any[]) => items + } + ], + series: [ + { + type: 'line', + id: 'line', + dataIndex: 0, + xField: ['x'], + yField: 'y' + } + ], + axes: [ + { orient: 'left', seriesIndex: [0] }, + { orient: 'bottom', label: { visible: true }, type: 'band' } + ] + } as any; + + vchart = new VChart(spec, { + dom, + animation: false + }); + vchart.renderSync(); + }); + + afterEach(() => { + vchart.release(); + removeDom(container); + }); + + it('should keep legend filtering working after updateSpecSync without remake', () => { + const nextSpec = { + ...vchart.getSpec(), + data: [ + { + id: 'id0', + values: data2 + } + ], + legends: [ + { + orient: 'right', + visible: true, + data: (items: any[]) => items + } + ] + }; + + vchart.updateSpecSync(nextSpec, false, undefined, { + change: false, + reMake: false + }); + + const legend = vchart.getComponents().find(com => com.type === 'discreteLegend') as any; + const legendComponent = legend.getVRenderComponents()[0] as any; + const legendItem = legendComponent._itemsContainer.getChildren()[0]; + const series = vchart.getChart()?.getAllSeries()[0]; + const vrenderLegendClickSpy = jest.fn(); + + legendComponent.addEventListener('legendItemClick', vrenderLegendClickSpy); + + expect(legend.getLegendDefaultData()).toEqual(['早餐2', '午餐2', '饮料2']); + expect(series?.getViewData()?.latestData.map((datum: any) => datum.type)).toEqual([ + '早餐2', + '午餐2', + '早餐2', + '午餐2', + '饮料2', + '饮料2' + ]); + + legendComponent._onClick({ target: legendItem }); + + expect(vrenderLegendClickSpy).toHaveBeenCalledTimes(1); + expect(vrenderLegendClickSpy.mock.calls[0]?.[0]?.detail?.currentSelected).toEqual(['午餐2', '饮料2']); + expect(legend.getSelectedData()).toEqual(['午餐2', '饮料2']); + expect(series?.getViewData()?.latestData.map((datum: any) => datum.type)).toEqual([ + '午餐2', + '午餐2', + '饮料2', + '饮料2' + ]); + }); +}); diff --git a/packages/vchart/src/component/legend/base-legend.ts b/packages/vchart/src/component/legend/base-legend.ts index 8a289e532d..95dcf7eb50 100644 --- a/packages/vchart/src/component/legend/base-legend.ts +++ b/packages/vchart/src/component/legend/base-legend.ts @@ -304,6 +304,7 @@ export abstract class BaseLegend extends BaseCompon clear(): void { super.clear(); + this._legendComponent = null; this._cacheAttrs = null; this._preSelectedData = null; } diff --git a/specs/011-fix-discrete-legend-filter-after-update-spec/plan.md b/specs/011-fix-discrete-legend-filter-after-update-spec/plan.md new file mode 100644 index 0000000000..3321da7e89 --- /dev/null +++ b/specs/011-fix-discrete-legend-filter-after-update-spec/plan.md @@ -0,0 +1,44 @@ +# Implementation Plan: Fix Discrete Legend Filtering After updateSpec + +**Branch**: `011-fix-discrete-legend-filter-after-update-spec` | **Date**: 2026-04-21 | **Spec**: [/Users/bytedance/Documents/GitHub/VChart3/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md](/Users/bytedance/Documents/GitHub/VChart3/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md) +**Input**: Feature specification from `/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md` + +## Summary + +Fix the discrete legend regression reported in issue #4566 by clearing the stale vrender legend instance during `BaseLegend.clear()`. The root cause is that the `reCompile` path releases the old legend component but leaves `_legendComponent` populated, so the next layout pass reuses a dead instance and skips the event-binding path that connects legend clicks back into VChart filtering. Add a focused regression test that reproduces `updateSpecSync(..., { reMake: false })` with `legends.data: items => items`. + +## Technical Context + +**Language/Version**: TypeScript 4.9.x +**Primary Dependencies**: `@visactor/vrender-components`, `@visactor/vutils`, `@visactor/vdataset` +**Testing**: Jest 26 unit tests under `packages/vchart/__tests__`, plus `tsc --noEmit` +**Target Platform**: Browser and cross-platform chart runtime +**Project Type**: Monorepo charting library package (`packages/vchart`) +**Constraints**: Keep the fix scoped to legend lifecycle cleanup; avoid changing discrete legend filtering semantics; preserve existing `reMake: true` behavior + +## Constitution Check + +- Pass: The change is narrowly scoped to the documented bug path. +- Pass: The fix reuses the existing legend rebuild path instead of adding a new special-case update branch. +- Pass: Regression coverage is added for the exact user-visible interaction. + +## Project Structure + +```text +specs/011-fix-discrete-legend-filter-after-update-spec/ +├── plan.md +├── spec.md +└── tasks.md +``` + +```text +packages/vchart/ +├── src/component/legend/base-legend.ts +└── __tests__/unit/core/vchart-event.test.ts +``` + +## Implementation Strategy + +1. Reproduce the regression with a focused event test that uses a discrete legend `data` callback and `updateSpecSync(..., { reMake: false })`. +2. Fix legend lifecycle cleanup by nulling `_legendComponent` after releasing the old vrender component so the next compile path recreates and rebinds it. +3. Run the focused Jest test file and TypeScript compile check to verify the regression and avoid collateral breakage. diff --git a/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md b/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md new file mode 100644 index 0000000000..57b5a8aeb6 --- /dev/null +++ b/specs/011-fix-discrete-legend-filter-after-update-spec/spec.md @@ -0,0 +1,43 @@ +# Feature Specification: Fix Discrete Legend Filtering After updateSpec + +**Feature Branch**: `011-fix-discrete-legend-filter-after-update-spec` +**Created**: 2026-04-21 +**Status**: Draft +**Input**: User description: "Fix VChart issue #4566: after `updateSpec`, clicking a discrete legend with a custom `data` callback no longer triggers legend filtering unless `reMake` is true" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Keep Discrete Legend Filtering Active After updateSpec (Priority: P1) + +As a chart author using a discrete legend with a custom `data` callback, I want legend clicks to keep filtering the latest series data after `updateSpec(..., { reMake: false })` so that I can update chart data without remaking the chart and losing legend interaction. + +**Why this priority**: The reported bug breaks the primary discrete-legend interaction path immediately after a common update flow and regresses behavior that previously worked in 1.x. + +**Independent Test**: Render a chart whose legend uses `data: items => items`, call `updateSpecSync` with new legend values and `reMake: false`, click a legend item, and verify both the legend selected state and filtered series view data update to the new legend domain. + +**Acceptance Scenarios**: + +1. **Given** a discrete legend configured with a custom `data` callback, **When** `updateSpecSync` updates the backing data with `reMake: false`, **Then** the legend still exposes the latest legend items after the update. +2. **Given** the same chart after `updateSpecSync`, **When** the user clicks a legend item, **Then** VChart updates the legend selected data and re-filters series view data according to the clicked legend item. + +### Edge Cases + +- The `reCompile` path must rebuild or rebind the underlying vrender legend component after cleanup so event handlers are not lost. +- Existing discrete legends without a custom `data` callback must keep their current interaction behavior. +- `reMake: true` behavior must remain unchanged. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: After `updateSpec` on a discrete legend with a custom `data` callback, the legend component MUST use the updated legend item set when `reMake` is false. +- **FR-002**: After the same update path, clicking a legend item MUST update `selectedData` and trigger the normal series data filtering flow. +- **FR-003**: Legend cleanup on the `reCompile` path MUST NOT retain a released vrender legend component instance. +- **FR-004**: Existing discrete legend behavior outside this update path MUST remain backward compatible. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In the issue #4566 reproduction, `updateSpec(..., { reMake: false })` no longer disables legend filtering when `legends.data` is a function. +- **SC-002**: A focused automated regression test proves legend selection and series filtering still work after the update. diff --git a/specs/011-fix-discrete-legend-filter-after-update-spec/tasks.md b/specs/011-fix-discrete-legend-filter-after-update-spec/tasks.md new file mode 100644 index 0000000000..4fd90fb642 --- /dev/null +++ b/specs/011-fix-discrete-legend-filter-after-update-spec/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Discrete Legend Filtering After updateSpec + +**Input**: Design documents from `/specs/011-fix-discrete-legend-filter-after-update-spec/` +**Prerequisites**: plan.md, spec.md + +**Tests**: Include focused regression coverage for discrete legend interaction after `updateSpecSync(..., { reMake: false })`. + +## Phase 1: Setup + +- [x] T001 Inspect discrete legend update, cleanup, and recompile lifecycle around `BaseLegend` and `DiscreteLegend` + +## Phase 2: User Story 1 - Keep Discrete Legend Filtering Active After updateSpec (Priority: P1) + +**Goal**: Preserve legend click filtering after `updateSpec` when the legend uses a custom `data` callback and the chart avoids remake + +### Tests for User Story 1 + +- [x] T002 [US1] Add a regression test for `updateSpecSync(..., { reMake: false })` with `legends.data: items => items` in `packages/vchart/__tests__/unit/core/vchart-event.test.ts` + +### Implementation for User Story 1 + +- [x] T003 [US1] Clear the stale vrender legend instance in `packages/vchart/src/component/legend/base-legend.ts` so legend events are rebound on recompile + +## Phase 3: Verification + +- [x] T004 Run the focused Jest test file for `packages/vchart/__tests__/unit/core/vchart-event.test.ts` +- [x] T005 Run `npm run compile` in `packages/vchart` +- [x] T006 Update task tracking after implementation