Skip to content
Merged
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
24 changes: 24 additions & 0 deletions docs/assets/guide/en/tutorial_docs/Theme/Customize_Theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions docs/assets/guide/zh/tutorial_docs/Theme/Customize_Theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 注册的可信函数,不会把字符串作为代码执行。建议使用带命名空间的函数名,避免与普通样式字符串重名。

## 更新主题

在某些应用场景下,我们可能需要根据用户的操作或其他状态来动态更新图表的主题。下面将介绍如何热更新单个图表实例和全局图表实例的主题:
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
109 changes: 109 additions & 0 deletions packages/vchart/__tests__/unit/theme/function.test.ts
Original file line number Diff line number Diff line change
@@ -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, (datum: (typeof data)[number]) => 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');
});
});
11 changes: 7 additions & 4 deletions packages/vchart/src/core/vchart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2292,6 +2292,9 @@ export class VChart implements IVChart {
this._currentTheme.colorScheme,
this._currentTheme.token
)[key];
if (this.getFunctionList()?.length) {
theme = functionTransform(theme, this);
}
}
});

Expand Down
2 changes: 1 addition & 1 deletion packages/vchart/src/series/treemap/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export interface ITreemapSeriesSpec
};
}

export interface ITreemapSeriesTheme extends ICartesianSeriesTheme {
export interface ITreemapSeriesTheme extends Omit<ICartesianSeriesTheme, 'label'> {
gapWidth?: TreemapOptions['padding'];
nodePadding?: TreemapOptions['padding'];
[SeriesMarkNameEnum.leaf]?: Partial<IMarkTheme<IRectMarkSpec>>;
Expand Down
6 changes: 4 additions & 2 deletions packages/vchart/src/typings/spec/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,15 +625,17 @@ export interface IMarkStateTheme<T> extends Record<string, T> {
selected_reverse?: T;
}

type ConvertToThemeMarkStyleSpec<T> = T extends Record<string, any> ? Partial<ConvertToMarkStyleSpec<T>> : never;

export type IMarkTheme<T> = {
/**
* mark 层 是否显示配置
*/
visible?: boolean;
/** 默认样式设置 */
style?: T;
style?: ConvertToThemeMarkStyleSpec<T>;
/** 不同状态下的样式配置 */
state?: IMarkStateTheme<T>;
state?: IMarkStateTheme<ConvertToThemeMarkStyleSpec<T>>;
/**
* 可交互的开关
*/
Expand Down
35 changes: 18 additions & 17 deletions packages/vchart/src/util/spec/transform.ts
Original file line number Diff line number Diff line change
@@ -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对象,时间消耗很少
Expand Down Expand Up @@ -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[]) {
Expand Down
Loading