Skip to content
Closed
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
8 changes: 8 additions & 0 deletions docs/assets/option/en/common/custom-mark-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ From version **1.9.0** onwards

When the type of custom mark is `component`, it can be used to set the specific component type

#${prefix} syncState(boolean) = false

From version **2.0.22** onwards

Whether to synchronize the interactive states (e.g., `hover`, `select`) from the corresponding primary mark. When enabled, the extensionMark will automatically follow state changes of the primary mark that shares the same data key. Users need to configure the corresponding `state` styles to take effect.

> Note: State synchronization only works when the extension mark and the primary mark are bound to the same datum (i.e., they share the same `context.key`).

{{ use: common-mark(
prefix = ${prefix}
) }}
Expand Down
8 changes: 8 additions & 0 deletions docs/assets/option/zh/common/custom-mark-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@

当自定义 mark 的类型为`component`时,可以用于设置具体的组件类型

#${prefix} syncState(boolean) = false

自 **2.0.22** 版本开始支持

是否同步主图元的交互状态(如 `hover`、`select` 等)。开启后,extensionMark 会自动跟随对应主图元的状态变化,用户需自行配置对应的 `state` 样式使其生效。

> 注意:仅当扩展图元与主图元绑定同一条 datum(即 `context.key` 相同)时,状态同步才会生效。

{{ use: common-mark(
prefix = ${prefix}
) }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* extensionMark syncState 功能验证页面
*
* 验证场景:
* 1. Hover bar → extensionMark(圆点 + 文字)同步进入 highlight,其余 blur
* 2. Click bar → extensionMark 同步 selected 状态
* 3. Legend 筛选 → extensionMark 跟随 highlight/blur
* 4. 数据更新后重新绑定是否正常
*/
import { default as VChart } from '../../../../src/index';

const CONTAINER_ID = 'chart';

const run = () => {
const data = [
{ category: 'A', value: 80, group: 'February' },
{ category: 'B', value: 120, group: 'February' },
{ category: 'C', value: 60, group: 'February' },
{ category: 'D', value: 150, group: 'February' },
{ category: 'A', value: 90, group: 'March' },
{ category: 'B', value: 100, group: 'March' },
{ category: 'C', value: 110, group: 'March' },
{ category: 'D', value: 70, group: 'March' }
];

const spec: any = {
type: 'bar',
data: [{ id: 'barData', values: data }],
xField: 'category',
yField: 'value',
seriesField: 'group',

bar: {
state: {
highlight: {
stroke: '#000',
lineWidth: 2
},
blur: {
fillOpacity: 0.2
},
selected: {
stroke: 'red',
lineWidth: 3
}
}
},

// ===== extensionMark:在每个 bar 顶部画一个圆点,syncState = true =====
extensionMark: [
{
type: 'symbol',
dataId: 'barData',
name: 'topDot',
syncState: true,
style: {
fill: (datum: any) => (datum.group === 'February' ? '#1664FF' : '#1AC6FF'),
symbolType: 'circle',
size: 12,
x: (datum: any, ctx: any) => {
return (
ctx.valueToX([datum.category]) +
ctx.xBandwidth() / 4 +
(datum.group === 'March' ? ctx.xBandwidth() / 2 : 0)
);
},
y: (datum: any, ctx: any) => {
return ctx.valueToY([datum.value]) - 15;
}
},
state: {
highlight: {
fill: 'orange',
size: 20,
stroke: '#000',
lineWidth: 2
},
blur: {
fillOpacity: 0.15,
size: 8
},
selected: {
fill: 'red',
size: 22,
outerBorder: {
distance: 3,
lineWidth: 2,
stroke: 'red'
}
}
}
},
{
type: 'text',
dataId: 'barData',
name: 'topLabel',
syncState: true,
style: {
text: (datum: any) => `${datum.value}`,
fontSize: 11,
fill: '#333',
textAlign: 'center',
textBaseline: 'bottom',
x: (datum: any, ctx: any) => {
return (
ctx.valueToX([datum.category]) +
ctx.xBandwidth() / 4 +
(datum.group === 'March' ? ctx.xBandwidth() / 2 : 0)
);
},
y: (datum: any, ctx: any) => {
return ctx.valueToY([datum.value]) - 26;
}
},
state: {
highlight: {
fill: 'orange',
fontSize: 16,
fontWeight: 'bold'
},
blur: {
fillOpacity: 0.1
},
selected: {
fill: 'red',
fontSize: 14,
fontWeight: 'bold'
}
}
}
],

// 交互配置
interaction: {
hover: {
enable: true
},
select: {
enable: true
}
},

legends: {
visible: true,
orient: 'top',
interactive: true
},

tooltip: {
visible: true
},

title: {
visible: true,
text: 'extensionMark syncState 验证',
subtext: 'Hover / Click bar → 观察圆点和数字是否同步状态'
}
};

const vchart = new VChart(spec, { dom: CONTAINER_ID });
vchart.renderSync();

// ===== 调试辅助:暴露到 window 方便 DevTools 检查 =====
(window as any).__vchart__ = vchart;

// ===== 数据更新按钮:验证数据更新后 syncState 重新绑定 =====
const btn = document.createElement('button');
btn.textContent = '更新数据(验证重新绑定)';
btn.style.cssText = 'margin: 4px 8px; padding: 4px 12px; cursor: pointer;';
btn.onclick = () => {
const newData = data.map(d => ({
...d,
value: Math.round(d.value * (0.6 + Math.random() * 0.8))
}));
vchart.updateData('barData', newData);
};
document.getElementById('controlPanel')?.appendChild(btn);

// ===== setHovered API 按钮:验证 API 触发路径 =====
const btnApi = document.createElement('button');
btnApi.textContent = 'API: setHovered(A-February)';
btnApi.style.cssText = 'margin: 4px 8px; padding: 4px 12px; cursor: pointer;';
btnApi.onclick = () => {
vchart.setHovered({
category: 'A',
group: 'February'
});
};
document.getElementById('controlPanel')?.appendChild(btnApi);

// ===== clearHovered API 按钮 =====
const btnClear = document.createElement('button');
btnClear.textContent = 'API: clearHovered';
btnClear.style.cssText = 'margin: 4px 8px; padding: 4px 12px; cursor: pointer;';
btnClear.onclick = () => {
vchart.setHovered(null);
};
document.getElementById('controlPanel')?.appendChild(btnClear);

console.log('%c[syncState 验证] VChart 实例已挂载到 window.__vchart__', 'color: green; font-weight: bold;');
console.log(
'%c[syncState 验证] 可通过以下方式检查绑定情况:\n' +
' const s = __vchart__.getChart().getAllSeries()[0];\n' +
// eslint-disable-next-line max-len
' s.getMarks().forEach(m => console.log(m.name, m.getGraphics().map(g => ({key: g.context?.key, syncBind: g._syncStateBindKey}))))',
'color: blue;'
);
};

run();
97 changes: 92 additions & 5 deletions packages/vchart/src/series/base/base-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> imp

declare getSpecInfo: () => ISeriesSpecInfo;

declare protected _option: ISeriesOption;
protected declare _option: ISeriesOption;

// 坐标系信息
readonly coordinate: CoordinateType = 'none';
Expand Down Expand Up @@ -240,7 +240,7 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> imp
}
protected _dataSet: DataSet;

declare protected _tooltipHelper: ISeriesTooltipHelper | undefined;
protected declare _tooltipHelper: ISeriesTooltipHelper | undefined;
get tooltipHelper() {
if (!this._tooltipHelper) {
this.initTooltip();
Expand Down Expand Up @@ -842,8 +842,8 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> imp
const triggerOff = isValid(finalSelectSpec.triggerOff)
? finalSelectSpec.triggerOff
: isMultiple
? ['empty']
: ['empty', finalSelectSpec.trigger];
? ['empty']
: ['empty', finalSelectSpec.trigger];
return {
type: TRIGGER_TYPE_ENUM.ELEMENT_SELECT as string,
trigger: finalSelectSpec.trigger as GraphicEventType,
Expand Down Expand Up @@ -978,8 +978,95 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> imp
protected initEvent() {
this._data?.getDataView()?.target.addListener('change', this.viewDataUpdate.bind(this));
this._viewDataStatistics?.target.addListener('change', this.viewDataStatisticsUpdate.bind(this));

// 如果存在配置了 syncState 的 extensionMark,在每次渲染完成后建立状态同步关联
if (this._spec.extensionMark?.some(m => m.type !== 'group' && (m as IExtensionMarkSpec<any>).syncState)) {
this.event.on(ChartEvent.afterRender, this._bindExtensionMarkSyncState);
}
}

/**
* 将配置了 syncState 的 extensionMark 的 graphics 与主 mark 的 graphics 通过 context.key 配对,
* 在主 mark graphic 上监听 afterStateUpdate 事件,回调中同步状态到 extensionMark graphic。
* 参考 VRender Label 的 syncState 实现。
*/
private _bindExtensionMarkSyncState = () => {
const extensionMarkSpecs = this._spec.extensionMark;
if (!extensionMarkSpecs) {
return;
}

// 收集主 mark 的 graphics,按 context.key 建立索引
const activeMarks = this.getActiveMarks();
const mainGraphicByKey = new Map<string, any>();
activeMarks.forEach(mark => {
mark.getGraphics().forEach(g => {
const key = g.context?.key;
if (isValid(key)) {
mainGraphicByKey.set(String(key), g);
}
});
});

if (mainGraphicByKey.size === 0) {
return;
}

const namePrefix = this._getExtensionMarkNamePrefix();

extensionMarkSpecs.forEach((spec, i) => {
if (spec.type === 'group' || !(spec as IExtensionMarkSpec<any>).syncState) {
return;
}

const markName = isValid(spec.name) ? `${spec.name}` : `${namePrefix}_${i}`;
const extMark = this._marks.get(markName);
if (!extMark) {
return;
}

extMark.getGraphics().forEach((extGraphic: any) => {
const key = extGraphic.context?.key;
if (!isValid(key)) {
return;
}

const mainGraphic = mainGraphicByKey.get(String(key));
if (!mainGraphic) {
return;
}

// 立即同步一次当前状态
const currentStates = mainGraphic.currentStates;
if (currentStates?.length) {
extGraphic.useStates(currentStates);
}

// 避免重复绑定:通过标记位判断
if (extGraphic._syncStateBindKey === key && extGraphic._syncStateBindTarget === mainGraphic) {
return;
}

// 清理旧监听(如果之前绑定过不同的 mainGraphic)
if (extGraphic._syncStateHandler && extGraphic._syncStateBindTarget) {
extGraphic._syncStateBindTarget.off('afterStateUpdate', extGraphic._syncStateHandler);
}

// 建立新监听
const handler = (e: any) => {
const states = e.target?.currentStates ?? [];
extGraphic.useStates(states);
};
mainGraphic.on('afterStateUpdate', handler);

// 记录绑定信息,用于下次去重/清理
extGraphic._syncStateHandler = handler;
extGraphic._syncStateBindKey = key;
extGraphic._syncStateBindTarget = mainGraphic;
});
});
};

protected _releaseEvent(): void {
super._releaseEvent();
// todo release interactions
Expand Down Expand Up @@ -1282,7 +1369,7 @@ export abstract class BaseSeries<T extends ISeriesSpec> extends BaseModel<T> imp
attributeContext: this._markAttributeContext,
componentType: option.componentType,
noSeparateStyle,
parent: parent !== false ? (parent ?? this._rootMark) : null
parent: parent !== false ? parent ?? this._rootMark : null
});

if (isValid(m)) {
Expand Down
10 changes: 10 additions & 0 deletions packages/vchart/src/typings/spec/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,16 @@ export interface IExtensionMarkSpec<T extends Exclude<EnableMarkType, 'group'>>
* @support since 1.9.0
*/
componentType?: string;
/**
* Whether to synchronize the interactive states (e.g., hover, select) from the corresponding primary mark.
* When enabled, the extensionMark will automatically follow state changes of the primary mark that shares
* the same data key. Users need to configure the corresponding `state` styles to take effect.
* 是否同步主图元的交互状态(如 hover、select 等)
* 开启后,extensionMark 会自动同步对应主图元的状态名,需自行配置对应的 state 样式
* @default false
* @support since 2.0.22
*/
syncState?: boolean;
}

export interface IExtensionGroupMarkSpec extends ICustomMarkSpec<MarkTypeEnum.group> {
Expand Down
Loading