diff --git a/common/changes/@visactor/vchart/feat-support-calc-in-formatter_2025-09-24-06-10.json b/common/changes/@visactor/vchart/feat-support-calc-in-formatter_2025-09-24-06-10.json new file mode 100644 index 0000000000..fee12d4527 --- /dev/null +++ b/common/changes/@visactor/vchart/feat-support-calc-in-formatter_2025-09-24-06-10.json @@ -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" +} \ No newline at end of file diff --git a/packages/vchart/__tests__/unit/function/formatter.test.ts b/packages/vchart/__tests__/unit/function/formatter.test.ts new file mode 100644 index 0000000000..392c0d27da --- /dev/null +++ b/packages/vchart/__tests__/unit/function/formatter.test.ts @@ -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'); + }); +}); diff --git a/packages/vchart/src/plugin/chart/formatter/formatter.ts b/packages/vchart/src/plugin/chart/formatter/formatter.ts index fb78cd8bb2..a67c9f540e 100644 --- a/packages/vchart/src/plugin/chart/formatter/formatter.ts +++ b/packages/vchart/src/plugin/chart/formatter/formatter.ts @@ -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'; @@ -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; @@ -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;