最近在做企业风控相关的产品,常常涉及企业上下游行业、企业关联关系等信息的展示,为了更好地表达这些关系,我们常常使用树形图来描述这些关系,如下图所示:
我们期望这个树形布局有以下特点:
- 同一层级节点在同一水平线上;
- 父节点位置始终在子节点中间位置;
- 子节点不允许重叠;
- 相邻节点有一定间距,且不同父节点的相邻节点加大间距区分。
传统的树形布局通常将子节点按照兄弟节点的最大宽度分开(一个节点的最大宽度即以该节点为根的树所占的最大宽度)。这样做虽然代码上容易实现,只要在每个节点保存该子树的最大宽度即可,但是视觉上不够美观,比较浪费空间。在某些传统布局甚至限制子节点的数目。
而这种布局与传统的树形布局有很大不同,它做到了在布局上节点与节点尽可能地紧凑,而且始终保持对称性和任意子节点数目。
实现我们期望的树形布局,最关键的一步是怎么从最底层一步步推导出全树节点的位置。
算法的实现步骤如下:
- 遍历树形结构数据,得出树形节点 id 和 节点的映射关系、以及一个二维数组保存了每一个层级的节点;
- 遍历二维数组:计算底层节点位置,向上修正父节点位置,得出节点数组;
- 遍历节点数组,计算出连接线数组,得出图形数据。
接下来我们就通过代码来具体实现:
第一步:数据准备
我们先准备一份树形结构数据:
const treeNodes = [
{
id: '0',
name: '0',
children: [
{
id: '1',
name: '1',
children: [
{
id: '11',
name: '11',
children: [
{
id: '111',
name: '111'
},
{
id: '112',
name: '112'
},
{
id: '113',
name: '113'
},
{
id: '114',
name: '114'
}
]
},
{
id: '12',
name: '12',
children: [
{
id: '121',
name: '121'
},
{
id: '122',
name: '122'
},
]
}
]
},
{
id: '2',
name: '2',
children: [
{
id: '22',
name: '22',
children: [
{
id: '221',
name: '221'
},
{
id: '222',
name: '222'
},
{
id: '223',
name: '223'
},
]
},
{
id: '23',
name: '23',
children: [
{
id: '231',
name: '231'
}
]
}
]
}
]
}
];
第二步:计算层级
定义节点数据类型
interface INode {
id: string | number,
name: string | number,
children?: Array<INode>,
parentId?: string | number,
depth?: number,
x?: number,
y?: number
}
遍历数据,计算出节点二维数组,以及节点 hash 表,用于映射节点:
export function iterator(treeNodes: Array<INode>, rootId = 'root'): {
matrix: Array<Array<INode>>,
hash: {}
} | void {
if (!treeNodes || !treeNodes.length) return;
/**
* matrix [
* 0 [ node(0) ],
* 1 [ node(1), node(2), node(3) ],
* 2 [ node(11), node(12), node(13), node(21), node(22), node(23) ],
* 3 [ node(111), node(112), node(113), node(121), node(122), node(123), ... ]
* ]
*/
let matrix: Array<Array<INode>> = [];
/**
* hash {
* [node.id]: node
* ...
* }
*/
let hash = {};
let stack: Array<INode> = [];
// 第一层节点入栈
for (let i = 0, len = treeNodes.length; i < len; i++) {
// 增加属性
treeNodes[i].parentId = rootId;
treeNodes[i].depth = 0; // 从 0 开始
// 剔除 children 属性后存入 matrix
const { children, ...rest } = treeNodes[i];
matrix[0] = matrix[0] || [];
matrix[0].push({ ...rest });
// 保留 children 属性用于遍历
stack.push(treeNodes[i]);
}
let item;
while(stack.length) {
item = stack.shift(); // 出栈
hash[item.id] = item; // 存入 hash
if (item.children && item.children.length) {
item.children.forEach(child => {
// 增加属性
child.parentId = item.id;
child.depth = item.depth + 1;
// 剔除 children 属性后存入 matrix
const { children: _children, ...rest } = child;
matrix[child.depth] = matrix[child.depth] || [];
matrix[child.depth].push({ ...rest });
});
stack = item.children.concat(stack);
}
}
return {
hash,
matrix
};
}
第三步:计算节点位置
主体函数结构如下:
function getLayout(
origin: { x: number, y: number},
offset: { offsetDepth: number, offsetNode: number },
direction: 'top' | 'right' | 'bottom' | 'left' = 'right'
): {
nodes: Array<INode>,
links: Array<ILink>
} {
// codes goes here
}
参数说明:
- origin: 图形坐标轴原点 (x, y), 如下图所示:
- offset:层级和节点偏移量
- direction: 树形排列方向,可选:’top’ | ‘right’ | ‘bottom’ | ‘left’,默认为 ‘right’ 向右排列
const { x, y } = origin;
const { offsetDepth, offsetNode } = offset;
const nodes: Array<INode> = [];
// 计算节点:从底层往上遍历
for (let i = matrix.length - 1; i > -1; i -= 1) {
let row = matrix[i];
let group = {};
let groupNodes: Array<INode> = [];
// 成组
row.forEach((item: INode) => {
if (item.parentId) {
group[item.parentId] = group[item.parentId] || [];
group[item.parentId].push(item);
}
});
Object.keys(group).forEach((item) => {
groupNodes = groupNodes.concat(group[item], [ ...new Array(1) ]) // 组与组之间填充空节点,用于分割
})
let positionNode = y; // 节点位置
let positionDepth = x; // 层级位置
let axisNode = 'y'; // 节点计算轴
let axisDepth = 'x'; // 层级计算轴
let positive = 1; // 排列顺序
if (direction === 'bottom' || direction === 'top') {
positionNode = x;
positionDepth = y;
axisNode = 'x';
axisDepth = 'y';
}
if (direction === 'left' || direction === 'top') positive = -1;
groupNodes.forEach((node: INode) => {
// 层级位置计算
if (node) {
node[axisDepth] = positionDepth + i * offsetDepth * positive;
}
// 节点位置计算
if (node && hash[node.id]) {
// 节点位置计算
if (typeof hash[node.id][axisNode] === 'undefined') {
node[axisNode] = positionNode; // 节点位置
}
// 节点位置修正
if (typeof hash[node.id][axisNode] !== 'undefined') {
node[axisNode] = hash[node.id][axisNode] / hash[node.id].children.length;
}
positionNode = node[axisNode] + offsetNode; // 增加间距
// 叠加子节点位置,存入父节点数据
if (node.parentId && hash[node.parentId]) {
if (typeof hash[node.parentId][axisNode] === 'undefined') {
hash[node.parentId][axisNode] = node[axisNode]
} else {
hash[node.parentId][axisNode] += node[axisNode]
}
}
}
// 空节点增加间距
if (!node) positionNode += offsetNode;
// 存入节点
if (node) nodes.push(node);
});
}
第四步:计算连接线
interface ILink {
source: INode,
target: INode
}
// 计算连接线
const links: Array<ILink> = [];
const map = {};
nodes.forEach(node => {
map[node.id] = node;
})
nodes.forEach(node => {
if (node.parentId && map[node.parentId]) {
links.push({ source: map[node.parentId], target: node});
}
})
// 返回图形数据
return { links, nodes };
最终效果
这样就可以实现任意方向排列的树形图了