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,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"
}
152 changes: 152 additions & 0 deletions packages/vchart/__tests__/unit/interaction/element-select.test.ts
Original file line number Diff line number Diff line change
@@ -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<IPieSeriesSpec> {
protected _computeLayoutRadius() {
return 100;
}
}

const dummyEvent = {
on: (): void => undefined,
off: (): void => undefined,
emit: (): void => undefined
};

function createPieSeries(spec: Partial<IPieSeriesSpec> = {}) {
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<IPieSeriesSpec>) {
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<PieSeries<IPieSeriesSpec>['getInteractionTriggers']>[number]['trigger']) {
return (trigger as { isMultiple?: boolean }).isMultiple;
}

function startSelects(selectTriggers: ReturnType<typeof getSelectTriggers>, 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);
});
});
8 changes: 8 additions & 0 deletions packages/vchart/src/series/base/base-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,14 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> 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);
Comment on lines +960 to +963

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 保留自定义交互作用范围外的默认选中

这里只要存在 element-select 就删除整个默认 select,忽略了 markNames / markIds 的作用范围。例如柱线组合图在图表级配置 interactions: [{ type: 'element-select', markNames: ['bar'], isMultiple: true }]:该配置会传给两个系列,折线系列虽然没有匹配的 bar 图元,也会先删除默认 select,随后又因匹配结果为空而不注册自定义交互,最终折线点完全无法选中。单个折线图配置 markNames: ['line'] 时,未被覆盖的 point 同样失去选中能力。上述两个场景均已验证基线通过、PR 失败。请先解析实际匹配的图元,仅处理重叠范围内的冲突,保留其他图元的默认选中,并补充回归测试。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 替换默认选中时保留 selected_reverse 状态

当系列配置 state.selected_reverse,同时使用文档形式的 interactions: [{ type: 'element-select', isMultiple: true }](未显式填写 reverseState)时,删除默认 select 后,未选中的图元不再进入 selected_reverse,原本的淡化等样式消失。原因是 _defaultSelectConfig 为默认触发器设置了 reverseState: STATE_SELECTED_REVERSE,而自定义触发器直接使用原始 spec;ElementSelect.defaultOptions 仅补 state 和 trigger,Interaction.updateStates 收到空的 reverseState 时只执行选中分支。此前默认触发器会设置未选中图元的反选状态;且 IElementSelectSpec 的注释明确允许用 state.selected_reverse 配置非激活图元。建议在避免双触发器冲突的同时为未显式覆盖该字段的自定义 select 保留默认反选状态,并增加该样式的真实状态回归测试。此问题与现有讨论所述 markNames / markIds 导致图元无法选中的作用范围问题不同:这里即使自定义交互覆盖全部图元,反选视觉状态也会丢失。

}
}

if (interactions && interactions.length) {
interactions.forEach(interaction => {
const marks: IMark[] = filterMarksOfInteraction(interaction, this.getMarks());
Expand Down
Loading