diff --git a/common/changes/@visactor/vchart/fix-interactions-element-select-default-4563_2026-09-20.json b/common/changes/@visactor/vchart/fix-interactions-element-select-default-4563_2026-09-20.json new file mode 100644 index 0000000000..635be6953c --- /dev/null +++ b/common/changes/@visactor/vchart/fix-interactions-element-select-default-4563_2026-09-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: skip default single-select when spec.interactions already registers element-select, so isMultiple multi-select works", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/packages/vchart/__tests__/unit/interaction/element-select.test.ts b/packages/vchart/__tests__/unit/interaction/element-select.test.ts new file mode 100644 index 0000000000..a088482d69 --- /dev/null +++ b/packages/vchart/__tests__/unit/interaction/element-select.test.ts @@ -0,0 +1,152 @@ +import { DataSet, DataView } from '@visactor/vdataset'; +import type { ISeriesOption } from '../../../src/series/interface'; +import { PieSeries, registerPieSeries } from '../../../src/series/pie/pie'; +import type { IPieSeriesSpec } from '../../../src/series/pie/interface'; +import { ElementSelect } from '../../../src/interaction/triggers/element-select'; +import { Interaction } from '../../../src/interaction/interaction'; +import { TRIGGER_TYPE_ENUM } from '../../../src/interaction/triggers/enum'; +import type { IMark } from '../../../src/mark/interface'; +import type { IMarkGraphic } from '../../../src/mark/interface/common'; +import { initChartDataSet, seriesOption } from '../../util/context'; + +registerPieSeries(); + +const dataSet = new DataSet(); +initChartDataSet(dataSet); + +class TestPieSeries extends PieSeries { + protected _computeLayoutRadius() { + return 100; + } +} + +const dummyEvent = { + on: (): void => undefined, + off: (): void => undefined, + emit: (): void => undefined +}; + +function createPieSeries(spec: Partial = {}) { + const dataView = new DataView(dataSet); + dataView.parse( + [ + { type: 'oxygen', value: 46.6 }, + { type: 'silicon', value: 27.72 }, + { type: 'aluminum', value: 8.13 } + ], + { + type: 'array' + } + ); + + const series = new TestPieSeries( + { + type: 'pie', + data: dataView, + valueField: 'value', + categoryField: 'type', + ...spec + }, + seriesOption({ dataSet }) as ISeriesOption + ); + series.created(); + series.init({}); + return series; +} + +function getSelectTriggers(series: PieSeries) { + return series.getInteractionTriggers().filter(item => item.trigger.type === TRIGGER_TYPE_ENUM.ELEMENT_SELECT); +} + +function createGraphic(mark: IMark): IMarkGraphic { + const graphic = { + context: { markId: mark.id }, + currentStates: [] as string[], + hasState(state: string) { + return graphic.currentStates.includes(state); + }, + setStates(states?: string[] | null) { + graphic.currentStates = states ?? []; + } + }; + return graphic as unknown as IMarkGraphic; +} + +function getIsMultiple(trigger: ReturnType['getInteractionTriggers']>[number]['trigger']) { + return (trigger as { isMultiple?: boolean }).isMultiple; +} + +function startSelects(selectTriggers: ReturnType, graphics: IMarkGraphic[]) { + const instances = selectTriggers.map(({ trigger, marks }) => { + const interaction = new Interaction(); + const instance = new ElementSelect({ + ...(trigger as any), + marks, + event: dummyEvent, + interaction + }); + return { interaction, instance }; + }); + + graphics.forEach(graphic => { + instances.forEach(({ instance }) => instance.start(graphic)); + }); + + return instances.map(({ interaction, instance }) => interaction.getStatedGraphics(instance) ?? []); +} + +describe('element-select vs default select', () => { + test('interactions isMultiple accumulates without default single-select fighting it', () => { + const series = createPieSeries({ + interactions: [ + { + type: 'element-select', + isMultiple: true + } + ] + }); + + const triggers = series.getInteractionTriggers(); + const selectTriggers = getSelectTriggers(series); + + expect(selectTriggers).toHaveLength(1); + expect(getIsMultiple(selectTriggers[0].trigger)).toBe(true); + expect(triggers.some(item => item.trigger.type === TRIGGER_TYPE_ENUM.DIMENSION_HOVER)).toBe(true); + expect(triggers.some(item => item.trigger.type === TRIGGER_TYPE_ENUM.ELEMENT_HIGHLIGHT)).toBe(true); + + const mark = selectTriggers[0].marks[0]; + const statedLists = startSelects(selectTriggers, [createGraphic(mark), createGraphic(mark)]); + + expect(statedLists.some(graphics => graphics.length > 1)).toBe(true); + }); + + test('default select without interactions stays single-select', () => { + const series = createPieSeries(); + const selectTriggers = getSelectTriggers(series); + + expect(selectTriggers).toHaveLength(1); + expect(getIsMultiple(selectTriggers[0].trigger)).toBe(false); + + const mark = selectTriggers[0].marks[0]; + const statedLists = startSelects(selectTriggers, [createGraphic(mark), createGraphic(mark)]); + + expect(statedLists[0]).toHaveLength(1); + }); + + test('select.mode multiple still accumulates without interactions', () => { + const series = createPieSeries({ + select: { + mode: 'multiple' + } + }); + const selectTriggers = getSelectTriggers(series); + + expect(selectTriggers).toHaveLength(1); + expect(getIsMultiple(selectTriggers[0].trigger)).toBe(true); + + const mark = selectTriggers[0].marks[0]; + const statedLists = startSelects(selectTriggers, [createGraphic(mark), createGraphic(mark)]); + + expect(statedLists[0].length).toBeGreaterThan(1); + }); +}); diff --git a/packages/vchart/src/series/base/base-series.ts b/packages/vchart/src/series/base/base-series.ts index 0e0b816711..35c3b19025 100644 --- a/packages/vchart/src/series/base/base-series.ts +++ b/packages/vchart/src/series/base/base-series.ts @@ -956,6 +956,14 @@ export abstract class BaseSeries extends BaseModel imp const { interactions } = this._spec; const res = this._parseDefaultInteractionConfig(mainMarks); + // Custom element-select is not isEqual to default select, so both would stay live and fight. + if (interactions?.some(interaction => interaction.type === TRIGGER_TYPE_ENUM.ELEMENT_SELECT)) { + const defaultSelectIndex = res.findIndex(item => item.trigger.type === TRIGGER_TYPE_ENUM.ELEMENT_SELECT); + if (defaultSelectIndex >= 0) { + res.splice(defaultSelectIndex, 1); + } + } + if (interactions && interactions.length) { interactions.forEach(interaction => { const marks: IMark[] = filterMarksOfInteraction(interaction, this.getMarks());