基于SVG的树图Vue组件

项目需要,展示一个超大型交互树形组织结构图,找了很久没有合适的,而且大部分是类似菜单或者基于canvas的(如echarts),并不十分符合要求,遂用svg写了一个vue组件。效果如下

基于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>

 数据是写死的,可以改成后台接口获取。某些数值没有参数化,后面再说吧。

上一篇:牛客网错题集--HTML、CSS试题(4)


下一篇:【SVG.js实战篇】01-Vue中优雅的使用SVG.js