Echarts 实现拓扑图

2,096 阅读4分钟

echarts 实现拓扑图

效果图

image.png

<template>
  <div class="echart-block">
    <!-- echarts容器 -->
    <div style="height:100%" ref="graphchart"></div>
  </div>
</template>

<script>
import { nodeData } from '@/views/nodeData.js'

export default {
  name: 'Echarts1',
  data () {
    return {
      echarts: null,
      nodes: [], // 节点
      links: [] // 连线
    }
  },
  mounted () {
    this.drawChart()
  },
  methods: {
    // 画出拓扑图
    drawChart () {
      // 第一步:初始化 nodes,就是每个节点
      nodeData.ne.forEach(value => {
        this.nodes.push({
          'name': value.nename,
          'symbol': 'circle'
        })
      })
      // 第二步,添加节点关系的数据
      nodeData.neToNe.forEach(value => {
        this.links.push({
          'source': value.nename,
          'target': value.snkNeName
        })
      })
      // 初始化echarts对象
      this.echarts = this.$echarts.init(this.$refs.graphchart)
      // 写配置
      let option = {
        series: [
          {
            // 关系图类型
            type: 'graph',
            // 鼠标放上去相关节点高亮显示
            focusNodeAdjacency: true,
            // 布局 力引布局。两个节点之间添加一个斥力,每条边的两个节点之间添加一个引力。力引导布局的结果有良好的对称性和局部聚合性,也比较美观
            layout: 'force',
            // 力引布局初始配置
            force: {
              initLayout: 'circular',// 初始化布局为环行布局
              layoutAnimation: false,// 迭代动画
              repulsion: 4000, // 节点之间的斥力因子
              edgeLength: 100,// 边的两个节点之间的距离
            },
            // 节点大小
            symbolSize: 60,
            // 是否开启鼠标缩放和平移漫游
            roam: true,
            // 当前视角的缩放比例
            zoom: 0.6,
            // 节点name
            label: {
              show: true,
              fontSize: 16,
            },
            // 节点颜色
            color: ['#66b1ff'],
            // 连接线两端的标记类型
            edgeSymbol: ['circle', 'arrow'],
            // 连接线两端的标记大小
            edgeSymbolSize: [2, 8],
            // 节点数据
            data: this.nodes,
            // 连接线
            links: this.links,
          }
        ]
      }
      // 挂载
      this.echarts.setOption(option)
      this.onClickEcharts() // 添加点击事件
    },
    /**
     * 点击图表
     */
    onClickEcharts() {
      this.echarts.on('click', function (params) {
        // 点击功能
        alert(params.name)
      })
    }
  }
}
</script>
<style scoped>
.echart-block {
  height: 100vh;
}
</style>

进阶一下,完善功能

  1. 渲染拓扑视图
  2. 点击空白处显示所有节点
  3. 单击节点,显示有关系的节点
  4. 单 / 双击节点触发单 / 双击事件
  5. 下拉框显示节点
  6. 点击连接线事件
export default {
  name: 'index',
  data() {
    return {
      // 节点容器
      dataArr: [],
      // 指向关系
      liArr: [],
      // 节点数组,下拉框使用
      listNe: [],
      echart: null,
      // 当前选中节点
      srcname: null,
      // 当前锁定节点
      locked: null, // 锁定状态
      clickCount: 0, // 默认就是0,表示单击次数(用于判断单击和双击)
      lastClickTime: 0,// 上次单击时间
      clickTimeout: null // 单击超时
    }
  },
  created() {
    // 获取节点
    this.neList()
  },
  mounted() {
    // 绘制拓扑图
    this.drawChart(this.dataArr)
  },
  methods: {
  
    // 拿节点数据
    neList() {
      getneList().then(res => this.listNe = res.data)
    },
    
    
    drawChart(data) {
      getTopList().then(res => { // 发送请求获取指向关系和节点
      
        res.data.ne.forEach(value => {
        
          // 根据数据开始配置节点
          this.dataArr.push({
            name: value.nename, // 节点名称
            symbol: 'circle',   // 节点形状
            neid: value.neid,   // 唯一ID--可以根据需求自定义
            itemStyle: { color: '#66b1ff' }, // 配置颜色
            layout: 'force', // force 布局
            highlighted: true // 是否显示,默认显示状态
          })
        })
        
        // 配置指向关系
        res.data.neToNe.forEach((value) => { // 遍历指向关系
        
          // 设置透明度默认是1
          let opacityValue = 1
          
          // 如果degree为null表示正常
          if (value.degree === null) {
            value.degree = 'black' // 颜色
            opacityValue = 0.05    // 透明度
          }
          
          this.liArr.push({
            'source': value.nename,    // 原节点
            'target': value.snkNeName, // 指向节点
            // 设置颜色和透明度
            'lineStyle': { color: value.degree, opacity: opacityValue }, 
            'item': value // 自定义数据,可根据需求
          })
        })
        
        // 初始化并赋值
        this.echart = this.$echarts.init(this.$refs.myEcharts)
        
        let option = {
          series: [
            {
              type: 'graph',    // graph 图形
              layout: 'force',  // force 算法布局
              focusNodeAdjacency: true, // 鼠标放上去相关节点高亮显示
              roam: true, // 可拖动缩放
              scaleLimit: { // 设置缩放范围限制
                min: 0.3,
                max: 1
              },
              force: {
                initLayout: 'circular',  // 环形布局
                layoutAnimation: false,  // 迭代动画
                repulsion: 7000,    // 节点之间的斥力因子
                edgeLength: 1600    // 边的两个节点之间的距离
              },
              emphasis: {
                focus: false // 关闭鼠标经过高亮
              },
              symbolSize: 60,  // 元素默认大小
              label: {
                show: true,   // 打开标签显示
                fontSize: 16  // 标签字体大小
              },
              edgeSymbol: ['arrow', 'arrow'],  // 表示连接线两边都是箭头
              edgeSymbolSize: [8, 8],          // 连接线箭头大小
              data: data,              // 赋值数据和指向关系
              links: this.liArr
            }
          ]
        }
        
        // 渲染视图
        this.echart.setOption(option)
        
        // 点击空白处事件
        this.echart.getZr().on('click', (params) => {
        
          // 表示点击的不是事件源,并且当前没有锁定元素,才触发
          if (!params.target && this.locked !== null) {
            // 重新渲染
            this.restNode()
            // 设置非锁定
            this.locked = null
            this.srcname = null // 关闭下拉框选中
          }
        })

        // 点击非空白处
        this.echart.on('click', (param) => {
          // 双击事件
          if (this.clickCount === 2) {
            this.dbClick(param)
          } else {
            // 当前单击事件
            const currentTime = new Date().getTime()
            
            // 点击超时时间不为空和不超过500,表示双击事件,否则单击
            if (this.clickTimeout !== null && currentTime - this.lastClickTime <= 500) {
              // 双击事件
              this.dbClick(param)
            } else {
              this.clickTimeout = setTimeout(() => {
                    
                //  加载元素    
                this.nodeLoading(param.data.name)

                this.clickCount = 0 // 清零计数
                this.clickTimeout = null // 时间清零
              }, 300)
            }
            // 上次点击时间更新
            this.lastClickTime = currentTime
          }
        })
      })
    },
    
    
    // 下拉框
    changeList(val) {
      // 如果为空,表示显示所有节点
      if (val === '') {
        this.restNode()
        return
      }
      
      // 选中当前选择的节点
      this.nodeLoading(val)
    },

    // 加载节点的方法
    nodeLoading(name) {
      // 等于说明点击的一样,无需渲染
      if (name === this.locked) return
      // 单击连接线,不做操作,点击连接线是没有name字段的所以是undefined
      if (name === undefined) return

      // 锁定当前节点
      this.locked = name

      const option = this.echart.getOption()
      // 数据---这里必须深克隆
      const nodes = JSON.parse(JSON.stringify(this.dataArr))
      // 节点链接线---这里必须深克隆
      const links = JSON.parse(JSON.stringify(this.liArr))

      // 将当前节点及相关连接的节点加入集合中(存入有关节点名字)
      const linkedSet = links.reduce((set, link) => {
        if (link.source === name || link.target === name) {
          set.add(link.source)
          set.add(link.target)
        }
        return set
      }, new Set())

      const currentLink = new Set()
      
      // 根据有关节点名称获取节点
      const currentNode = nodes.filter(node => linkedSet.has(node.name))

      // 将不在linkedSet集合中的node元素的highlighted属性设置为false
      // 表示不需要显示
      nodes.filter(node => !linkedSet.has(node.name)).forEach(node => {
        node.highlighted = false
      })

      // 遍历集合中的节点及其连接,并进一步更新集合
      // (获得的是所有节点与当前节点有关系的节点链接线)
      Array.from(linkedSet).forEach(currentName => {
        nodes.forEach(node => {
          // 如果元素的名字等于当前元素,将他设置为true
          if (node.name === currentName) {
            // 遍历当前节点连接的所有链接,将这些链接的源和目标节点的名称添加到集合中
            links.forEach(link => {
              if (link.source === currentName || link.target === currentName) {
                linkedSet.add(link.source)
                linkedSet.add(link.target)
              }
            })
          }
        })
      })

      // 将没有关系的节点隐藏掉
      links.forEach(link => {
        if (isNodeVisible(link.source) && isNodeVisible(link.target)) {
          let co = 'black'
          if (link.lineStyle.color === 'red') co = 'red'
          link.lineStyle = {
            normal: {
              color: co,  // 默认连接线的颜色
              width: 2  // 默认连接线的宽度
            },
            emphasis: {
              color: co,  // 高亮连接线的颜色
              width: 3  // 高亮连接线的宽度
            }
          }
          currentLink.add(link)
        }
      })

      // 判断节点是否存在关系
      function isNodeVisible(name) {
        const node = nodes.find(node => node.name === name)
        return node && node.highlighted === true
      }

      option.series[0].links = Array.from(currentLink)
      option.series[0].data = Array.from(currentNode)

      // 选中元素
      this.srcname = name

      this.restShow(option)
    },

    // 还原所有节点到初始状态
    restNode() {
      // 将全部节点显示出来
      const option = this.echart.getOption()
      option.series[0].links = this.liArr
      option.series[0].data = this.dataArr
      this.restShow(option)
    },

    // 将最新数据更新
    restShow(option) {
      this.echart.setOption(option)
      this.echart.resize()
    },
    dbClick(param) {
      // 将其他节点全部异常
      if (param.dataType === 'node') {
        // 点击节点事件
      }
      if (param.dataType === 'edge') {
        // 点击连接线事件
      }
      this.clickCount = 0 // 清零
      clearTimeout(this.clickTimeout)
      this.clickTimeout = null
    }

  }
}
</script>

上面的方式定位会出现问题,修复定位

主组件

<template>
  <div class="echarts-block">
    <el-row>
      <!--拓扑图组件-->
      <!--缓存该组件,AllGraph-->
      <keep-alive include="AllGraph">
        <component :is="currentName" @switchComponent="updateChart"
        @doubleClick="getElementOrNode" :dataArr="dataArr"
        :links="links" ref="child" :selected="selected" :listNe="listNe"
        ></component>
      </keep-alive>
    </el-row>


  </div>
</template>

<script>
import AllGraph from '@/components/AllGraph/AllGraph.vue'
import index2 from '@/components/AllGraph/index2.vue'
import eventBus from '@/utils/event-bus'
import drawer from '@/components/AllGraph/drawer.vue'

export default {
  name: 'index',
  data() {
    return {
      currentName: 'AllGraph',
      dataArr: null, // 节点数据
      links: null,   // 连接线数据
      clickCount: 0, // 默认就是0,表示单击次数(用于判断单击和双击)
      lastClickTime: 0,// 上次单击时间
      clickTimeout: null, // 单击超时
      selected: null, // 下拉框选中的元素

      display: false,
      drawerWidth: '1235px'
    }
  },
  components: { drawer, AllGraph, index2 },
  methods: {
    // 切换展示方法
    updateChart(val, name) {
      if (val === undefined) {
        this.currentName = 'AllGraph'
        this.selected = null
      } else {
        this.dataArr = val.data
        this.links = val.links
        this.currentName = 'index2'
      }
    },
    // 双击之后的获取数据
    getElementOrNode(param) {
      // 双击事件
      if (this.clickCount === 2) {
        this.dbClick(param)
      } else {
        const currentTime = new Date().getTime() // 获取当前时间毫秒
        if (this.clickTimeout !== null && currentTime - this.lastClickTime <= 500) {
          this.dbClick(param) // 双击事件
        } else {
          this.clickTimeout = setTimeout(() => {
            // 点击节点没有反应
            if (param.dataType === 'edge') return
            if (this.currentName === 'index2' && this.selected !== param.data.name) {
              // 更新显示节点元素
              this.showOrHideElement(param.data.name)

            } else if (this.currentName === 'AllGraph') {
              // 调用子组件根据元素名称获取数据
              const series = this.$refs.child.nodeLoading(param.data.name, 1).series[0]
              // 单击了组件---切换组件
              this.updateChart(series, param.data.name)
            }
            // 更新当前选中元素
            this.selected = param.data.name
            this.clickCount = 0 // 清零计数
            this.clickTimeout = null // 时间清零
          }, 300)
        }
        this.lastClickTime = currentTime
      }
    },
    
    // 双击事件处理函数
    dbClick(param) {
      // 双击了元素
      if (param.dataType === 'node') {

      }
      // 双击了节点
      if (param.dataType === 'edge') {

      }
      this.clickCount = 0 // 清零
      clearTimeout(this.clickTimeout)
      this.clickTimeout = null
    },

    // 组件中更新echarts元素
    // 下拉框选中元素,或者点击了元素
    showOrHideElement(name) {
      // 如果为空表示返回首页组件
      if (name === '') {
        // 跳转到首页---获取数据
        this.currentName = 'AllGraph'
        return
      }
      // 判断当前页面是不是不等于 index2,就切换到index2,显示元素
      if (this.currentName !== 'index2') {
        const series = this.$refs.child.nodeLoading(name, 1).series[0]
        // 单击了组件---切换组件
        this.updateChart(series, name)
      } else {
        // 跳转页面获取数据,最后重新渲染当前index2的元素
        eventBus.$emit('goIndex', name)
      }
    },
    // 移动方法
    onMouseMoveEntry() {
      // 表示调用方法,将抽屉打开
      this.$refs.drawer.changeXY(0)
      // 下面两个是将文字居中
      this.$refs.neTitleAssay.style.textAlign = 'center'
      this.$refs.predictionTitleAssay.style.textAlign = 'center'
    },
    onMouseMoveOut() {
      // 表示调用方法,将抽屉关闭
      this.$refs.drawer.changeXY(-830)
      // 下面两个是将文字不居中
      this.$refs.neTitleAssay.style.textAlign = null
      this.$refs.predictionTitleAssay.style.textAlign = null
    }
  }

}
</script>

<style scoped>

.echarts-block {
  position: relative;
}

.select-tool {
  position: absolute;
  top: 10px;
  left: 20px;
}
</style>

默认显示的组件 AllGraph

<template>
  <div>
    <div class="echarts-block">
      <!--Echarts渲染的位置-->
      <div id="myEcharts" ref="myEcharts" style="width:100%;height: 100vh;"></div>
    </div>
  </div>
</template>

<script>

import eventBus from '@/utils/event-bus'

export default {
  name: 'AllGraph',
  data() {
    return {
      // 元素容器
      dataArr: [],
      // 指向关系
      liArr: [],
      echart: null
    }
  },
  created() {
    // 在显示相邻元素的页面触发方法,获取渲染数据
    eventBus.$on('goIndex', (val) => {
      this.rerenderEChartsByName(val, 0)
    })
  },
  mounted() {
    // 初始化echarts
    this.drawChart(this.dataArr)
  },
  methods: {
    drawChart(data) {
      // 调用接口获取数据
      getTopList().then(res => {
        // 配置元素信息
        res.data.ne.forEach(value => {
          let color = '#66b1ff' // 默认颜色
          if (value.degree === 'red') color = 'red' // 修改为红色
          this.dataArr.push({
            name: value.nename, // 元素名称
            symbol: 'circle', // 元素形状
            neid: value.neid, // 自定义信息
            itemStyle: { color: color }, // 元素样式
            layout: 'force', // 布局方式 force算法
            highlighted: true // 是否高亮
          })
        })
        
        // 连接关系配置
        res.data.neToNe.forEach((value, index) => {
          let opacityValue = 0.07 // 连接线默认透明度

          // 渐变色
          let accessArr = [] // 渐变色每一段默认为 1 / 分段数量
          let len = 1.0 / value.accessList.length

          // 计算每个渐变段的颜色以及所属的 Access 对象
          if (value.accessList.length !== 0) {
            value.accessList.forEach(value => {
              let color = 'gray'
              if (value.colors === 'red') {
                color = 'red'
                // 若 access 的颜色为 red,线条的透明度改为不透明
                opacityValue = 1
              }
              // 添加渐变段
              accessArr.push({ offset: len, color: color, 'access': value })
            })
          } else {
            // 如果 accessList 为空,则添加一个默认的渐变段
            accessArr[0] = { offset: 0, color: 'gray', 'access': null }
          }

          this.liArr.push({
            source: value.nename,
            target: value.snkNeName,
            lineStyle: {
              color: {
                // 将渐变配置赋值给线条颜色
                colorStops: accessArr
              },
              // 将透明度信息赋给线条透明度
              opacity: opacityValue
            },
            // 自定义对象
            'item': value
          })
        })
        
        this.echart = this.$echarts.init(this.$refs.myEcharts)
        let option = {
          // 也就是鼠标放到元素上显示的信息框
          tooltip: {
            formatter: function(params) {
              // 放到连接线上
              if (params.dataType === 'edge') {

              }
            }
          },
          
          series: [
            {
              type: 'graph',
              layout: 'force',
              roam: true, // 开启平移缩放
              scaleLimit: { // 设置缩放范围限制
                min: 0.1,
                max: 0.4
              },
              animation: false, // 关闭渲染动画
              force: {
                initLayout: 'circular', // 初始化布局方式环形布局
                layoutAnimation: false, // 关闭布局动画
                repulsion: 7000, // 连接线之间斥力因子
                edgeLength: 1600 // 连接线长度
              },
              emphasis: {
                focus: false // 关闭鼠标经过高亮
              },
              symbolSize: 60, // 元素大小
              label: {
                show: true, // 是否显示标签
                fontSize: 16
              },
              edgeSymbol: ['arrow', 'arrow'], // 连接使用箭头样式
              edgeSymbolSize: [8, 8],  // 箭头大小
              data: data,  // 元素数据
              links: this.liArr // 连接线数据
            }
          ]
        }
        this.echart.setOption(option)

        // 点击事件
        this.echart.on('click', (param) => {
          // 点击了元素,直接触发父组件方法,进行元素切换
          this.$emit('doubleClick', param)
        })
      })
    },

    // 加载节点的方法(这里可以看上面的,没有改变)
    nodeLoading(name, status) {
      const option = this.echart.getOption()
      // 数据
      const nodes = JSON.parse(JSON.stringify(this.dataArr))
      // 节点链接线
      const links = JSON.parse(JSON.stringify(this.liArr))

      const linkedSet = links.reduce((set, link) => {
        if (link.source === name || link.target === name) {
          set.add(link.source)
          set.add(link.target)
        }
        return set
      }, new Set())

      const currentLink = new Set()

      const currentNode = nodes.filter(node => linkedSet.has(node.name))

      nodes.filter(node => !linkedSet.has(node.name)).forEach(node => {
        node.highlighted = false
      })

      Array.from(linkedSet).forEach(currentName => {
        nodes.forEach(node => {
          if (node.name === currentName) {
            links.forEach(link => {
              if (link.source === currentName || link.target === currentName) {
                linkedSet.add(link.source)
                linkedSet.add(link.target)
              }
            })
          }
        })
      })

      links.forEach(link => {
        if (isNodeVisible(link.source) && isNodeVisible(link.target)) {
          link.lineStyle = {
            normal: {
              color: link.lineStyle.color,
              width: 2
            },
            emphasis: {
              color: link.lineStyle.color,
              width: 3
            }
          }
          currentLink.add(link)
        }
      })

      function isNodeVisible(name) {
        const node = nodes.find(node => node.name === name)
        return node && node.highlighted === true
      }

      option.series[0].data = currentNode
      option.series[0].links = Array.from(currentLink)

      this.srcname = name
      if (status === 1) return option
      this.echart.setOption(option)
      this.echart.resize()
    },

    // 进行跳转页面
    rerenderEChartsByName(name, status) {
      // 根据节点获取最新的 option加载项
      let series = this.nodeLoading(name, 1).series[0]
      if (status === 1) {
        // 表示从首页跳转到详细页,直接跳转
        this.$router.push({ name: 'index2', params: { dataArr: series.data, links: series.links } })
      } else {
        // 当前就在详细页,可以直接渲染
        eventBus.$emit('backIndex', series)
      }
    }
  }
}
</script>

详细页面的组件

<template>
  <div>
    <div class="echarts-block">
      <!--渲染的div-->
      <div id="myEcharts" ref="myEcharts" style="width:100%;height: 100vh;"></div>
    </div>
  </div>
</template>

<script>

import eventBus from '@/utils/event-bus'

export default {
  name: 'index2',
  data() {
    return {
      name: 'index2',
      echart: null
    }
  },
  // 接收父组件传递来的元素信息和连接线信息
  props: ['dataArr', 'links'],
  
  mounted() {
    this.drawChart()
    // 监听到了方法,执行重新渲染
    eventBus.$on('backIndex', (val) => {
      const option = this.echart.getOption()
      const series = option.series[0]
      series.data = val.data
      series.links = val.links
      this.echart.setOption(option)
      this.echart.resize()
    })
  },
  
  methods: {
    drawChart() {
      this.echart = this.$echarts.init(this.$refs.myEcharts)
      let option = {
        // 跟上面一样....
      }
      this.echart.setOption(option)

      // 空白处---返回首页
      this.echart.getZr().on('click', (params) => {
        if (!params.target && this.currentValue !== null) {
          this.$emit('switchComponent')
        }
      })
      // 执行重新渲染
      this.echart.on('click', (param) => {
        this.$emit('doubleClick', param)
      })
    }
  }
}
</script>