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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "feat: support calc in formatter\n\n",
"type": "none",
"packageName": "@visactor/vchart"
}
],
"packageName": "@visactor/vchart",
"email": "lixuef1313@163.com"
}
65 changes: 65 additions & 0 deletions packages/vchart/__tests__/unit/function/formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { DataSet } from '@visactor/vdataset';
import { VChart } from '../../../src/vchart-all';
import { createCanvas, removeDom } from '../../util/dom';
import { initChartDataSet } from '../../util/context';
import type { IBarChartSpec } from '../../../src';

// 保证引入执行 Build-in
const dataSet = new DataSet();
initChartDataSet(dataSet);

const data = [
{ y: 100, x: '0', type: 'A', value: 50 },
{ y: 200, x: '1', type: 'B', value: 75 },
{ y: 300, x: '2', type: 'C', value: 25 },
{ y: 150, x: '3', type: 'D', value: 100 }
];

describe('formatter calc function test', () => {
let canvasDom: HTMLCanvasElement;

beforeEach(() => {
canvasDom = createCanvas();
canvasDom.style.position = 'relative';
canvasDom.style.width = '500px';
canvasDom.style.height = '500px';
canvasDom.width = 500;
canvasDom.height = 500;
});

afterEach(() => {
removeDom(canvasDom);
});

test('basic calc expressions', () => {
const spec = {
data: {
id: 'barData',
values: data
},
type: 'bar',
xField: 'x',
yField: 'y',
label: {
visible: true
},
animation: false
};

const chart = new VChart(spec as unknown as IBarChartSpec, {
renderCanvas: canvasDom,
onError: () => {}
});

chart.renderSync();

// 验证 calc 功能是否正确应用
const formatter = (chart as any)._chartPlugin?._plugins?.find((p: any) => p.type === 'formatterPlugin');
expect(formatter).toBeDefined();

// 测试基本的 calc 表达式
expect(formatter._format(100, { value: 10000 }, '{value:calc(v/10000)}w')).toBe('1w');
expect(formatter._format(1, { value: 1 }, '{value:calc(v+99)}')).toBe('100');
expect(formatter._format(0.111, { value: 0.111 }, '{value:.2f}')).toBe('0.11');
});
});
72 changes: 66 additions & 6 deletions packages/vchart/src/plugin/chart/formatter/formatter.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { isFunction, isArray, TimeUtil, NumberUtil, numberSpecifierReg } from '@visactor/vutils';

import { BasePlugin } from '../../base/base-plugin';

import type { IChartPlugin, IChartPluginService } from '../interface';
import { Factory } from '../../../core/factory';
import { registerChartPlugin } from '../register';

const bracketReg = /\{([^}]+)\}/;
const bracketGReg = /\{([^}]+)\}/g;

const semicolonReg = /:/;

export class FormatterPlugin extends BasePlugin implements IChartPlugin {
static readonly pluginType: 'chart' = 'chart';

static readonly specKey = 'formatter';

static readonly type: string = 'formatterPlugin';
readonly type: string = 'formatterPlugin';

Expand All @@ -32,7 +27,6 @@ export class FormatterPlugin extends BasePlugin implements IChartPlugin {
};

protected _formatter = this._format;

private _timeFormatter = this._timeModeFormat.local;
private _numericFormatter = NumberUtil.getInstance().format;

Expand Down Expand Up @@ -130,10 +124,76 @@ export class FormatterPlugin extends BasePlugin implements IChartPlugin {
return this._numericFormatter(formatter, Number(text));
} else if (formatter.includes('%') && this._timeFormatter) {
return this._timeFormatter(formatter, text);
} else if (formatter.startsWith('calc(')) {
return this._calcFormatter(formatter, text);
}
return text;
}

/**
* 安全地计算数学表达式
* 支持基本运算:+ - * / ( ) 和变量 v
* @param formatter 格式字符串,如 "calc(v*2+1)"
* @param text 要计算的数值
* @returns 计算结果或原始文本(如果计算失败)
*/
private _calcFormatter(formatter: string, text: string | number): string | number {
try {
// 提取表达式部分,移除 "calc(" 和 ")"
const expression = formatter.slice(5, -1).replace(/v/g, String(text));

// 使用安全的数学表达式计算器
return this._calculateMathExpression(expression, text);
} catch (e) {
return text;
}
}

/**
* 安全的数学表达式计算器
* 支持操作符:+ - * / ( ) 和数字
* @param expression 数学表达式字符串
* @returns 计算结果
*/
private _calculateMathExpression(expression: string, text: string | number): number | string {
// 移除所有空白字符
const cleanExpression = expression.replace(/\s+/g, '');

// 验证表达式只包含允许的字符
if (!this._isValidMathExpression(cleanExpression)) {
return text;
}

// 使用 Function 构造函数创建安全的计算函数
// 这比 eval 安全,因为它只能访问提供的参数
try {
// 将表达式转换为安全的函数调用
// 例如: "2+3*4" -> "return (2+3*4)"
const safeFunction = new Function('return (' + cleanExpression + ')');
const result = safeFunction();

// 验证结果是否为有效数字
if (typeof result !== 'number' || isNaN(result) || !isFinite(result)) {
throw new Error('Invalid calculation result');
}

return result;
} catch (error) {
return text;
}
}

/**
* 验证数学表达式是否只包含允许的字符
* @param expression 要验证的表达式
* @returns 是否有效
*/
private _isValidMathExpression(expression: string): boolean {
// 只允许数字、小数点、运算符和括号
const validPattern = /^[0-9+\-*/().]+$/;
return validPattern.test(expression);
}

release() {
super.release();
this._format = null;
Expand Down
Loading