之前在工作中遇到了实现组织架构图的需求,想整理一下思路,或许也可以帮助到有类似需求的同学。
需求
实现一个基本组织架构图,至少需要满足以下几点:架构图节点大小为动态,支持跨层级连接,支持缩放平移,导出SVG文件、PDF文件。 除去节点的渲染逻辑和交互逻辑之后,大致效果如下图:
分析
因为节点的渲染逻辑和交互逻辑比较复杂,直接使用现成的树组件可能无法很好地满足需求,所以打算自己实现一个树组件。确定了基本方向之后,首先是需要确定树的数据结构。对于一般的树而言,可以用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.vue
和Org.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>
最终效果如下:
基本的骨架就出来了,但是内容不能很好的适应页面,下面处理一下这个问题。
在svg中,自适应是很容易的,指定svg元素的viewBox和preserveAspectRatio即可。由于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
是一个常量,表示内边距,这样看起来更美观一些,调整后的效果如下:
调整浏览器窗口大小时,内容也能很好地适应。
缩放与平移
这里直接使用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>
最终效果如下:
写在最后
纵观全文,最核心的其实是布局算法部分,有了布局之后,节点、线条的内容和样式以及交互完全可以自定义。跨层结构的实现,稍微修改一下布局函数就可以了。至于PDF导出,可以使用pdfkit这个库来实现。
线上地址:org-tree-example.vercel.app
源码:github.com/reactjser/o…