背景
在Ai Agent的项目开发中,为了编排画图的美观,采用了reactflow实现,可以自定义节点和边,但是没有提供免费的布局优化算法,笔者看了下付费情况,开通会员 $269/每月 !
思来想去决定自己开发,当然不是心疼会员费,而是自己开发更有性价比。
不浪费时间,上干货
分析问题
先看看reactflow的节点元素
interface Node {
id: string;
data: Record<string, any>;
position: {
x: number;
y: number;
}
}
我们发现,这里是要求提前写好节点的位置!
我们的终极目标是实现类似下图的多叉树,root节点为初始节点,只有A一个入口节点
mindmap
Root
A
B
B1
B2
C
C1
C2
C3
D
D1
D2
D3
首先笔者采用的从左到右的布局方式,理由如下:
- PC一般屏幕宽度大于高度
- PC上的用户习惯是横向为主
根据上面的思考,最终的布局大致为下图所示
布局确定后,我们发现编排的几种方式,难度由浅到深
- 横向类似链表
- 只有最后一层节点为多节点
- 随机每层都可能为多节点
类似链表布局的实现
这个很简单了,直接平铺节点:
- 我们只需要确定初始节点的坐标:x和y,确定每个节点的宽度:比如为160px
- 每次插入节点的时候,x在父节点的基础上+节点宽度:160+ 一段距离:80,y用父节点的y
这样解决了链表的布局
import { produce } from "immer";
const nodeWidth: number = 160;
const rootNode: Node = {
id: 'root',
data: {
name: 'root'
},
position: {
x: 100,
y: 100
}
};
const nodes: Node[] = [rootNode];
function addNode(nodes: Node[], parentNode: Node): Node[] {
const newNode: Node = {
id: 'node1',
data: {
name: 'node1'
},
position: {
x: parentNode.x + nodeWidth + 80,
y: parentNode.y
}
};
const newNodes: Node[] = produce(nodes, (state: Node[]) => {
return [...state, newNode];
});
return newNodes;
}
最终效果如下:
最后一层为多节点的情况
这种和链表的区别是,需要解决最后一层的布局:
- 除了最后一层仍然使用之前的布局方案,初始节点默认level层级为0
- 最后一层的元素,x在父节点的基础上+节点宽度:160+ 一段距离:80,第一个子节点的y用父节点的y,其余子节点 y = 父节点的y + 最后一层数组下标 * (节点高度:80 + 一段距离:40)
- 数组下标可以在每次插入节点标记level=父节点level+1
import { produce } from "immer";
const nodeWidth: number = 160;
const nodeHeight: number = 80;
const rootNode: Node = {
id: 'root',
data: {
name: 'root',
level: 0
},
position: {
x: 100,
y: 100
}
};
const nodes: Node[] = [rootNode];
function addNode(nodes: Node[], parentNode: Node): Node[] {
// 假想可能每层都为末尾节点
const lastChildren: Node[] = nodes.filter((node: Node) => {
return node.data.level = parentNode.level + 1;
});
const i = lastChildren.length;
const newNode: Node = {
id: 'node1',
data: {
name: 'node1',
level: parentNode.level + 1,
},
position: {
x: parentNode.x + nodeWidth + 80,
y: parentNode.y + i * (nodeHeight + 40)
}
};
const newNodes: Node[] = produce(nodes, (state: Node[]) => {
return [...state, newNode];
});
return newNodes;
}
最终效果如下:
随机每层都是多节点
需要引入多叉树的数据结构了,帮我们记录整个编排的结构
class CustomTree {
id: number;
parentId: number;
level: number;
chlidren: CustomTree[];
constructor(
data: any,
parentId: string | undefined = undefined,
children: any[] = []
) {
this.id = `${Math.random().toString(16).slice(2, 10)}`;
this.data = data;
this.parentId = parentId;
this.level = 0;
this.children = children;
}
addNode(id: string, node: CustomTree) {
// TODO
}
findNodeById(id: string): CustomTree | null {
// TODO
}
}
整体思路:
- 每次创建节点的时候同步创建一个多叉树节点,在多叉树中保存相关状态
- 用二维数组levelArr描述整个布局方案,第一层表示每一列,第二层表示每一列的每一行
- level初始为0,每次注入节点的时候,子节点level=父节点level+1
- 根据levelArr的某一层下标给 x 添加 节点宽度 * 某一层下标,根据某一层的数组长度给 y 添加 节点高度 * 某一层的数组长度
import { produce } from "immer";
const nodeWidth: number = 160;
const nodeHeight: number = 80;
const rootNode: Node = {
id: 'root',
data: {
name: 'root',
level: 0
},
position: {
x: 100,
y: 100
}
};
const nodes: Node[] = [rootNode];
function layoutSort(nodes: Node[], tree: CustomTree): Node[] {
const levelArr: string[][] = [];
const newNodes = produce(nodes, (state) => {
state.forEach((node) => {
const item = tree.findNodeById(node.id);
if (item) {
const index = item.level;
if (Array.isArray(levelArr[index])) {
levelArr[index].push(node.id);
} else {
levelArr[index] = [node.id];
}
node.position.x = rootNode.x + nodeWidth * index;
node.position.y = rootNode.y + nodeHeight * levelArr[index].length;
}
});
});
return newNodes;
}
最终效果如下:
优化
看起来好像解决问题了,但是如果上图所示,在最后一层节点靠下方的节点 后面继续追加节点会咋样?
因为在levelArr中,新的一层没有元素,所以 当前节点的 y又被重置为初始节点的 y 了,我们期望新的子节点 y坐标和父节点的 y坐标继续保持一致
优化如下:
function layoutSort(nodes: Node[], tree: CustomTree): Node[] {
const levelArr: string[][] = [];
const newNodes = produce(nodes, (state) => {
state.forEach((node) => {
const item = tree.findNodeById(node.id);
if (item) {
const index = item.level;
levelArr[index] = Array.isArray(levelArr[index]) ? levelArr[index] : [];
// 是否需要重置 y坐标
const parentItem = tree.findNodeById(item.parentId || "");
if (parentItem) {
const parentIndex = levelArr[parentItem?.level].indexOf(
parentItem.id
);
const isNeedResetPos =
index > 1 && parentIndex > levelArr[index].length;
const repeatArr = isNeedResetPos
? Array(
parentIndex - levelArr[index].length > 0
? parentIndex - levelArr[index].length
: 0
).map((v: string, i: number) => i)
: [];
levelArr[index] = [...levelArr[index], ...repeatArr, node.id];
} else {
levelArr[index] = [...levelArr[index], node.id];
}
node.position.x = 100 + agentOffsetWidth * index;
node.position.y = 100 + agentOffsetHeight * levelArr[index].length;
}
});
});
return newNodes;
}
最终效果如下:
总结
试问还能优化吗???
能,布局算法只是解决尽可能不冲突,而真正的布局优化是用户手动去拖拽生成的位置
如果要真正达到用户心目中满意的程度,在用户未操作前用布局算法实现,用户不满意接着拖拽后存储每次拖拽的位置,把每个节点的位置都记录下,下一次的布局直接使用用户手动调整的位置,而不是布局算法了!
以上,欢迎留言和评论