diff --git a/docs/assets/option/en/common/custom-mark-base.md b/docs/assets/option/en/common/custom-mark-base.md index 3bda557b66..24ae83c8e1 100644 --- a/docs/assets/option/en/common/custom-mark-base.md +++ b/docs/assets/option/en/common/custom-mark-base.md @@ -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} ) }} diff --git a/docs/assets/option/zh/common/custom-mark-base.md b/docs/assets/option/zh/common/custom-mark-base.md index 6df308479c..6c3a7d7de3 100644 --- a/docs/assets/option/zh/common/custom-mark-base.md +++ b/docs/assets/option/zh/common/custom-mark-base.md @@ -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} ) }} diff --git a/packages/vchart/__tests__/runtime/browser/test-page/extension-mark-sync-state.ts b/packages/vchart/__tests__/runtime/browser/test-page/extension-mark-sync-state.ts new file mode 100644 index 0000000000..a7a2724440 --- /dev/null +++ b/packages/vchart/__tests__/runtime/browser/test-page/extension-mark-sync-state.ts @@ -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(); diff --git a/packages/vchart/src/series/base/base-series.ts b/packages/vchart/src/series/base/base-series.ts index c639b4420a..6cae744908 100644 --- a/packages/vchart/src/series/base/base-series.ts +++ b/packages/vchart/src/series/base/base-series.ts @@ -111,7 +111,7 @@ export abstract class BaseSeries extends BaseModel imp declare getSpecInfo: () => ISeriesSpecInfo; - declare protected _option: ISeriesOption; + protected declare _option: ISeriesOption; // 坐标系信息 readonly coordinate: CoordinateType = 'none'; @@ -240,7 +240,7 @@ export abstract class BaseSeries extends BaseModel imp } protected _dataSet: DataSet; - declare protected _tooltipHelper: ISeriesTooltipHelper | undefined; + protected declare _tooltipHelper: ISeriesTooltipHelper | undefined; get tooltipHelper() { if (!this._tooltipHelper) { this.initTooltip(); @@ -842,8 +842,8 @@ export abstract class BaseSeries extends BaseModel 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, @@ -978,8 +978,95 @@ export abstract class BaseSeries extends BaseModel 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).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(); + 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).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 @@ -1282,7 +1369,7 @@ export abstract class BaseSeries extends BaseModel 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)) { diff --git a/packages/vchart/src/typings/spec/common.ts b/packages/vchart/src/typings/spec/common.ts index 03f904d6dd..9da7488722 100644 --- a/packages/vchart/src/typings/spec/common.ts +++ b/packages/vchart/src/typings/spec/common.ts @@ -808,6 +808,16 @@ export interface IExtensionMarkSpec> * @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 {