vue 实现拓扑图,基于jsplumb.js

先看效果图,可拖拽,可实现页面的缩放和平移


1713343220203.png

1713344998565.png

(1)用jsplumb实现拓扑图的绘制以及拖拽功能
(2)用panzoom实现缩放和平移功能
引入jsplumb、panzoom

npm install jsplumb --save

npm install panzoom --save

直接上代码,以后有时间在整理

copy 到工程里可以直接使用

目录结构


1713344595928.png

index.vue

<template>
  <div class="flow-view" ref="flowView">
    <!-- 设置拓扑图区域 -->
    <div id="jsplumb-flow" ref="JsplumbFlow">
      <div class="manage">
        <CardView :list="list1" id="manage-area" />
      </div>
      <div class="system">
        // 点击节点高亮,目前只加了中间这个
        <CardGroupView :list="list2" id="system-area" @clickNode="handleClickNode" />
      </div>
      <div class="serve">
        <CardGroupView :list="list3" id="serve-area" />
      </div>
    </div>
  </div>
</template>

<script>
import jsplumbModule from 'jsplumb'
import CardView from './components/cardView.vue'
import CardGroupView from './components/cardGroupView.vue'
import { jsPlumbDefaultConfig } from './jsplumbConfig'
import panzoom from "panzoom";

import { debounce } from '@/utils/utils'

const jsplumb = jsplumbModule.jsPlumb
export default {
  components: { CardView, CardGroupView },
  data() {
    return {
      list1: [],
      list2: [],
      list3: [],
      connectList: [],
      jsplumbInstance: null,
      panzoomInstance: null,
      remberSelectNodeId: '', // 记录当前选中的节点,重复点击,回复默认颜色
      resizeObserver : null
    }
  },
  mounted() {
    this.resizeObserver = new ResizeObserver(debounce(() => {
      this.handleResize()
    }), 500)
    this.resizeObserver.observe(this.$refs.flowView)
    // 加载数据
    this.getData().then(() => {
      this.$nextTick(() => {
        // 初始化jsplumb
        this.initJsplumb()
      })
    })
  },
  methods: {
    initJsplumb() {
      jsplumb.ready(() => {
        //设置jsplumb实例、设置jsplumb默认配置、设置jsplumb容器
        this.jsplumbInstance = jsplumb.getInstance().importDefaults(jsPlumbDefaultConfig)

        // 重设container
        // this.jsplumbInstance.setContainer('jsplumb-flow')

        // 先清除一下画布,防止缓存
        this.jsplumbInstance.reset();

        // 处理节点数据
        this.handleNodeData()

        // // 会使整个jsPlumb立即重绘。
        // this.jsplumbInstance.setSuspendDrawing(false, true);
        this.initPanZoom()
      })
    },
    // 处理数据,绘制节点
    handleNodeData() {
      this.list1.forEach((item) => {
        // 设置拖拽, 拖拽方法,调用接口重绘的时候,settingDrag 不需要重复调用,不然会报错
        // 我猜测可能是节点已经存在,目前我的解决方法是传一个参数,控制是否调用settingDrag方法
        this.settingDrag(item)
        // 初始化节点
        this.initNodes(item)
      })
      this.list2.forEach((item) => {
        this.settingDrag(item)
        this.initNodes(item)
      })
      this.list3.forEach((item) => {
        this.settingDrag(item)
        this.initNodes(item)
      })
      // 节点连接的数据
      this.connectList.forEach(item => {
        const { source, target } = item
        this.nodeConnect(source.nodeId, target.nodeId)
      })
    },
    // 初始化节点
    initNodes(node) {
      // let endPointConfig = {}
      const { nodeId } = node
      if (node.type === 1) {
        // 这是左侧第一列,只需要右侧连接点
        // uuid 设置nodeID-Right,为了节点相连,对准方向
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
      } else if (node.type === 2) {
        // 中间数据
        // 递归调用,将子节点下的所有节点,全部加入endPoint
        if (node.children && node.children.length > 0) {
          node.children.forEach(item => {
            this.initNodes({...item, ...{ type: 2 }})
          })
        }
        // 添加父节点,如果不需要链接父节点,可不写
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Left', uuid: `${nodeId}-Left` }, jsPlumbDefaultConfig)
      } else {
        // 右侧数据,只需要暴露左侧连接点
        if (node.children && node.children.length > 0) {
          node.children.forEach(item => {
            this.initNodes({...item, ...{ type: 3 }})
          })
        }
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Left', uuid: `${nodeId}-Left` }, jsPlumbDefaultConfig)
      }
    },
    // 节点相连
    nodeConnect(sourceId, targetId) {
      this.jsplumbInstance.connect({ uuids: [`${sourceId}-Right`, `${targetId}-Left`]})
    },
    settingDrag(node) {
      // 也可以限制节点的拖拽区域
      // if (node.type === 1) {
      //   this.jsplumbInstance.draggable(node.nodeId, {
      //     containment: 'manage-area'
      //   });
      // } else if (node.type === 2) {
      //   this.jsplumbInstance.draggable(node.nodeId, {
      //     containment: 'system-area'
      //   });
      // } else {
      //   this.jsplumbInstance.draggable(node.nodeId, {
      //     containment: 'serve-area'
      //   });
      // 设置节点可拖拽
      this.jsplumbInstance.draggable(node.nodeId);      
    },
    getData() {
      return new Promise((resolve, reject) => {
        // 添加节点数据
        const arr1 = []
        const arr2 = []
        const arr3 = []
        for (let i = 1; i < 20; i++) {
          if (i <= 6) {
            arr1.push({
              name: '管理' + i,
              nodeId: 'manage' + i,
              type: 1
            })
          } else if (i < 13) {
            const childrenList = [
              {
                name: '系统' + i + '-ip1',
                nodeId: 'system' + i + 'ip1',
              },
              {
                name: '系统' + i + '-ip2',
                nodeId: 'system' + i + 'ip2',
              },
              {
                name: '系统' + i + '-ip3',
                nodeId: 'system' + i + 'ip3',
              }
            ]
            arr2.push({
              name: '系统' + i,
              nodeId: 'system' + i,
              type: 2,
              children: i === 7 ? childrenList : []
            })
          } else {
            const childrenList = [
              {
                name: '服务' + i + '-ip1',
                nodeId: 'serve' + i + 'ip1',
              },
              {
                name: '服务' + i + '-ip2',
                nodeId: 'serve' + i + 'ip2',
              },
              {
                name: '服务' + i + '-ip3',
                nodeId: 'serve' + i + 'ip3',
              }
            ]
            arr3.push({
              name: '服务' + i,
              nodeId: 'serve' + i,
              type: 3,
              children: i === 14 ? childrenList : []
            })
          }
        }

        // 设置节点位置,如果需要拖拽,node节点必须使用absolute,绝对定位去设置坐标点
        this.fixNodesPosition(arr1, 1)
        this.fixNodesPosition(arr2, 2)
        this.fixNodesPosition(arr3, 3)
        
        // 获取节点相连的数据
        this.connectList = [
          { source: arr1[1], target: arr2[0].children[0] },
          { source: arr2[0].children[2], target: arr3[1].children[1] },
        ]
        resolve()
      })
    },
    // 手动计算节点位置
    fixNodesPosition(arr, type) {
      const topSpace = 12
      if (type === 1) {
        const modeWidth = 150
        const modeHeight = 35
        const width = this.$refs.flowView.offsetWidth
        const modeLeft = (width / 3 / 2) - (modeWidth / 2)
        arr.forEach((item, i) => {
          item['width'] = modeWidth
          item['height'] = modeHeight
          item['top'] = (modeHeight * i) + (topSpace * (i + 1))
          item['left'] = modeLeft
        })
        this.list1 = arr
      } else if (type === 2 || type === 3) {
        const modeWidth = 150
        const emptyHeight = 40
        const headerHeight = 30
        const rowHeight = 30
        const width = this.$refs.flowView.offsetWidth
        const modeLeft = (width / 3 / 2) - (modeWidth / 2)
        let totalHeight = 0
        arr.forEach((item, i) => {
          let modeHeight = 0
          if (item.children && item.children.length > 0) {
            if (item.children.length * rowHeight < 30) {
              modeHeight = headerHeight + emptyHeight
            } else {
              modeHeight = (item.children.length * rowHeight) + headerHeight
            }
          } else {
            modeHeight = headerHeight + emptyHeight
          }
          item['width'] = modeWidth
          item['height'] = modeHeight
          item['top'] = totalHeight + (topSpace * (i + 1))
          item['left'] = modeLeft
          totalHeight = modeHeight + totalHeight
        })
        if (type === 2) {
          this.list2 = arr
        } else {
          this.list3 = arr
        }
      }
    },
    // 使用panZoom 实现缩放功能
    //初始化缩放功能
    initPanZoom() {
      // panzoom(缩放区域,相关配置) 
      this.panzoomInstance = panzoom(this.$refs.JsplumbFlow, {
        smoothScroll: false,
        bounds: true,
        // autocenter: true,
        zoomDoubleClickSpeed: 1,
        minZoom: 0.5,
        maxZoom: 2,
      })
    },
    handleClickNode(node) {
      // 重置所有线条颜色
      const connectionsAll = this.jsplumbInstance.getAllConnections()
      connectionsAll.map(item => {
        item.setPaintStyle({ stroke: '#000' })
      })

      // 设置source链接线
      const connect = this.jsplumbInstance.getConnections({
        source: node.nodeId,
        target: ''
      })
      // target
      const connect2 = this.jsplumbInstance.getConnections({
        source: '',
        target: node.nodeId
      })

      connect.map((item) => {
        item.setPaintStyle({ stroke: 'red' })
      })
      connect2.map((item) => {
        item.setPaintStyle({ stroke: 'red' })
      })
    },
    handleResize() {
      // 改变view尺寸后,重新连线
      this.jsplumbInstance.repaintEverything()
    },
    beforeDestroy() {
      // 组件销毁前移除panzoom以避免内存泄露
      this.panzoomInstance.dispose();

      this.resizeObserver.unobserve(this.$refs.flowView)
      this.resizeObserver.disconnect()
    }
  }
}
</script>

<style lang="less" scoped>
.flow-view {
  width: 100%;
  height: 500px;
  border: 1px solid gray;
  overflow: hidden;
  #jsplumb-flow {
    position: relative;
    width: 100%;
    height: 100%;
    display: flex;
    .manage, .system, .serve {
      flex: 1;
      display: flex;
      flex-direction: column;
      position: relative;
      p {
        text-align: center;
        font-size: 20px;
        font-weight: bold;
        margin: 0;
        height: 40px;
        line-height: 40px;
      }
    }
    // .manage {
    //   background-color: honeydew;
    // }
    // .system {
    //   background-color: pink;
    // }
    // .serve {
    //   background-color: paleturquoise;
    // }
  }
}
</style>

jsplumbConfig/index.js

// 基础配置
export const jsPlumbDefaultConfig = {
  Container: "jsplumb-flow",
  Anchors: ['Left', 'Right'],
  //四种样式:Bezier/Straight/Flowchart/StateMachine
  Connector: ["Bezier"],
//   Connector: ["Straight", {stub: [20, 50], gap: 0}],
//   Connector: ["Flowchart", { stub: [20, 10], gap: 10, cornerRadius: 5, alwaysRespectStubs: true }],
  // Connector: ["StateMachine"],
  // 连线的端点
  Endpoint: "Blank", // Blank:空,不可见;Dot:圆点;Image;Rectangle
  // 端点的样式
  EndpointStyle: {
      fill: "#c4c4c4",
      outlineWidth: 10
  },
  // 通常连线的样式
  PaintStyle: {
      stroke: '#000',
      strokeWidth: 2,
      outlineWidth: 20
  },
  //hover激活连线的样式
  HoverPaintStyle: {
      stroke: 'blue',
      strokeWidth: 2
  },
  maxConnections: -1, // 设置连接点最多可以连接几条线 -1不限
  // 绘制箭头
  Overlays: [
      [
          "Arrow",
          {
              width: 8, // 箭头宽度
              length: 8, // 箭头长度
              location: 1 // 线尾部(0-1)
          }
      ]
  ],
  DrapOptions: { cursor: "crosshair", zIndex: 2000 },
}

components/cardView.vue

<template>
  <div class="card-view">
    <div
      v-for="item in list"
      :key="item.nodeId"
      :id="item.nodeId"
      :style="getStyle(item)"
      class="item"
      >
      {{ item.name }}
    </div>
  </div>
</template>

<script>
export default {
  props: {
    list: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {

    }
  },
  methods: {
    // 根据计算的top、left、width、height,设置style
    getStyle(item) {
      return {
        width: `${item.width}px`,
        height: `${item.height}px`,
        top: `${item.top}px`,
        left: `${item.left}px`,
        lineHeight: `${item.height}px`
      }
    }
  }
}
</script>

<style lang="less" scoped>
.card-view {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: absolute;
  // top: 40px;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  .item {
    text-align: center;
    background: orange;
    position: absolute;
    font-size: 14px;
  }
}
</style>

components/cardGroupView.vue

<template>
  <div class="card-view">
    <div
      v-for="item in list"
      :key="item.nodeId"
      :id="item.nodeId"
      :style="getStyle(item)"
      class="item"
      >
      <p class="title">{{ item.name }}</p>
      <div class="children">
        <template v-if="item.children && item.children.length > 0">
          <p
            v-for="childrenItem in item.children"
            :key="childrenItem.nodeId"
            :id="childrenItem.nodeId"
            class="row"
            @click="clickNode(item.children[childIndex])"
          >
            {{ childrenItem.name }}
          </p>
        </template>
        <p v-else class="empty">
          {{ emptyText }}
        </p>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    emptyText: {
      type: String,
      default: '暂无设备'
    },
    list: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {

    }
  },
  methods: {
    // 根据计算的top、left、width、height,设置style
    getStyle(item) {
      return {
        width: `${item.width}px`,
        height: `${item.height}px`,
        top: `${item.top}px`,
        left: `${item.left}px`,
        lineHeight: `${item.height}px`
      }
    },
    clickNode(node) {
      this.$emit('clickNode', node)
    }
  }
}
</script>

<style lang="less" scoped>
.card-view {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: absolute;
  // top: 40px;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  .item {
    text-align: center;
    background: orange;
    position: absolute;
    font-size: 14px;
    border: 1px solid gray;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    .title {
      border-bottom: 1px solid gray;
      box-sizing: border-box;
    }
    p {
      margin: 0;
      height: 30px;
      line-height: 30px;
    }
    .children {
      display: flex;
      flex-direction: column;
      min-height: 40px;
      justify-content: space-around;
      flex: 1;
      p {
        height: 20px;
        line-height: 20px;
      }
      .row {
        height: 20px;
        line-height: 20px;
        margin: 0 16px;
        font-size: 12px;
        border: 1px solid gray;
      }
    }
  }
}
</style>

新增:点击节点,高亮显示连接线,同步更新在上面代碼裏

1714099326320.png

主要代码:

method: {
    handleClickNode(node) {
      // 增加判断,重复点击恢复默认颜色
      if (node.nodeId === this.remberSelectNodeId) {
        this.remberSelectNodeId = ''
        this.settingDefaultLine()
        return
      }
      this.remberSelectNodeId = node.nodeId
      this.settingDefaultLine()
      this.settingSelectLine({ source: node.nodeId, target: '' })
      this.settingSelectLine({ source: '', target: node.nodeId })
    },
  // 设置高亮颜色
    settingSelectLine(lineObj) {
      const connect = this.jsplumbInstance.getConnections(lineObj)
      connect.map((item) => {
        item.setPaintStyle({ stroke: 'red' })
      })
    },
    settingDefaultLine() {
      // 重置所有线条颜色
      const connectionsAll = this.jsplumbInstance.getAllConnections()
      connectionsAll.map(item => {
        item.setPaintStyle({ stroke: '#000' })
      })
    },
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349