Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
130 changes: 130 additions & 0 deletions packages/vchart/__tests__/unit/core/vchart-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]);
});
});
1 change: 1 addition & 0 deletions packages/vchart/src/component/legend/base-legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export abstract class BaseLegend<T extends ILegendCommonSpec> extends BaseCompon

clear(): void {
super.clear();
this._legendComponent = null;
this._cacheAttrs = null;
this._preSelectedData = null;
}
Expand Down
44 changes: 44 additions & 0 deletions specs/011-fix-discrete-legend-filter-after-update-spec/plan.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions specs/011-fix-discrete-legend-filter-after-update-spec/spec.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions specs/011-fix-discrete-legend-filter-after-update-spec/tasks.md
Original file line number Diff line number Diff line change
@@ -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
Loading