项目概述
在数据科学和工程计算领域,我们经常需要构建复杂的数值计算表达式。传统的代码编写方式对于非技术人员来说存在一定门槛。本文将介绍如何基于 AntV X6、Vue3 和 TDesign 开发一个图形化的数值计算表达式编辑器,实现可视化建模与代码生成的双向转换。
核心功能需求
-
图形化建模:通过拖拽节点和连接线构建计算表达式
-
表达式生成:将图形转换为可执行的 JavaScript 表达式
-
反向解析:将表达式还原为图形节点,保持原有布局
-
节点类型支持:
-
基础四则运算
-
数学函数(Math.sin、Math.cos 等)
-
条件分支(if-else、switch)
-
输入输出节点
-
-
可视化特性:
-
统一样式的标题栏
-
左侧输入/右侧输出的端口设计
-
右键菜单快速添加节点
-
技术架构设计
1. 整体架构
┌─────────────────────────────────────────────┐
│ 表示层 │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 节点组件 │ │ 图形画布 │ │
│ │ (Vue3组件) │ │ (AntV X6) │ │
│ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 业务层 │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 节点工厂 │ │ 表达式转换器 │ │
│ │ (NodeFactory)│ │ (ExpressionConverter)│ │
│ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 数据层 │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 节点定义 │ │ 图形数据模型 │ │
│ │ (Node Types)│ │ (Graph Model) │ │
│ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────┘
2. 核心模块设计
2.1 节点类型系统
TypeScript
// 节点基础类型定义
export enum NodeType {
INPUT = 'input', // 输入节点
OUTPUT = 'output', // 输出节点
ADD = 'add', // 加法
SUBTRACT = 'subtract', // 减法
MULTIPLY = 'multiply', // 乘法
DIVIDE = 'divide', // 除法
SIN = 'sin', // 正弦
COS = 'cos', // 余弦
POW = 'pow', // 幂运算
IF = 'if', // 条件判断
SWITCH = 'switch', // 分支选择
CONSTANT = 'constant' // 常量
}
// 节点数据结构
export interface NodeData {
id: string;
type: NodeType;
label: string;
config: Record<string, any>;
position: { x: number; y: number };
}
// 端口配置
export interface PortConfig {
id: string;
group: 'in' | 'out';
attrs?: Record<string, any>;
}
2.2 节点工厂模式
采用工厂模式统一管理节点创建逻辑:
TypeScript
export class NodeFactory {
// 创建节点实例
static createNode(type: NodeType, position: Position): NodeData {
const baseNode = {
id: `${type}_${Date.now()}`,
type,
position,
config: {}
};
switch (type) {
case NodeType.INPUT:
return {
...baseNode,
label: '输入值',
config: { variableName: `input_${Date.now()}` }
};
case NodeType.ADD:
return {
...baseNode,
label: '加法运算',
config: { addend: 0 }
};
// 其他节点类型...
}
}
// 获取节点端口配置
static getPorts(nodeType: NodeType): PortConfig[] {
const ports: PortConfig[] = [];
// 根据节点类型配置输入输出端口
switch (nodeType) {
case NodeType.INPUT:
ports.push({ id: 'output', group: 'out' });
break;
case NodeType.OUTPUT:
ports.push({ id: 'input', group: 'in' });
break;
case NodeType.ADD:
ports.push({ id: 'input', group: 'in' });
ports.push({ id: 'output', group: 'out' });
break;
}
return ports;
}
}
关键实现细节
1. 自定义节点组件设计
TypeScript
<template>
<div class="calculation-node" :class="`node-${nodeData.type}`">
<!-- 标题栏 -->
<div class="node-header">
<span class="node-title">{{ nodeData.label }}</span>
</div>
<!-- 内容区域 -->
<div class="node-content">
<!-- 输入节点 -->
<div v-if="nodeData.type === 'input'" class="input-config">
<t-input
v-model="nodeData.config.variableName"
placeholder="请输入变量名"
@blur="onConfigChange"
/>
</div>
<!-- 加法节点 -->
<div v-else-if="nodeData.type === 'add'" class="add-config">
<t-input-number
v-model="nodeData.config.addend"
placeholder="加数"
@change="onConfigChange"
/>
</div>
<!-- Sin节点 -->
<div v-else-if="nodeData.type === 'sin'" class="sin-config">
<t-radio-group v-model="nodeData.config.useDegree" @change="onConfigChange">
<t-radio value="true">角度制</t-radio>
<t-radio value="false">弧度制</t-radio>
</t-radio-group>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NodeData } from '@/types/node';
interface Props {
node: any;
}
const props = defineProps<Props>();
const nodeData = ref<NodeData>(props.node.getData());
const onConfigChange = () => {
// 同步数据到图形节点
props.node.setData(nodeData.value);
props.node.updateAttrs();
};
</script>
<style scoped>
.calculation-node {
width: 180px;
background: #ffffff;
border: 2px solid #1890ff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.node-header {
background: linear-gradient(135deg, #1890ff, #096dd9);
color: white;
padding: 8px 12px;
font-size: 14px;
font-weight: 600;
text-align: center;
}
.node-content {
padding: 12px;
min-height: 50px;
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
2. 表达式转换引擎
图形到表达式的转换采用深度优先遍历算法:
TypeScript
export class ExpressionConverter {
/**
* 将图形转换为可执行表达式
*/
static graphToExpression(graph: Graph): string {
const nodes = graph.getNodes();
const edges = graph.getEdges();
// 查找输出节点作为表达式根节点
const outputNode = nodes.find(node =>
node.getData().type === NodeType.OUTPUT
);
if (!outputNode) {
throw new Error('图中未找到输出节点');
}
return this.buildExpression(outputNode, nodes, edges);
}
/**
* 递归构建表达式
*/
private static buildExpression(
node: any,
nodes: any[],
edges: any[]
): string {
const nodeData = node.getData();
switch (nodeData.type) {
case NodeType.INPUT:
return nodeData.config.variableName || 'defaultInput';
case NodeType.ADD:
const addInput = this.getInputExpression(node, nodes, edges, 'input');
return `(${addInput} + ${nodeData.config.addend})`;
case NodeType.SIN:
const sinInput = this.getInputExpression(node, nodes, edges, 'input');
if (nodeData.config.useDegree === 'true') {
return `Math.sin(${sinInput} * Math.PI / 180)`;
}
return `Math.sin(${sinInput})`;
case NodeType.IF:
return this.buildConditionalExpression(node, nodes, edges);
default:
return '0';
}
}
/**
* 获取输入端口表达式
*/
private static getInputExpression(
node: any,
nodes: any[],
edges: any[],
portId: string
): string {
const inputEdge = edges.find(edge =>
edge.getTargetCell() === node &&
edge.getTargetPortId() === portId
);
if (!inputEdge) {
return this.getDefaultValue(node.getData().type);
}
const sourceNode = nodes.find(n =>
n.id === inputEdge.getSourceCell().id
);
return this.buildExpression(sourceNode, nodes, edges);
}
/**
* 构建条件表达式
*/
private static buildConditionalExpression(
node: any,
nodes: any[],
edges: any[]
): string {
const nodeData = node.getData();
const condition = this.getInputExpression(node, nodes, edges, 'condition');
const trueValue = nodeData.config.trueValue || 'true';
const falseValue = nodeData.config.falseValue || 'false';
return `(${condition} ? ${trueValue} : ${falseValue})`;
}
}
3. 图形初始化与节点注册
TypeScript
export class GraphManager {
private graph: Graph;
constructor(container: HTMLElement) {
this.graph = new Graph({
container,
width: 1000,
height: 600,
grid: true,
mousewheel: {
enabled: true,
zoomAtMousePosition: true,
modifiers: 'ctrl',
minScale: 0.5,
maxScale: 3,
},
connecting: {
router: 'manhattan',
connector: {
name: 'rounded',
args: { radius: 8 },
},
anchor: 'center',
connectionPoint: 'anchor',
allowBlank: false,
snap: { radius: 20 },
createEdge() {
return new Shape.Edge({
attrs: {
line: {
stroke: '#A2B1C3',
strokeWidth: 2,
targetMarker: {
name: 'block',
width: 12,
height: 8,
},
},
},
zIndex: 0,
});
},
},
});
this.registerCustomNodes();
this.setupEventListeners();
}
/**
* 注册所有自定义节点类型
*/
private registerCustomNodes(): void {
Object.values(NodeType).forEach(type => {
Graph.registerNode(
type,
{
inherit: 'vue-shape',
component: {
template: `<calculation-node />`
},
ports: {
groups: {
in: {
position: 'left',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#1890ff',
strokeWidth: 2,
fill: '#fff',
},
},
},
out: {
position: 'right',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#52c41a',
strokeWidth: 2,
fill: '#fff',
},
},
},
},
items: NodeFactory.getPorts(type),
},
},
true
);
});
}
}
4. 主编辑器组件实现
TypeScript
<template>
<div class="expression-editor">
<!-- 工具栏 -->
<div class="editor-toolbar">
<t-space>
<t-button @click="generateExpression" icon="play-circle">
生成表达式
</t-button>
<t-button @click="importExpression" icon="upload">
导入表达式
</t-button>
<t-button @click="clearGraph" icon="delete">
清空画布
</t-button>
</t-space>
<t-input
v-model="currentExpression"
placeholder="生成的表达式将显示在这里"
readonly
class="expression-preview"
/>
</div>
<!-- 图形画布 -->
<div ref="graphContainer" class="graph-container"></div>
<!-- 右键菜单 -->
<t-dropdown
v-model:visible="contextMenu.visible"
:options="nodeMenuOptions"
@click="handleContextMenuClick"
trigger="contextMenu"
>
<div
v-show="contextMenu.visible"
class="context-menu-anchor"
:style="contextMenuStyle"
/>
</t-dropdown>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { Graph } from '@antv/x6';
import { GraphManager } from '@/utils/GraphManager';
import { ExpressionConverter } from '@/utils/ExpressionConverter';
import { NodeFactory, NodeType } from '@/utils/NodeFactory';
const graphContainer = ref<HTMLElement>();
const graphManager = ref<GraphManager>();
const currentExpression = ref('');
const contextMenu = ref({
visible: false,
x: 0,
y: 0
});
// 右键菜单选项
const nodeMenuOptions = [
{ value: NodeType.ADD, label: '加法运算', icon: 'add' },
{ value: NodeType.SUBTRACT, label: '减法运算', icon: 'remove' },
{ value: NodeType.MULTIPLY, label: '乘法运算', icon: 'multiply' },
{ value: NodeType.DIVIDE, label: '除法运算', icon: 'divide' },
{ value: NodeType.SIN, label: '正弦函数', icon: 'sin' },
{ value: NodeType.COS, label: '余弦函数', icon: 'cos' },
{ value: NodeType.POW, label: '幂运算', icon: 'power' },
{ value: NodeType.IF, label: '条件判断', icon: 'if' },
{ value: NodeType.CONSTANT, label: '常量值', icon: 'number' },
];
const contextMenuStyle = computed(() => ({
left: `${contextMenu.value.x}px`,
top: `${contextMenu.value.y}px`
}));
onMounted(() => {
if (graphContainer.value) {
graphManager.value = new GraphManager(graphContainer.value);
setupDefaultNodes();
setupContextMenu();
}
});
// 设置默认节点
const setupDefaultNodes = () => {
if (!graphManager.value) return;
const graph = graphManager.value.getGraph();
graph.addNode({
id: 'input_node',
shape: NodeType.INPUT,
x: 100,
y: 300,
data: NodeFactory.createNode(NodeType.INPUT, { x: 100, y: 300 })
});
graph.addNode({
id: 'output_node',
shape: NodeType.OUTPUT,
x: 800,
y: 300,
data: NodeFactory.createNode(NodeType.OUTPUT, { x: 800, y: 300 })
});
};
// 设置右键菜单
const setupContextMenu = () => {
if (!graphManager.value) return;
const graph = graphManager.value.getGraph();
graph.on('blank:contextmenu', ({ e, x, y }) => {
e.preventDefault();
contextMenu.value = {
visible: true,
x: e.clientX,
y: e.clientY
};
});
};
// 处理右键菜单点击
const handleContextMenuClick = (menuItem: any) => {
if (!graphManager.value) return;
const graph = graphManager.value.getGraph();
const position = graph.clientToLocal(
contextMenu.value.x,
contextMenu.value.y
);
const nodeData = NodeFactory.createNode(menuItem.value, position);
graph.addNode({
id: nodeData.id,
shape: nodeData.type,
x: position.x,
y: position.y,
data: nodeData
});
contextMenu.value.visible = false;
};
// 生成表达式
const generateExpression = () => {
if (!graphManager.value) return;
try {
const graph = graphManager.value.getGraph();
currentExpression.value = ExpressionConverter.graphToExpression(graph);
// 可以进一步使用 eval 执行验证
const result = eval(currentExpression.value.replace(/[a-zA-Z_$][a-zA-Z0-9_$]*/g, '1'));
console.log('表达式验证结果:', result);
} catch (error) {
console.error('表达式生成失败:', error);
currentExpression.value = `错误: ${error.message}`;
}
};
// 导入表达式(简化版)
const importExpression = () => {
// 实现表达式解析和图形重建逻辑
// 这里需要构建表达式解析器来反向生成节点图
};
// 清空画布
const clearGraph = () => {
if (!graphManager.value) return;
graphManager.value.getGraph().clearCells();
setupDefaultNodes();
};
</script>
<style scoped>
.expression-editor {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.editor-toolbar {
padding: 16px 24px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.expression-preview {
flex: 1;
max-width: 500px;
}
.graph-container {
flex: 1;
background: #fafafa;
}
.context-menu-anchor {
position: fixed;
width: 1px;
height: 1px;
}
</style>
扩展性与优化建议
1. 性能优化
-
虚拟滚动:对于大型图形,实现画布的虚拟滚动
-
节点懒加载:复杂表达式可以分组加载
-
增量更新:只更新变化的节点和连接
2. 功能扩展
-
表达式验证:在生成阶段进行语法和语义检查
-
版本管理:支持表达式版本的保存和恢复
-
模板系统:提供常用计算模板
-
协作编辑:支持多用户同时编辑
3. 用户体验
-
撤销重做:完整的操作历史管理
-
快捷键支持:提高操作效率
-
节点分组:支持将相关节点打包成子图
-
自动布局:提供多种自动布局算法
总结
本文详细介绍了基于 AntV X6 的图形化数值计算表达式编辑器的设计与实现。通过模块化的架构设计、工厂模式的节点管理、以及深度优先的表达式转换算法,我们实现了可视化建模与代码生成的无缝衔接。
这种图形化编程方式不仅降低了技术门槛,还提高了表达式的可读性和维护性。开发者可以根据实际需求扩展更多的节点类型和功能,构建出更加强大的可视化计算平台。