vue 實現(xiàn)拓?fù)鋱D笤喳,基于jsplumb.js

先看效果圖辜妓,可拖拽枯途,可實現(xiàn)頁面的縮放和平移


1713343220203.png

1713344998565.png

(1)用jsplumb實現(xiàn)拓?fù)鋱D的繪制以及拖拽功能
(2)用panzoom實現(xiàn)縮放和平移功能
引入jsplumb、panzoom

npm install jsplumb --save

npm install panzoom --save

直接上代碼籍滴,以后有時間在整理

copy 到工程里可以直接使用

目錄結(jié)構(gòu)


1713344595928.png

index.vue

<template>
  <div class="flow-view" ref="flowView">
    <!-- 設(shè)置拓?fù)鋱D區(qū)域 -->
    <div id="jsplumb-flow" ref="JsplumbFlow">
      <div class="manage">
        <CardView :list="list1" id="manage-area" />
      </div>
      <div class="system">
        // 點擊節(jié)點高亮酪夷,目前只加了中間這個
        <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: '', // 記錄當(dāng)前選中的節(jié)點,重復(fù)點擊孽惰,回復(fù)默認(rèn)顏色
      resizeObserver : null
    }
  },
  mounted() {
    this.resizeObserver = new ResizeObserver(debounce(() => {
      this.handleResize()
    }), 500)
    this.resizeObserver.observe(this.$refs.flowView)
    // 加載數(shù)據(jù)
    this.getData().then(() => {
      this.$nextTick(() => {
        // 初始化jsplumb
        this.initJsplumb()
      })
    })
  },
  methods: {
    initJsplumb() {
      jsplumb.ready(() => {
        //設(shè)置jsplumb實例晚岭、設(shè)置jsplumb默認(rèn)配置、設(shè)置jsplumb容器
        this.jsplumbInstance = jsplumb.getInstance().importDefaults(jsPlumbDefaultConfig)

        // 重設(shè)container
        // this.jsplumbInstance.setContainer('jsplumb-flow')

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

        // 處理節(jié)點數(shù)據(jù)
        this.handleNodeData()

        // // 會使整個jsPlumb立即重繪勋功。
        // this.jsplumbInstance.setSuspendDrawing(false, true);
        this.initPanZoom()
      })
    },
    // 處理數(shù)據(jù)坦报,繪制節(jié)點
    handleNodeData() {
      this.list1.forEach((item) => {
        // 設(shè)置拖拽, 拖拽方法狂鞋,調(diào)用接口重繪的時候片择,settingDrag 不需要重復(fù)調(diào)用,不然會報錯
        // 我猜測可能是節(jié)點已經(jīng)存在要销,目前我的解決方法是傳一個參數(shù)构回,控制是否調(diào)用settingDrag方法
        this.settingDrag(item)
        // 初始化節(jié)點
        this.initNodes(item)
      })
      this.list2.forEach((item) => {
        this.settingDrag(item)
        this.initNodes(item)
      })
      this.list3.forEach((item) => {
        this.settingDrag(item)
        this.initNodes(item)
      })
      // 節(jié)點連接的數(shù)據(jù)
      this.connectList.forEach(item => {
        const { source, target } = item
        this.nodeConnect(source.nodeId, target.nodeId)
      })
    },
    // 初始化節(jié)點
    initNodes(node) {
      // let endPointConfig = {}
      const { nodeId } = node
      if (node.type === 1) {
        // 這是左側(cè)第一列,只需要右側(cè)連接點
        // uuid 設(shè)置nodeID-Right疏咐,為了節(jié)點相連纤掸,對準(zhǔn)方向
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
      } else if (node.type === 2) {
        // 中間數(shù)據(jù)
        // 遞歸調(diào)用,將子節(jié)點下的所有節(jié)點浑塞,全部加入endPoint
        if (node.children && node.children.length > 0) {
          node.children.forEach(item => {
            this.initNodes({...item, ...{ type: 2 }})
          })
        }
        // 添加父節(jié)點借跪,如果不需要鏈接父節(jié)點,可不寫
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Right', uuid: `${nodeId}-Right` }, jsPlumbDefaultConfig)
        this.jsplumbInstance.addEndpoint(node.nodeId, { anchor: 'Left', uuid: `${nodeId}-Left` }, jsPlumbDefaultConfig)
      } else {
        // 右側(cè)數(shù)據(jù)酌壕,只需要暴露左側(cè)連接點
        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)
      }
    },
    // 節(jié)點相連
    nodeConnect(sourceId, targetId) {
      this.jsplumbInstance.connect({ uuids: [`${sourceId}-Right`, `${targetId}-Left`]})
    },
    settingDrag(node) {
      // 也可以限制節(jié)點的拖拽區(qū)域
      // 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'
      //   });
      // 設(shè)置節(jié)點可拖拽
      this.jsplumbInstance.draggable(node.nodeId);      
    },
    getData() {
      return new Promise((resolve, reject) => {
        // 添加節(jié)點數(shù)據(jù)
        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: '系統(tǒng)' + i + '-ip1',
                nodeId: 'system' + i + 'ip1',
              },
              {
                name: '系統(tǒng)' + i + '-ip2',
                nodeId: 'system' + i + 'ip2',
              },
              {
                name: '系統(tǒng)' + i + '-ip3',
                nodeId: 'system' + i + 'ip3',
              }
            ]
            arr2.push({
              name: '系統(tǒng)' + i,
              nodeId: 'system' + i,
              type: 2,
              children: i === 7 ? childrenList : []
            })
          } else {
            const childrenList = [
              {
                name: '服務(wù)' + i + '-ip1',
                nodeId: 'serve' + i + 'ip1',
              },
              {
                name: '服務(wù)' + i + '-ip2',
                nodeId: 'serve' + i + 'ip2',
              },
              {
                name: '服務(wù)' + i + '-ip3',
                nodeId: 'serve' + i + 'ip3',
              }
            ]
            arr3.push({
              name: '服務(wù)' + i,
              nodeId: 'serve' + i,
              type: 3,
              children: i === 14 ? childrenList : []
            })
          }
        }

        // 設(shè)置節(jié)點位置掏愁,如果需要拖拽,node節(jié)點必須使用absolute卵牍,絕對定位去設(shè)置坐標(biāo)點
        this.fixNodesPosition(arr1, 1)
        this.fixNodesPosition(arr2, 2)
        this.fixNodesPosition(arr3, 3)
        
        // 獲取節(jié)點相連的數(shù)據(jù)
        this.connectList = [
          { source: arr1[1], target: arr2[0].children[0] },
          { source: arr2[0].children[2], target: arr3[1].children[1] },
        ]
        resolve()
      })
    },
    // 手動計算節(jié)點位置
    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 實現(xiàn)縮放功能
    //初始化縮放功能
    initPanZoom() {
      // panzoom(縮放區(qū)域果港,相關(guān)配置) 
      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' })
      })

      // 設(shè)置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以避免內(nèi)存泄露
      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

// 基礎(chǔ)配置
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, // 設(shè)置連接點最多可以連接幾條線 -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: {
    // 根據(jù)計算的top萝衩、left、width没咙、height,設(shè)置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: '暫無設(shè)備'
    },
    list: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {

    }
  },
  methods: {
    // 根據(jù)計算的top猩谊、left、width祭刚、height,設(shè)置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>

新增:點擊節(jié)點,高亮顯示連接線涡驮,同步更新在上面代碼裏

1714099326320.png

主要代碼:

method: {
    handleClickNode(node) {
      // 增加判斷宜鸯,重復(fù)點擊恢復(fù)默認(rèn)顏色
      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 })
    },
  // 設(shè)置高亮顏色
    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' })
      })
    },
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市遮怜,隨后出現(xiàn)的幾起案子淋袖,更是在濱河造成了極大的恐慌,老刑警劉巖锯梁,帶你破解...
    沈念sama閱讀 221,548評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件即碗,死亡現(xiàn)場離奇詭異,居然都是意外死亡陌凳,警方通過查閱死者的電腦和手機(jī)剥懒,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,497評論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來合敦,“玉大人初橘,你說我怎么就攤上這事。” “怎么了保檐?”我有些...
    開封第一講書人閱讀 167,990評論 0 360
  • 文/不壞的土叔 我叫張陵耕蝉,是天一觀的道長。 經(jīng)常有香客問我夜只,道長垒在,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,618評論 1 296
  • 正文 為了忘掉前任扔亥,我火速辦了婚禮场躯,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘旅挤。我一直安慰自己踢关,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 68,618評論 6 397
  • 文/花漫 我一把揭開白布粘茄。 她就那樣靜靜地躺著签舞,像睡著了一般。 火紅的嫁衣襯著肌膚如雪驹闰。 梳的紋絲不亂的頭發(fā)上瘪菌,一...
    開封第一講書人閱讀 52,246評論 1 308
  • 那天,我揣著相機(jī)與錄音嘹朗,去河邊找鬼师妙。 笑死,一個胖子當(dāng)著我的面吹牛屹培,可吹牛的內(nèi)容都是我干的默穴。 我是一名探鬼主播,決...
    沈念sama閱讀 40,819評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼褪秀,長吁一口氣:“原來是場噩夢啊……” “哼蓄诽!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起媒吗,我...
    開封第一講書人閱讀 39,725評論 0 276
  • 序言:老撾萬榮一對情侶失蹤仑氛,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后闸英,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體锯岖,經(jīng)...
    沈念sama閱讀 46,268評論 1 320
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,356評論 3 340
  • 正文 我和宋清朗相戀三年甫何,在試婚紗的時候發(fā)現(xiàn)自己被綠了出吹。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,488評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡辙喂,死狀恐怖捶牢,靈堂內(nèi)的尸體忽然破棺而出鸠珠,到底是詐尸還是另有隱情,我是刑警寧澤秋麸,帶...
    沈念sama閱讀 36,181評論 5 350
  • 正文 年R本政府宣布渐排,位于F島的核電站,受9級特大地震影響竹勉,放射性物質(zhì)發(fā)生泄漏飞盆。R本人自食惡果不足惜娄琉,卻給世界環(huán)境...
    茶點故事閱讀 41,862評論 3 333
  • 文/蒙蒙 一次乓、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧孽水,春花似錦票腰、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,331評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至炼鞠,卻和暖如春缘滥,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背谒主。 一陣腳步聲響...
    開封第一講書人閱讀 33,445評論 1 272
  • 我被黑心中介騙來泰國打工朝扼, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人霎肯。 一個月前我還...
    沈念sama閱讀 48,897評論 3 376
  • 正文 我出身青樓擎颖,卻偏偏與公主長得像,于是被迫代替她去往敵國和親观游。 傳聞我的和親對象是個殘疾皇子搂捧,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,500評論 2 359