项目需要,展示一个超大型交互树形组织结构图,找了很久没有合适的,而且大部分是类似菜单或者基于canvas的(如echarts),并不十分符合要求,遂用svg写了一个vue组件。效果如下
SVG的好处在于交互性,并且样式控制可以基于html css。
核心在于要根据树的数据结构计算节点的x、y坐标。
BLine.vue 用于两点间的贝塞尔曲线:
<template>
<svg width="5000" height="5000">
<path :d="path" stroke="#999" stroke-width="1" fill="none" />
</svg>
</template>
<script>
export default {
name:'BLine',
props:{
x1:{type:Number,default:100},
y1:{type:Number,default:0},
x2:{type:Number,default:40},
y2:{type:Number,default:100},
},
data(){
return{
path:'',
// cx0:this.x2+(this.x1-this.x2)/10,
cx0:this.x2,
cy0:this.y2-(this.y2-this.y1)/10*2,
cx1:this.x2,
cy1:this.y2-(this.y2-this.y1)/10*2,
width:0,
height:0
}
},
mounted(){
this.path='M '+this.x1+' '+this.y1+' C '+this.cx0+' '+this.cy0+', '+this.cx1+' '+this.cy1+', '+this.x2 +' ' + this.y2
}
}
</script>
Children.vue 子组件, 存在递归调用,千万注意死循环问题
<template>
<svg width="5000" height="5000" style="font-size:14px;">
<foreignObject :width="realWidth" :height="height" :x="org.x" :y="org.y">
<div style="display:flex">
<div v-if='org.showDetail' style="text-align:center;height:120px;width:25px;line-height:16px;border:solid 1px #999;color:blue;margin-right:5px" >所长 张三</div>
<div style="text-align:center;height:140px;width:25px;line-height:16px;border:solid 1px #999;color:#000;cursor: pointer;" @click='toggle(org)'>{{org.name}}</div>
</div>
</foreignObject>
<BLine v-for='(item,index) in org.children' :key='"line_"+index' :x1='org.x+width/2' :y1='org.y+height' :x2='x2(item)' :y2='item.y' v-if='org.expand'></BLine>
<Children v-for='(item,index) in org.children' :key='index' :org='item' v-if='org.expand' @toggle='toggle'></Children>
</svg>
</template>
<script>
import BLine from './BLine'
export default {
name:'Children',
components:{
BLine
},
props:{
org:{type:Object,required:true},
},
data(){
return{
width:25,
height:140,
realWidth:25
}
},
computed:{
x2:function(){
return (item)=>{
if(item.showDetail){
return item.x+this.width/2+this.width
}else{
return item.x+this.width/2
}
}
}
},
created(){
if(this.org.showDetail){
this.realWidth=this.width*2+5 //5为gap
}else{
this.realWidth=this.width
}
},
methods:{
toggle(item){
this.$emit('toggle',item)
}
}
}
</script>
OrgTree.vue 主组件
<template>
<div style="font-size:16px;">
<svg :width="5000" :height="height" v-if="loaded">
<foreignObject width="100" height="40" :x="org.x-50" :y="org.y">
<div style="background-color:blue;color:#fff;text-align:center;height:40px;width:100px;line-height:40px;cursor:pointer;" @click='toggle(org)'>{{org.name}}</div>
</foreignObject>
<BLine v-for='(item,index) in org.children' :key='"line0_"+index' :x1='org.x' :y1='org.y+40' :x2='item.x+itemWidth/2' :y2='item.y' v-if='org.expand'></BLine>
<Children v-for='(item,index) in org.children' :key='index' :org='item' v-if='org.expand' @toggle='toggle'></Children>
</svg>
</div>
</template>
<script>
import Children from './Children.vue'
import BLine from './BLine'
export default {
name:'OrgTree',
components:{
Children,BLine
},
props:{
},
data(){
return{
loaded:false,
org:{
name:'**区',
children:[
{
name:'*局',id:11,
children:[
{ name:'派出所1'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'},
]
},
{
name:'区政法委',id:12,
children:[
{ name:'政法委',
children:[
{ name:'派出所'},
{ name:'派出所'},
{ name:'派出所'}
]
},
{ name:'派出所'},
]
},
{
name:'办事处',id:13,
children:[
{ name:'办事处'},
{ name:'办事处'},
]
}
]
},
width:60, // SVG画布的宽度
height:400, // SVG画布的高度
horItemNum:0, // 横向最大节点数
verItemNum:0, // 显示的最大层数
xTemp:{}, // 临时记录每层的最大x坐标,上层定位要用
firstHeight:100, // 第一层的高度(比较特殊)
itemWidth:25, // 元素的宽度
itemHeight:200, // 元素的高度
gap:5, //兄弟之间的间隔
defaultExpandLevel:0, // 从0开始
broGap:10 //表兄弟之间的间隔
}
},
mounted(){
this.setExpand(this.org,0)
this.genLocation()
this.loaded=true
},
methods:{
setExpand(item,level){
if(level<=this.defaultExpandLevel||item.expand){
item.expand=true
}else{
item.expand=false
}
item.showDetail=false
if(item.children){
item.children.forEach(element => {
this.setExpand(element,level+1)
});
}
},
genLocation(){
this.getChildrenLocation(this.org,0)
this.org.minx=0
this.org.x=(this.org.minx+this.org.maxx+this.itemWidth+this.gap)/2
this.org.y=0
// this.width=(this.itemWidth+this.gap)*this.horItemNum
this.width=this.xTemp[this.verItemNum]+this.itemWidth+this.gap+10
this.height=this.itemHeight*(this.verItemNum)+100
console.log(this.org,this.xTemp)
},
getChildrenLocation(item,level,index){
item.maxx=0
if(item.children&&item.children.length>0&&item.expand){
for(let i=0;i<item.children.length;i++){
item.children[i].minx=this.xTemp[level+1]?(this.xTemp[level+1]+this.itemWidth+this.gap+this.broGap):0
this.getChildrenLocation(item.children[i],level+1,i)
item.maxx=item.children[i].maxx
this.xTemp[level] = item.children[i].maxx
}
this.xTemp[level+1]+=this.broGap
item.x=(item.minx+item.maxx)/2
if(level==0){
item.y=0
}else{
item.y=(level-1)*this.itemHeight+this.firstHeight
}
}else{
this.verItemNum=level>this.verItemNum?level:this.verItemNum
this.horItemNum++
if(!this.xTemp[level]){
this.xTemp[level]=0.001
item.minx=0
}else{
this.xTemp[level]+=this.itemWidth+this.gap
item.minx=this.xTemp[level]
}
if(!item.maxx||this.xTemp[level]>item.maxx){
item.maxx=this.xTemp[level]
}
if(!this.xTemp[level+1]){
this.xTemp[level+1]=0.001
}else{
this.xTemp[level+1]+=this.itemWidth+this.gap
}
if(item.showDetail){
this.xTemp[level]+=this.itemWidth+this.gap
// this.xTemp[level+1]+=this.itemWidth+this.gap
}
}
item.x=(item.minx+item.maxx)/2
item.y=(level-1)*this.itemHeight+this.firstHeight
},
toggle(item){
this.loaded=false
item.expand=!item.expand
if(!item.children||item.children.length<0){
item.showDetail=!item.showDetail
}
this.$nextTick(()=>{
this.horItemNum=0
this.verItemNum=0
this.xTemp={}
this.org.x=0
this.genLocation()
this.loaded=true
})
},
}
}
</script>
数据是写死的,可以改成后台接口获取。某些数值没有参数化,后面再说吧。