From c65006479c4915876195a5dc0286a1b4cac31311 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Tue, 14 Jul 2026 17:12:54 +0800 Subject: [PATCH] feat: theme support callback function. close#4620 --- .../en/tutorial_docs/Theme/Customize_Theme.md | 24 ++++ .../zh/tutorial_docs/Theme/Customize_Theme.md | 24 ++++ .../browser/test-page/theme-function-style.ts | 98 ++++++++++++++++ .../__tests__/unit/theme/function.test.ts | 109 ++++++++++++++++++ packages/vchart/src/core/vchart.ts | 11 +- .../vchart/src/series/treemap/interface.ts | 2 +- packages/vchart/src/typings/spec/common.ts | 6 +- packages/vchart/src/util/spec/transform.ts | 35 +++--- 8 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 packages/vchart/__tests__/runtime/browser/test-page/theme-function-style.ts create mode 100644 packages/vchart/__tests__/unit/theme/function.test.ts diff --git a/docs/assets/guide/en/tutorial_docs/Theme/Customize_Theme.md b/docs/assets/guide/en/tutorial_docs/Theme/Customize_Theme.md index 2cba312432..5cd2f3f616 100644 --- a/docs/assets/guide/en/tutorial_docs/Theme/Customize_Theme.md +++ b/docs/assets/guide/en/tutorial_docs/Theme/Customize_Theme.md @@ -208,6 +208,30 @@ const vchart = new VChart(spec, { dom: CONTAINER_ID }); vchart.renderSync(); ``` +### Referencing callbacks in serialized themes + +When registering a JavaScript theme directly, callbacks can be configured in mark `style` and `state`. If a theme comes from serialized data such as JSON, register the callback with `VChart.registerFunction` first and use its name in the theme. The function must be registered before the chart is rendered or switched to that theme. + +```javascript +VChart.registerFunction('theme.bar.fill', datum => { + return datum.value < 0 ? '#f53f3f' : '#00b42a'; +}); + +VChart.ThemeManager.registerTheme('userTheme', { + series: { + bar: { + bar: { + style: { + fill: 'theme.bar.fill' + } + } + } + } +}); +``` + +Only trusted functions registered through `registerFunction` are resolved. String values are never evaluated as code. Use namespaced function names to avoid collisions with ordinary style strings. + ## Updating Themes In some application scenarios, we may need to dynamically update the chart theme based on user actions or other states. The following will introduce how to hot-update the theme of individual chart instances and global chart instances: diff --git a/docs/assets/guide/zh/tutorial_docs/Theme/Customize_Theme.md b/docs/assets/guide/zh/tutorial_docs/Theme/Customize_Theme.md index bb0da57585..fe6c34e024 100644 --- a/docs/assets/guide/zh/tutorial_docs/Theme/Customize_Theme.md +++ b/docs/assets/guide/zh/tutorial_docs/Theme/Customize_Theme.md @@ -208,6 +208,30 @@ const vchart = new VChart(spec, { dom: CONTAINER_ID }); vchart.renderSync(); ``` +### 在序列化主题中引用回调函数 + +直接注册 JavaScript 主题时,可以在图元的 `style` 和 `state` 中配置回调函数。如果主题来自 JSON 等不可保存函数的序列化数据,可以先通过 `VChart.registerFunction` 注册函数,再在主题中使用函数名。函数需要在图表渲染或切换到该主题前完成注册。 + +```javascript +VChart.registerFunction('theme.bar.fill', datum => { + return datum.value < 0 ? '#f53f3f' : '#00b42a'; +}); + +VChart.ThemeManager.registerTheme('userTheme', { + series: { + bar: { + bar: { + style: { + fill: 'theme.bar.fill' + } + } + } + } +}); +``` + +函数名只会解析为通过 `registerFunction` 注册的可信函数,不会把字符串作为代码执行。建议使用带命名空间的函数名,避免与普通样式字符串重名。 + ## 更新主题 在某些应用场景下,我们可能需要根据用户的操作或其他状态来动态更新图表的主题。下面将介绍如何热更新单个图表实例和全局图表实例的主题: diff --git a/packages/vchart/__tests__/runtime/browser/test-page/theme-function-style.ts b/packages/vchart/__tests__/runtime/browser/test-page/theme-function-style.ts new file mode 100644 index 0000000000..f9d1807fa2 --- /dev/null +++ b/packages/vchart/__tests__/runtime/browser/test-page/theme-function-style.ts @@ -0,0 +1,98 @@ +import { isMobile } from 'react-device-detect'; +import { default as VChart } from '../../../../src/index'; +import type { IBarChartSpec } from '../../../../src/index'; +import type { ITheme } from '../../../../src/theme'; + +// 方式一:直接注册主题,主题中直接配置函数 +const directThemeName = 'local-theme-function-direct'; // 直接注册主题名 +const directTheme: ITheme = { + series: { + bar: { + bar: { + style: { + fill: datum => (datum.value < 0 ? '#f53f3f' : '#00b42a'), + fillOpacity: datum => (Math.abs(datum.value) >= 50 ? 1 : 0.65) + }, + state: { + hover: { + stroke: '#1d2129', + lineWidth: datum => (Math.abs(datum.value) >= 50 ? 4 : 2) + } + } + } + } + } +}; +VChart.ThemeManager.registerTheme(directThemeName, directTheme); + +// 方式二:注册函数,主题中引用函数名 +const registeredThemeName = 'local-theme-function-registered'; // 注册函数的主题名 +const registeredFillName = 'local.theme.barFill'; // 函数名 +VChart.registerFunction(registeredFillName, datum => (datum.value < 0 ? '#ff7d00' : '#165dff')); +const registeredTheme: ITheme = { + series: { + bar: { + bar: { + style: { + fill: registeredFillName + }, + state: { + hover: { + stroke: '#1d2129', + lineWidth: 3 + } + } + } + } + } +}; +VChart.ThemeManager.registerTheme(registeredThemeName, registeredTheme); + +VChart.ThemeManager.setCurrentTheme(directThemeName); +const spec: IBarChartSpec = { + type: 'bar', + data: [ + { + id: 'data', + values: [ + { category: '收入', value: 86 }, + { category: '退款', value: -42 }, + { category: '订阅', value: 58 }, + { category: '成本', value: -67 } + ] + } + ], + xField: 'category', + yField: 'value', + animation: false, + title: { + visible: true, + text: '主题回调控制图元样式', + subtext: '绿色/红色:主题直接配置函数;蓝色/橙色:主题引用 registerFunction 注册名' + }, + bar: { + state: { + hover: {} + } + }, + label: { + visible: true, + position: 'outside' + } +}; +const cs = new VChart(spec, { + dom: document.getElementById('chart') as HTMLElement, + mode: isMobile ? 'mobile-browser' : 'desktop-browser', + //theme: 'dark', + onError: err => { + console.error(err); + } +}); +console.time('renderTime'); + +cs.renderAsync().then(() => { + console.timeEnd('renderTime'); +}); + +window['vchart'] = cs; +console.log(cs); diff --git a/packages/vchart/__tests__/unit/theme/function.test.ts b/packages/vchart/__tests__/unit/theme/function.test.ts new file mode 100644 index 0000000000..672e77892f --- /dev/null +++ b/packages/vchart/__tests__/unit/theme/function.test.ts @@ -0,0 +1,109 @@ +import { ThemeManager, type ITheme } from '../../../src/theme'; +import type { IBarSeriesSpec } from '../../../src/series/bar/interface'; +import { functionTransform } from '../../../src/util/spec/transform'; +import { mergeSpec } from '@visactor/vutils-extension'; + +const data = [ + { category: 'positive', value: 10 }, + { category: 'negative', value: -5 } +]; + +describe('theme function style', () => { + const directThemeName = 'theme-function-direct'; + const registeredThemeName = 'theme-function-registered'; + const overrideThemeName = 'theme-function-override'; + const registeredFunctionName = 'theme.function.barFill'; + + const functions = new Map string>(); + const functionRegistry = { + getFunction: (key: string) => functions.get(key) ?? null + }; + + afterEach(() => { + ThemeManager.removeTheme(directThemeName); + ThemeManager.removeTheme(registeredThemeName); + ThemeManager.removeTheme(overrideThemeName); + functions.clear(); + }); + + const transformBarSpec = (themeName: string, bar?: IBarSeriesSpec['bar']) => { + const currentTheme = ThemeManager.getTheme(themeName); + const barTheme = functionTransform(currentTheme.series?.bar?.bar, functionRegistry); + return mergeSpec({}, barTheme, bar); + }; + + it('supports callbacks in theme mark styles and states', () => { + const theme: ITheme = { + series: { + bar: { + bar: { + style: { + fill: datum => (datum.value < 0 ? 'red' : 'green') + }, + state: { + hover: { + lineWidth: datum => (datum.value < 0 ? 1 : 3) + } + } + } + } + } + }; + ThemeManager.registerTheme(directThemeName, theme); + + const spec = transformBarSpec(directThemeName); + const fill = spec.style?.fill; + const hoverLineWidth = spec.state?.hover?.lineWidth; + + expect(typeof fill).toBe('function'); + expect((fill as (datum: (typeof data)[number]) => string)(data[0])).toBe('green'); + expect((fill as (datum: (typeof data)[number]) => string)(data[1])).toBe('red'); + expect(typeof hoverLineWidth).toBe('function'); + expect((hoverLineWidth as (datum: (typeof data)[number]) => number)(data[0])).toBe(3); + expect((hoverLineWidth as (datum: (typeof data)[number]) => number)(data[1])).toBe(1); + }); + + it('resolves registered function names in theme mark styles', () => { + functions.set(registeredFunctionName, (datum: (typeof data)[number]) => (datum.value < 0 ? 'red' : 'green')); + ThemeManager.registerTheme(registeredThemeName, { + series: { + bar: { + bar: { + style: { + fill: registeredFunctionName + } + } + } + } + }); + + const spec = transformBarSpec(registeredThemeName); + const fill = spec.style?.fill; + + expect(typeof fill).toBe('function'); + expect((fill as (datum: (typeof data)[number]) => string)(data[0])).toBe('green'); + expect((fill as (datum: (typeof data)[number]) => string)(data[1])).toBe('red'); + }); + + it('keeps mark spec styles at a higher priority than theme callbacks', () => { + ThemeManager.registerTheme(overrideThemeName, { + series: { + bar: { + bar: { + style: { + fill: () => 'red' + } + } + } + } + }); + + const spec = transformBarSpec(overrideThemeName, { + style: { + fill: 'blue' + } + }); + + expect(spec.style?.fill).toBe('blue'); + }); +}); diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index 27cfca67f3..8adb7c4cea 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -467,7 +467,7 @@ export class VChart implements IVChart { // 设置全局字体 this._setFontFamilyTheme(this.getTheme('fontFamily') as string); this._initDataSet(this._option.dataSet); - this._autoSize = isTrueBrowseEnv ? spec.autoFit ?? this._option.autoFit ?? true : false; + this._autoSize = isTrueBrowseEnv ? (spec.autoFit ?? this._option.autoFit ?? true) : false; this._bindResizeEvent(); this._bindViewEvent(); this._initChartPlugin(); @@ -1534,8 +1534,8 @@ export class VChart implements IVChart { isObject(specTheme) && specTheme.type ? specTheme.type : isObject(optionTheme) && optionTheme.type - ? optionTheme.type - : this._currentThemeName + ? optionTheme.type + : this._currentThemeName ), getThemeObject(optionTheme), getThemeObject(specTheme) @@ -1569,7 +1569,7 @@ export class VChart implements IVChart { } const lasAutoSize = this._autoSize; - this._autoSize = isTrueBrowser(this._option.mode) ? this._spec.autoFit ?? this._option.autoFit ?? true : false; + this._autoSize = isTrueBrowser(this._option.mode) ? (this._spec.autoFit ?? this._option.autoFit ?? true) : false; if (this._autoSize !== lasAutoSize) { resize = true; } @@ -2292,6 +2292,9 @@ export class VChart implements IVChart { this._currentTheme.colorScheme, this._currentTheme.token )[key]; + if (this.getFunctionList()?.length) { + theme = functionTransform(theme, this); + } } }); diff --git a/packages/vchart/src/series/treemap/interface.ts b/packages/vchart/src/series/treemap/interface.ts index 9d8baeba0e..8316873a9f 100644 --- a/packages/vchart/src/series/treemap/interface.ts +++ b/packages/vchart/src/series/treemap/interface.ts @@ -115,7 +115,7 @@ export interface ITreemapSeriesSpec }; } -export interface ITreemapSeriesTheme extends ICartesianSeriesTheme { +export interface ITreemapSeriesTheme extends Omit { gapWidth?: TreemapOptions['padding']; nodePadding?: TreemapOptions['padding']; [SeriesMarkNameEnum.leaf]?: Partial>; diff --git a/packages/vchart/src/typings/spec/common.ts b/packages/vchart/src/typings/spec/common.ts index 03f904d6dd..9381d20110 100644 --- a/packages/vchart/src/typings/spec/common.ts +++ b/packages/vchart/src/typings/spec/common.ts @@ -625,15 +625,17 @@ export interface IMarkStateTheme extends Record { selected_reverse?: T; } +type ConvertToThemeMarkStyleSpec = T extends Record ? Partial> : never; + export type IMarkTheme = { /** * mark 层 是否显示配置 */ visible?: boolean; /** 默认样式设置 */ - style?: T; + style?: ConvertToThemeMarkStyleSpec; /** 不同状态下的样式配置 */ - state?: IMarkStateTheme; + state?: IMarkStateTheme>; /** * 可交互的开关 */ diff --git a/packages/vchart/src/util/spec/transform.ts b/packages/vchart/src/util/spec/transform.ts index 3d6f29958a..22a9baea27 100644 --- a/packages/vchart/src/util/spec/transform.ts +++ b/packages/vchart/src/util/spec/transform.ts @@ -1,5 +1,8 @@ import { isArray, isFunction, isPlainObject, isString } from '@visactor/vutils'; -import type { ISeriesSpec } from '../../typings'; + +interface IFunctionTransformRegistry { + getFunction: (key: string) => unknown; +} // todo 以目前的场景来看,并没有递归的需要。 // 考虑到不确定性,还是递归处理spec对象,时间消耗很少 @@ -38,33 +41,31 @@ export function specTransform( /** * functionTransform is used to replace the function registered by the instance - * @param spec + * @param value * @returns */ -export function functionTransform(spec: ISeriesSpec, VChart: any): any { - if (!spec) { - return spec; +export function functionTransform(value: unknown, registry: IFunctionTransformRegistry): any { + if (!value) { + return value; + } + if (isString(value)) { + return registry.getFunction(value) ?? value; } // 如果是普通对象 - if (isPlainObject(spec)) { + if (isPlainObject(value)) { const result: any = {}; - for (const key in spec as any) { - if (Object.prototype.hasOwnProperty.call(spec, key)) { - // 如果使用了注册函数 - if (isString(spec[key]) && VChart.getFunction(spec[key])) { - result[key] = VChart.getFunction(spec[key]); - continue; - } - result[key] = functionTransform(spec[key], VChart); + for (const key in value as any) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + result[key] = functionTransform((value as any)[key], registry); } } return result; } // 如果是数组 - if (isArray(spec)) { - return (spec as ISeriesSpec[]).map((s: ISeriesSpec) => functionTransform(s, VChart)); + if (isArray(value)) { + return value.map(item => functionTransform(item, registry)); } - return spec; + return value; } export function transformFunctionAttribute(att: unknown, ...args: unknown[]) {