动手实现一个组织架构图

4,465 阅读3分钟

之前在工作中遇到了实现组织架构图的需求,想整理一下思路,或许也可以帮助到有类似需求的同学。

需求

实现一个基本组织架构图,至少需要满足以下几点:架构图节点大小为动态,支持跨层级连接,支持缩放平移,导出SVG文件、PDF文件。 除去节点的渲染逻辑和交互逻辑之后,大致效果如下图:

org-1.png

分析

因为节点的渲染逻辑和交互逻辑比较复杂,直接使用现成的树组件可能无法很好地满足需求,所以打算自己实现一个树组件。确定了基本方向之后,首先是需要确定树的数据结构。对于一般的树而言,可以用id、pid关联的一维数组表示,也可以用树形结构来表示。考虑到用数组不够直观,所以我们直接用树形结构表示。从渲染的实现上,可以用div、canvas、svg方案,因为导出的文件放大后不能失真,所以我们采用svg来渲染。

实现

以下将使用vue2来实现。

创建项目

vue create org-tree-example

创建完项目之后,在components目录中新建一个Org.vue文件:

<template>
  <div class="org-container">
    <svg v-if="data"></svg>
    <span v-else>暂无数据</span>
  </div>
</template>

<script>
export default {
  name: 'Org',
  props: {
    dataSource: {
      type: Object,
    }
  }
}
</script>

<style>
.org-container {
  height: 100%;
}
.org-container svg {
  width: 100%;
  height: 100%;
}
</style>

App.vue中使用Org.vue组件:

<template>
  <div id="app">
    <Org :dataSource="orgData"/>
  </div>
</template>

<script>
import Org from './components/Org.vue'

export default {
  name: 'App',
  components: {
    Org
  },
  data() {
    return {
      orgData: null
    }
  }
}
</script>

<style>
html, body {
  margin: 0;
  padding: 0
}
#app {
  height: 100vh;
}
</style>

布局算法

因为d3.tree()只支持固定大小的节点,不满足需求,不过我在issue里面找到一个支持动态大小节点的库d3-flextree。安装相关依赖:

yarn add d3 d3-flextree lodash.clonedeep

对于树形数据,需要保证有size属性,值是一个数组,表示节点的宽和高。

创建utils/genTreeLayout.js文件:

import { flextree } from 'd3-flextree';
import clonedeep from 'lodash.clonedeep';

const genTreeLayout = (
  data,
  {
    spaceX, // 水平方向节点间距
    spaceY, // 垂直方向节点间距
  }
) => {
  if (typeof data !== 'object') {
    throw new Error('请检查数据');
  }

  // 深拷贝,防止污染原对象
  const dataCopy = clonedeep(data);

  // 深度优先遍历,更新节点size属性,因为d3-flextree的节点高度包含节点下方的间距
  const dfs = (root) => {
    const [width, height] = root.size;
    root.size = [width, height + spaceY];
    root.children.forEach(dfs);
  };
  dfs(dataCopy);

  const layout = flextree({ spacing: spaceX });
  const tree = layout.hierarchy(data);
  const nodes = layout(tree);
  const descendants = nodes.descendants();
  const { left, top } = nodes.extents;

  // 节点和线条数据
  // 注意:节点实际显示高度需减去垂直方向节点间距
  // x和y坐标需要修正偏移量
  const nodesData = descendants.map((node) => ({
    ...node,
    x: node.x - left,
    y: node.y - top,
    width: node.xSize,
    height: node.ySize - spaceY,
  }));

  const linesData = descendants.slice(1).map((node) => ({
    x1: node.parent.x - left,
    y1: node.parent.y - top + node.parent.ySize - spaceY,
    x2: node.x - left,
    y2: node.y - top,
  }));

  return {
    nodesData,
    linesData,
  };
};

export default genTreeLayout;

渲染页面

先新建mock数据文件treeData.js,以测试布局算法是否可用:

export default {
  id: '2021033001',
  size: [60, 50],
  children: [
    {
      id: '2021033002',
      size: [80, 50],
      children: [
        {
          id: '2021033003',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033004',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033005',
          size: [30, 120],
          children: [],
        },
      ],
    },
    {
      id: '2021033006',
      size: [80, 50],
      children: [
        {
          id: '2021033007',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033008',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033009',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033010',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033011',
          size: [30, 120],
          children: [],
        },
      ],
    },
    {
      id: '2021033013',
      size: [100, 50],
      children: [
        {
          id: '2021033014',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033015',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033016',
          size: [30, 120],
          children: [],
        },
        {
          id: '2021033017',
          size: [30, 120],
          children: [],
        },
      ],
    },
  ],
};

App.vueOrg.vue分别修改如下:

<template>
  <div id="app">
    <Org :dataSource="orgData" />
  </div>
</template>

<script>
import Org from './components/Org.vue';
import treeData from './treeData';

export default {
  name: 'App',
  components: {
    Org,
  },
  data() {
    return {
      orgData: treeData,
    };
  },
};
</script>

<style>
html,
body {
  margin: 0;
  padding: 0;
}
#app {
  height: 100vh;
}
</style>
<template>
  <div class="org-container">
    <svg>
      <g class="container">
        <!-- 线条 -->
        <path
          v-for="d in linesData"
          :key="d.id"
          :d="`M${d.x1} ${d.y1} V${d.y1 + (d.y2 - d.y1) / 2} H${d.x2} V${d.y2}`"
          class="line"
        />

        <!-- 节点,这里g元素往左偏移自身一半宽度以保证居中 -->
        <g
          v-for="d in nodesData"
          :key="d.id"
          :transform="`translate(${d.x - d.width / 2}, ${d.y})`"
          class="node"
        >
          <rect :width="d.width" :height="d.height" />

          <!-- 这里是自定义节点内容,节点原始数据都在d.data中 -->
          <!-- <Box :data="d.data" /> -->
        </g>
      </g>
    </svg>
  </div>
</template>

<script>
import genTreeLayout from '../utils/genTreeLayout';

export default {
  name: 'Org',
  props: {
    dataSource: {
      type: Object,
    },
  },
  data() {
    return {
      nodesData: [],
      linesData: [],
    };
  },
  watch: {
    dataSource: {
      handler(treeData) {
        this.updateTree(treeData);
      },
      immediate: true,
    },
  },
  methods: {
    updateTree(treeData) {
      if (!treeData) {
        return;
      }
      const { nodesData, linesData } = genTreeLayout(treeData, {
        spaceX: 20,
        spaceY: 20,
      });
      this.nodesData = nodesData;
      this.linesData = linesData;
    },
  },
};
</script>

<style>
.org-container {
  height: 100%;
}
svg {
  width: 100%;
  height: 100%;
}
svg .line {
  fill: none;
  stroke: #409EFF;
}
svg .node rect {
  fill: rgba(64, 158, 255, 0.09);
  stroke: #409EFF;
}
</style>

最终效果如下:

image.png

基本的骨架就出来了,但是内容不能很好的适应页面,下面处理一下这个问题。

在svg中,自适应是很容易的,指定svg元素的viewBoxpreserveAspectRatio即可。由于preserveAspectRatio默认值为xMidYMid(将SVG元素的viewbox属性的X的中点值与视图的X的中点值对齐,将SVG元素的viewbox属性的Y的中点值与视图的Y的中点值对齐),所以不用显示指定。

genTreeLayout函数中,需要返回内容的边界信息,这样就可以根据这些信息生成viewBox了。

// genTreeLayout.js
...
const { left, top, right, bottom } = nodes.extents;
...
return {
    ...,
    layoutExtends: {
      minX: 0,
      minY: 0,
      width: right - left,
      height: bottom - top - spaceY
    }
}

Org.vue中,添加如下代码:

const { nodesData, linesData, layoutExtends } = genTreeLayout(treeData, {
    spaceX: 20,
    spaceY: 20,
});
const { minX, minY, width, height } = layoutExtends;
this.nodesData = nodesData;
this.linesData = linesData;
this.viewBox = `${minX - PADDING} ${minY - PADDING} ${width + PADDING * 2} ${height + PADDING * 2}`;

这里的PADDING是一个常量,表示内边距,这样看起来更美观一些,调整后的效果如下:

image.png

调整浏览器窗口大小时,内容也能很好地适应。

缩放与平移

这里直接使用d3.js实现

const zoom = d3
    .zoom()
    .scaleExtent([0.5, 2])
    .on('zoom', (event) => {
      d3.select('g.container').attr('transform', event.transform);
    });

d3.select(this.$refs.svgRef)
    .call(zoom.transform, d3.zoomIdentity)
    .call(zoom);

自定义节点内容

修改测试数据,添加department属性,表示该节点代表的部门,调整Org.vue文件:

<!-- 节点 -->
<g
  v-for="d in nodesData"
  :key="d.id"
  :transform="`translate(${d.x}, ${d.y})`"
  class="node"
>
  <rect :x="-d.width / 2" :width="d.width" :height="d.height" />

  <!-- 这里是自定义节点内容,节点原始数据都在d.data中 -->
  <text v-if="d.depth <= 1" y="20">{{ d.data.department }}</text>
  <text v-else>
    <tspan v-for="(t, i) in d.data.department" :key="i" x="0" dy="18">{{t}}</tspan>
  </text>
</g>

最终效果如下: image.png

写在最后

纵观全文,最核心的其实是布局算法部分,有了布局之后,节点、线条的内容和样式以及交互完全可以自定义。跨层结构的实现,稍微修改一下布局函数就可以了。至于PDF导出,可以使用pdfkit这个库来实现。
线上地址:org-tree-example.vercel.app
源码:github.com/reactjser/o…