最近在做企业风控相关的产品,常常涉及企业上下游行业、企业关联关系等信息的展示,为了更好地表达这些关系,我们常常使用树形图来描述这些关系,如下图所示:

image.png
我们期望这个树形布局有以下特点:

  • 同一层级节点在同一水平线上;
  • 父节点位置始终在子节点中间位置;
  • 子节点不允许重叠;
  • 相邻节点有一定间距,且不同父节点的相邻节点加大间距区分。

传统的树形布局通常将子节点按照兄弟节点的最大宽度分开(一个节点的最大宽度即以该节点为根的树所占的最大宽度)。这样做虽然代码上容易实现,只要在每个节点保存该子树的最大宽度即可,但是视觉上不够美观,比较浪费空间。在某些传统布局甚至限制子节点的数目。

而这种布局与传统的树形布局有很大不同,它做到了在布局上节点与节点尽可能地紧凑,而且始终保持对称性和任意子节点数目。

实现我们期望的树形布局,最关键的一步是怎么从最底层一步步推导出全树节点的位置。

算法的实现步骤如下:

  1. 遍历树形结构数据,得出树形节点 id 和 节点的映射关系、以及一个二维数组保存了每一个层级的节点;
  2. 遍历二维数组:计算底层节点位置,向上修正父节点位置,得出节点数组;
  3. 遍历节点数组,计算出连接线数组,得出图形数据。

接下来我们就通过代码来具体实现:

第一步:数据准备

我们先准备一份树形结构数据:

  1. const treeNodes = [
  2. {
  3. id: '0',
  4. name: '0',
  5. children: [
  6. {
  7. id: '1',
  8. name: '1',
  9. children: [
  10. {
  11. id: '11',
  12. name: '11',
  13. children: [
  14. {
  15. id: '111',
  16. name: '111'
  17. },
  18. {
  19. id: '112',
  20. name: '112'
  21. },
  22. {
  23. id: '113',
  24. name: '113'
  25. },
  26. {
  27. id: '114',
  28. name: '114'
  29. }
  30. ]
  31. },
  32. {
  33. id: '12',
  34. name: '12',
  35. children: [
  36. {
  37. id: '121',
  38. name: '121'
  39. },
  40. {
  41. id: '122',
  42. name: '122'
  43. },
  44. ]
  45. }
  46. ]
  47. },
  48. {
  49. id: '2',
  50. name: '2',
  51. children: [
  52. {
  53. id: '22',
  54. name: '22',
  55. children: [
  56. {
  57. id: '221',
  58. name: '221'
  59. },
  60. {
  61. id: '222',
  62. name: '222'
  63. },
  64. {
  65. id: '223',
  66. name: '223'
  67. },
  68. ]
  69. },
  70. {
  71. id: '23',
  72. name: '23',
  73. children: [
  74. {
  75. id: '231',
  76. name: '231'
  77. }
  78. ]
  79. }
  80. ]
  81. }
  82. ]
  83. }
  84. ];

第二步:计算层级

定义节点数据类型

  1. interface INode {
  2. id: string | number,
  3. name: string | number,
  4. children?: Array<INode>,
  5. parentId?: string | number,
  6. depth?: number,
  7. x?: number,
  8. y?: number
  9. }

遍历数据,计算出节点二维数组,以及节点 hash 表,用于映射节点:

  1. export function iterator(treeNodes: Array<INode>, rootId = 'root'): {
  2. matrix: Array<Array<INode>>,
  3. hash: {}
  4. } | void {
  5. if (!treeNodes || !treeNodes.length) return;
  6. /**
  7. * matrix [
  8. * 0 [ node(0) ],
  9. * 1 [ node(1), node(2), node(3) ],
  10. * 2 [ node(11), node(12), node(13), node(21), node(22), node(23) ],
  11. * 3 [ node(111), node(112), node(113), node(121), node(122), node(123), ... ]
  12. * ]
  13. */
  14. let matrix: Array<Array<INode>> = [];
  15. /**
  16. * hash {
  17. * [node.id]: node
  18. * ...
  19. * }
  20. */
  21. let hash = {};
  22. let stack: Array<INode> = [];
  23. // 第一层节点入栈
  24. for (let i = 0, len = treeNodes.length; i < len; i++) {
  25. // 增加属性
  26. treeNodes[i].parentId = rootId;
  27. treeNodes[i].depth = 0; // 从 0 开始
  28. // 剔除 children 属性后存入 matrix
  29. const { children, ...rest } = treeNodes[i];
  30. matrix[0] = matrix[0] || [];
  31. matrix[0].push({ ...rest });
  32. // 保留 children 属性用于遍历
  33. stack.push(treeNodes[i]);
  34. }
  35. let item;
  36. while(stack.length) {
  37. item = stack.shift(); // 出栈
  38. hash[item.id] = item; // 存入 hash
  39. if (item.children && item.children.length) {
  40. item.children.forEach(child => {
  41. // 增加属性
  42. child.parentId = item.id;
  43. child.depth = item.depth + 1;
  44. // 剔除 children 属性后存入 matrix
  45. const { children: _children, ...rest } = child;
  46. matrix[child.depth] = matrix[child.depth] || [];
  47. matrix[child.depth].push({ ...rest });
  48. });
  49. stack = item.children.concat(stack);
  50. }
  51. }
  52. return {
  53. hash,
  54. matrix
  55. };
  56. }

第三步:计算节点位置

主体函数结构如下:

  1. function getLayout(
  2. origin: { x: number, y: number},
  3. offset: { offsetDepth: number, offsetNode: number },
  4. direction: 'top' | 'right' | 'bottom' | 'left' = 'right'
  5. ): {
  6. nodes: Array<INode>,
  7. links: Array<ILink>
  8. } {
  9. // codes goes here
  10. }

参数说明:

  • origin: 图形坐标轴原点 (x, y), 如下图所示:

image.png

  • offset:层级和节点偏移量

image.pngimage.png

  • direction: 树形排列方向,可选:’top’ | ‘right’ | ‘bottom’ | ‘left’,默认为 ‘right’ 向右排列
  1. const { x, y } = origin;
  2. const { offsetDepth, offsetNode } = offset;
  3. const nodes: Array<INode> = [];
  4. // 计算节点:从底层往上遍历
  5. for (let i = matrix.length - 1; i > -1; i -= 1) {
  6. let row = matrix[i];
  7. let group = {};
  8. let groupNodes: Array<INode> = [];
  9. // 成组
  10. row.forEach((item: INode) => {
  11. if (item.parentId) {
  12. group[item.parentId] = group[item.parentId] || [];
  13. group[item.parentId].push(item);
  14. }
  15. });
  16. Object.keys(group).forEach((item) => {
  17. groupNodes = groupNodes.concat(group[item], [ ...new Array(1) ]) // 组与组之间填充空节点,用于分割
  18. })
  19. let positionNode = y; // 节点位置
  20. let positionDepth = x; // 层级位置
  21. let axisNode = 'y'; // 节点计算轴
  22. let axisDepth = 'x'; // 层级计算轴
  23. let positive = 1; // 排列顺序
  24. if (direction === 'bottom' || direction === 'top') {
  25. positionNode = x;
  26. positionDepth = y;
  27. axisNode = 'x';
  28. axisDepth = 'y';
  29. }
  30. if (direction === 'left' || direction === 'top') positive = -1;
  31. groupNodes.forEach((node: INode) => {
  32. // 层级位置计算
  33. if (node) {
  34. node[axisDepth] = positionDepth + i * offsetDepth * positive;
  35. }
  36. // 节点位置计算
  37. if (node && hash[node.id]) {
  38. // 节点位置计算
  39. if (typeof hash[node.id][axisNode] === 'undefined') {
  40. node[axisNode] = positionNode; // 节点位置
  41. }
  42. // 节点位置修正
  43. if (typeof hash[node.id][axisNode] !== 'undefined') {
  44. node[axisNode] = hash[node.id][axisNode] / hash[node.id].children.length;
  45. }
  46. positionNode = node[axisNode] + offsetNode; // 增加间距
  47. // 叠加子节点位置,存入父节点数据
  48. if (node.parentId && hash[node.parentId]) {
  49. if (typeof hash[node.parentId][axisNode] === 'undefined') {
  50. hash[node.parentId][axisNode] = node[axisNode]
  51. } else {
  52. hash[node.parentId][axisNode] += node[axisNode]
  53. }
  54. }
  55. }
  56. // 空节点增加间距
  57. if (!node) positionNode += offsetNode;
  58. // 存入节点
  59. if (node) nodes.push(node);
  60. });
  61. }

第四步:计算连接线

  1. interface ILink {
  2. source: INode,
  3. target: INode
  4. }
  1. // 计算连接线
  2. const links: Array<ILink> = [];
  3. const map = {};
  4. nodes.forEach(node => {
  5. map[node.id] = node;
  6. })
  7. nodes.forEach(node => {
  8. if (node.parentId && map[node.parentId]) {
  9. links.push({ source: map[node.parentId], target: node});
  10. }
  11. })
  12. // 返回图形数据
  13. return { links, nodes };

最终效果

这样就可以实现任意方向排列的树形图了

image.pngimage.png

image.pngimage.png