[簡易版]有向無環圖(DAG)前端可視化

首發簡書, 此爲合併整理版.代碼連接放在文末html

最近公司須要作一個內部使用的機器學習平臺,其中有一部分需求能夠抽象爲有向無環圖,一邊踩坑一邊把研發過程記錄了一下(實際上是搜不到高耦合業務的成品輪子🤷),若是有相似需求,不妨泡杯枸杞,慢慢讀完此篇.前端

教程實現的內容有:vue

模型節點的拖動, 創建關係(連線) node

模型節點外部操做(節點的增刪,前端實現的DAG環檢測) git

模型整圖的平面移動(全圖放縮,選框,全屏等) github

關於前端可視化的技術選型.

初接需求, 考慮使用svg與canvas實現此內容,綜合來看:web

名稱 svg canvas
圖像質量 矢量圖隨意縮放 位圖,縮放失真
事件驅動 基於dom元素,綁定事件easy 腳本驅動,事件配置不靈活
性能 同上,故渲染元素過多會形成卡頓 性能極高,更有離屏canvas將來趨勢
適用場景 交互行爲較多量級較少圖像 超多重複元素的渲染
學習成本 相對簡單 上手有必定成本

故,總體選用svg,且目前市面上基於svg實現的成品有不少, 好比墨刀,processon,noflo,和阿里系的諸多平臺,在部分場景下的表現至關優秀(固然也方便隨時扒開代碼學習寫法啦~)vuex

書接前文,切回正題chrome

1、節點的實現

對應節點代碼(第一版)爲

{
    name: "name1",
    description: "description1",
    id: 1,
    parentNode: 0,
    childNode: 2,
    imgContent: "",
    parentDetails: {
      a: "",
      b: ""
    },
    linkTo: [{ id: 2 }, { id: 3 }],
    translate: {
      left: 100,
      top: 20
    }
  }
複製代碼

後期(教程step5後)優化爲:canvas

{
    name: "name2",
    id: 2,
    imgContent: "",
    pos_x: 300,
    pos_y: 400,
    type: 'constant',
    in_ports: [0, 1, 2, 3, 4],
    out_ports: [0, 1, 2, 3, 4]
 }
複製代碼

請忽略靈魂繪圖師的抽象,一切基於數據驅動,模型節點只須要仿照上圖與後端研發交互便可.

2、模型節點連線的實現

<path
  class="connector"
  v-for="(each, n) in item.linkTo" :key="n"
  :d="computedLink(i, each, n)">
</path>
複製代碼

基於vue實現因此直接用了:d 動態計算貝塞爾曲線,思路是利用出入節點的id計算起始位置,對曲線公式進行賦值 點擊->關於貝塞爾曲線可參考https://brucewar.gitbooks.io/svg-tutorial/15.SVG-path%E5%85%83%E7%B4%A0.html

3、節點拖拽的實現

dragPre(e, i) {
      // 準備拖動節點
      this.setInitRect(); // 初始化畫板座標
      this.currentEvent = "dragPane"; // 修正行爲
      this.choice.index = i;
      this.setDragFramePosition(e);
    },
複製代碼

初始化畫板的緣由: 因爲元素在窗口的位置並不是固定,每次須要初始座標, 方便計算相對位移量.

<g
        :transform="`translate(${dragFrame.posX}, ${dragFrame.posY})`"
        class="dragFrame">
          <foreignObject width="180" height="30" >
            <body xmlns="http://www.w3.org/1999/xhtml">
              <div
              v-show="currentEvent === 'dragPane'"
              class="dragFrameArea">
              </div>
            </body>
          </foreignObject>
        </g>
複製代碼

mousedown時獲取拖拽元素的下標,修正座標

dragIng(e) {
      if (this.currentEvent === "dragPane") {
        this.setDragFramePosition(e);
        // 模擬框隨動
      }
    },
setDragFramePosition(e) {
      const x = e.x - this.initPos.left; // 修正拖動元素座標
      const y = e.y - this.initPos.top;
      this.dragFrame = { posX: x - 90, posY: y - 15 };
    }
複製代碼

拖動時給模擬拖動的元素賦值位置

dragEnd(e) {
      // 拖動結束
      if (this.currentEvent === "dragPane") {
        this.dragFrame = { dragFrame: false, posX: 0, posY: 0 };
        this.setPanePosition(e); // 設定拖動後的位置
      }
      this.currentEvent = null; // 清空事件行爲
    },
setPanePosition(e) {
      const x = e.x - this.initPos.left - 90;
      const y = e.y - this.initPos.top - 15;
      const i = this.choice.index;
      this.DataAll[i].translate = { left: x, top: y };
    },
複製代碼

拖動結束把新的位置賦值給對應元素,固然在實際項目中, 每次變動須要跟後臺交互這些數據, 不須要前端模擬數據變動的,直接請求整張圖的接口從新渲染就行了,更easy

4、節點連線拖拽的實現

和上一步相似,咱們也是經過監聽mousedown mousemove 與 mouseup這些事件.來實現節點間連線的拖拽效果.惟一難點在於計算起始的位置.

<g>
          <path
          class="connector"
          :d="dragLinkPath()"
          ></path>
        </g>
複製代碼

首先來個path

setInitRect() {
      let { left, top } = document
        .getElementById("svgContent")
        .getBoundingClientRect();
      this.initPos = { left, top }; // 修正座標
    },
    linkPre(e, i) {
      this.setInitRect();
      this.currentEvent = "dragLink";
      this.choice.index = i;
      this.setDragLinkPostion(e, true);
      e.preventDefault();
      e.stopPropagation();
    },
複製代碼

mousedown修正座標

dragIng(e) {
      if (this.currentEvent === "dragLink") {
        this.setDragLinkPostion(e);
      }
    },
複製代碼

mousemove的時候肯定位置

linkEnd(e, i) {
      if (this.currentEvent === "dragLink") {
        this.DataAll[this.choice.index].linkTo.push({ id: i });
        this.DataAll.find(item => item.id === i).parentNode = 1;
      }
      this.currentEvent = null;
    },
    setDragLinkPostion(e, init) {
      // 定位連線
      const x = e.x - this.initPos.left;
      const y = e.y - this.initPos.top;
      if (init) {
        this.dragLink = Object.assign({}, this.dragLink, {
          fromX: x,
          fromY: y
        });
      }
      this.dragLink = Object.assign({}, this.dragLink, { toX: x, toY: y });
    },
複製代碼

mouseup的時候判斷連入了哪一個元素

5、整合以上步驟, 組件抽離

隨着內容的增多,咱們須要把全部內容整合, 基於耦合內容對組件進行分割,具體可看目錄結構

全部的連線變成arrow組件,只繼承座標位置用以渲染 simulateFrame和simulateArrow只動態繼承拖拽時的座標,用以模擬拖拽效果

6、節點拖拽添加的實現

面向過程來看, 節點拖動無非3個操做:

·拖動前判斷當前狀況下可否拖動, 拖動的元素攜帶的節點類型,節點名稱等參數

·拖動中模擬的節點隨鼠標進行位移,將參數賦值給模擬的節點

·拖動中止判斷鬆手位置是否在畫板中, ( 更改模型數據 | 調用後臺接口 )

因此咱們須要一個可以全屏移動的模擬元素 如圖 class='nodesBus-contain'

<nodes-bus v-if="dragBus" :value="busValue.value" :pos_x="busValue.pos_x" :pos_y="busValue.pos_y" />
複製代碼

這個元素在全局dom中位置僅次於最大容器,接收座標位置和展現名稱.

dragBus: false,
      busValue: {
        value: "name",
        pos_x: 100,
        pos_y: 100
      }
複製代碼

最外層組件使用dragBus控制是否展現和位置等.

<div class="page-content" @mousedown="startNodesBus($event)" @mousemove="moveNodesBus($event)" @mouseup="endNodesBus($event)">
複製代碼

外層容器3個事件, mouseDown, mouseMove, mouseUp

<span @mousedown="dragIt('拖動1')">拖動我吧1</span>
<span @mousedown="dragIt('拖動2')">拖動我吧2</span>
dragIt(val) {
      sessionStorage["dragDes"] = JSON.stringify({
        drag: true,
        name: val
      });
    }
複製代碼

須要點擊觸發拖動的元素使用緩存來傳遞數據,控制模擬節點.

startNodesBus(e) {
      /**
       *  別的組件調用時, 先放入緩存
       * dragDes: {
       *    drag: true,
       *    name: 組件名稱
       *    type: 組件類型
       *    model_id: 跟後臺交互使用
       * }
       **/
      let dragDes = null;
      if (sessionStorage["dragDes"]) {
        dragDes = JSON.parse(sessionStorage["dragDes"])
      }
      if (dragDes && dragDes.drag) {
        const x = e.pageX;
        const y = e.pageY;
        this.busValue = Object.assign({}, this.busValue, {
          pos_x: x,
          pos_y: y,
          value: dragDes.name
        });
        this.dragBus = true;
      }
    }
複製代碼

冒泡到最上層組件時觸發容器的mouseUp事件, 使模擬的節點展現,並賦值須要的參數.使用緩存來控制行爲,是爲了防止別的無關元素干擾.

moveNodesBus(e) {
      if (this.dragBus) {
        const x = e.pageX;
        const y = e.pageY;
        this.busValue = Object.assign({}, this.busValue, {
          pos_x: x,
          pos_y: y
        });
      }
    },
複製代碼

移動中的行爲很簡單,只須要動態將鼠標的頁面位置賦值進入便可.

endNodesBus(e) {
      let dragDes = null;
      if (sessionStorage["dragDes"]) {
        dragDes = JSON.parse(sessionStorage["dragDes"])
      }
      if (dragDes && dragDes.drag && e.toElement.id === "svgContent") {
        const { model_id, type } = dragDes;
        const pos_x = e.offsetX - 90; // 參數修正
        const pos_y = e.offsetY - 15; // 參數修正
        const params = {
          model_id: sessionStorage["newGraph"],
          desp: {
            type,
            pos_x,
            pos_y,
            name: this.busValue.value
          }
        };
        this.addNode(params);
      }
      window.sessionStorage["dragDes"] = null;
      this.dragBus = false;
    }
複製代碼

取出mouseUp時的鼠標位置, 矯正以後更改模型數據便可, 這裏調用的this.addNode(params)來自於vuex, 在後文會對vuex進行統一講解.

7、節點的刪除

刪除節點使用右鍵調出選項框,這裏咱們能夠監聽元素的右鍵行爲,並禁掉全部默認行爲.

<g
        v-for="(item, i) in DataAll.nodes"
        :key="'_' + i" class="svgEach"
        :transform="`translate(${item.pos_x}, ${item.pos_y})`"
        @contextmenu="r_click_nodes($event, i)">
---------------------------------------------------------------------------
  r_click_nodes(e, i) { // 節點的右鍵事件
      this.setInitRect()
      const id = this.DataAll.nodes[i].id;
      const x = e.x - this.initPos.left;
      const y = e.y - this.initPos.top;
      this.is_edit_area = {
        value: true,
        x,
        y,
        id
      }
      e.stopPropagation();
      e.cancelBubble = true;
      e.preventDefault();
    }
複製代碼

而後將操做的節點id和鼠標位置傳給選項模擬組件nodesBus.vue 以保證選項框出如今合適位置. 這裏還有一個坑, 咱們要保證點擊其餘位置能夠關閉模態框,因此須要加一層遮罩,在這裏筆者取了個巧,並無加一層cover div

<foreignObject width="100%" height="100%" style="position: relative" @click="click_menu_cover($event)">
        <body xmlns="http://www.w3.org/1999/xhtml" :style="get_menu_style()">
            <div class="menu_contain">
                <span @click="delEdges">刪除節點</span>
                <span>編輯</span>
                <span>乾點別的啥</span>
            </div>
        </body>
 </foreignObject>
-------------------------------------------------
click_menu_cover(e) {
      this.$emit('close_click_nodes')
      e.preventDefault();
      e.cancelBubble = true;
      e.stopPropagation();
 },
複製代碼

直接在組件內部攔截mouseDown 關閉彈框便可.

let params = {
        model_id: sessionStorage['newGraph'],
        id: this.isEditAreaShow.id
 }
this.delNode(params)
複製代碼

model_id是本項目跟後臺交互的參數請無視 拿到id直接調用vuex的delNode便可

8、 連線,節點的刪除及vuex的使用

爲了組件分的更加細緻,方便組件間的數據共享,引入vuex做爲本項目的數據承接.多組件共同使用dagStore.js的DataAll,

addEdge: ({ commit }, { desp }) => { // 增長邊
      commit('ADD_EDGE_DATA', desp)
    },
    delEdge: ({ commit }, { id }) => { // 刪除邊
      commit('DEL_EDGE_DATA', id)
    },
    moveNode: ({ commit }, params) => { // 移動點的位置
      commit('MOVE_NODE_DATA', params)
    },
    addNode: ({ commit }, params) => { // 增長節點
      commit('ADD_NODE_DATA', params)
    },
    delNode: ({ commit }, { id }) => { // 刪除節點
      commit('DEL_NODE_DATA', id)
    },
複製代碼

state的數據結構爲

DataAll: {
      nodes: [{
        name: "name5",
        id: 1,
        imgContent: "",
        pos_x: 100,
        pos_y: 230,
        type: "constant",
        in_ports: [0, 1, 2],
        out_ports: [0, 1, 2, 3, 4]
      }],
      edges: [{
        id: 1,
        dst_input_idx: 1,
        dst_node_id: 1,
        src_node_id: 2,
        src_output_idx: 2
      }],
      model_id: 21
    }
複製代碼

全部操做只更改state中的DataAll便可.

ADD_NODE_DATA: (state, params) => {
      let _nodes = state.DataAll.nodes
      _nodes.push({
        ...params.desp,
        id: state.DataAll.nodes.length + 10,
        in_ports: [0, 1, 2, 3, 4],
        out_ports: [0, 1, 2, 3, 4]
      })
}
複製代碼

節點新增

DEL_NODE_DATA: (state, id) => {
      let _edges = []
      let _nodes = []
      state.DataAll.edges.forEach(item => {
        if (item.dst_node_id !== id && item.src_node_id !== id) {
          _edges.push(item)
        }
      })
      state.DataAll.nodes.forEach(item => {
        if (item.id !== id) {
          _nodes.push(item)
        }
      })
      state.DataAll.edges = _edges
      state.DataAll.nodes = _nodes
 }
複製代碼

節點刪除

DEL_EDGE_DATA: (state, id) => {
      let _edges = []
      state.DataAll.edges.forEach((item, i) => {
        if (item.id !== id) {
          _edges.push(item)
        }
      })
      state.DataAll.edges = _edges
},
複製代碼

節點間連線的清除

ADD_EDGE_DATA: (state, desp) => {
      let _DataAll = state.DataAll
      _DataAll.edges.push({
        ...desp,
        id: state.DataAll.edges.length + 10
      })
      /**
       * 檢測是否成環
       **/
      let isCircle = false
      const { dst_node_id } = desp // 出口 入口id
      const checkCircle = (dst_node_id, nth) => {
        if (nth > _DataAll.nodes.length) {
          isCircle = true
          return false
        } else {
          _DataAll.edges.forEach(item => {
            if (item.src_node_id === dst_node_id) {
              console.log('目標節點是', item.src_node_id, '次數爲', nth)
              checkCircle(item.dst_node_id, ++nth)
            }
          })
        }
      }
      checkCircle(dst_node_id, 1)
      if (isCircle) {
        _DataAll.edges.pop()
        alert('禁止成環')
      }
}
複製代碼

上面的代碼爲節點的增長,其中添加了一個是否成環的檢測, 不斷遞歸節點, 從目標節點身上尋找節點路徑,若是循環次數超過節點總數, 則證實出現了環,取消操做.

在實際項目中, 每一步操做均可以傳給後端,所以前端沒有很大計算量,由後端同窗負責放在緩存中計算

9、 整圖拖動的實現

整圖拖動的實現 把整圖放進svg內部的一個g元素內, 動態傳入g元素上transfrom的translate進行位置的變換,因爲是組件的狀態值(state),筆者不建議放入vuex進行管控,建議放入vue組件裏的data便可, 在本項目中筆者存入了sessionStorage, 方便後面精確計算當前鼠標位置和原始比例中鼠標的所屬位置.

svgMouseDown(e) {
      // svg鼠標按下觸發事件分發
      this.setInitRect();
      if (this.currentEvent === "sel_area") {
        this.selAreaStart(e);
      } else {
        // 那就拖動畫布
        this.currentEvent = "move_graph";
        this.graphMovePre(e);
      }
    },
複製代碼

事件觸發: 在svg畫布mousedown的時候進行事件分發

/**
     * 畫布拖動
     */
    graphMovePre(e) {
      const { x, y } = e;
      this.svg_trans_init = { x, y };
      this.svg_trans_pre = { x: this.svg_left, y: this.svg_top };
    },
    graphMoveIng(e) {
      const { x, y } = this.svg_trans_init;
      this.svg_left = e.x - x + this.svg_trans_pre.x;
      this.svg_top = e.y - y + this.svg_trans_pre.y;
      sessionStorage["svg_left"] = this.svg_left;
      sessionStorage["svg_top"] = this.svg_top;
    },
複製代碼

在mousemove的過程當中監聽鼠標動態變化, 經過比較mousedown的初始位置,來更改當前畫布位置 關於座標計算的問題放在整圖縮放裏講, 迴歸座標計算須要考慮縮放倍數

10、 整圖縮放的實現 & 當前鼠標位置計算原始座標

同十一, 經過svg下面g標籤的transform: scale(x), 來進行節點的總體縮放

<g :transform="` translate(${svg_left}, ${svg_top}) scale(${svgScale})`" >
複製代碼

在這裏svgScale使用了vuex來管控 , 是想證實, 組件的狀態管理, 沒有統一規範, 可是依然強烈建議state交給組件, 數據(data)交給vuex.
↓↓

svgScale: state => state.dagStore.svgSize
複製代碼

這裏新增一個懸浮欄組件, 方便用戶操做.

<template>
     <g>
        <foreignObject width="200px" height="30px" style="position: relative">
        <body xmlns="http://www.w3.org/1999/xhtml">
            <div class="control_menu">
                <span @click="sizeExpend">╋</span>
                <span @click="sizeShrink">一</span>
                <span @click="sizeInit">╬</span>
                <span :class="['sel_area', 'sel_area_ing'].indexOf(currentEvent) !== -1 ? 'sel_ing' : ''" @click="sel_area($event)">口</span>
                <span @click="fullScreen">{{ changeScreen }}</span>
            </div>
        </body>
        </foreignObject>
    </g>
</template>
複製代碼
/**
     *  svg畫板縮放行爲
     */
    sizeInit() {
      this.changeSize("init"); // 迴歸到默認倍數
      this.svg_left = 0; // 迴歸到默認位置
      this.svg_top = 0;
      sessionStorage['svg_left'] = 0;
      sessionStorage['svg_top'] = 0;
    },
    sizeExpend() {
      this.changeSize("expend"); // 畫板放大0.1
    },
    sizeShrink() {
      this.changeSize("shrink"); // 畫板縮小0.1
    },
複製代碼

因爲是vuex管控,因此在mutation裏改變svgSize

CHANGE_SIZE: (state, action) => {
      switch (action) {
        case 'init':
          state.svgSize = 1
          break
        case 'expend':
          state.svgSize += 0.1
          break
        case 'shrink':
          state.svgSize -= 0.1
          break
        default: state.svgSize = state.svgSize
      }
      sessionStorage['svgScale'] = state.svgSize
    },
複製代碼

截至目前, 咱們已經完成了graph的座標移動和縮放功能,下面有個重要的問題,就是咱們在操做座標行爲的時候,拿到的只能是在組件中的座標, 這樣會致使全部的結果都是錯位的,咱們須要從新計算,拿回無縮放無位移時的真正座標.

以節點拖動結束爲例

paneDragEnd(e) {
      // 節點拖動結束
      this.dragFrame = { dragFrame: false, posX: 0, posY: 0 }; // 關閉模態框
      const x = // x軸座標須要減去X軸位移量, 再除以放縮比例 減去模態框寬度一半
        (e.x - this.initPos.left - (sessionStorage["svg_left"] || 0)) / this.svgScale -
        90;
      const y = // y軸座標須要減去y軸位移量, 再除以放縮比例 減去模態框高度一半
        (e.y - this.initPos.top - (sessionStorage["svg_top"] || 0)) / this.svgScale -
        15;
      let params = {
        model_id: sessionStorage["newGraph"],
        id: this.DataAll.nodes[this.choice.index].id,
        pos_x: x,
        pos_y: y
      };
      this.moveNode(params);
    },
複製代碼

全部用獲得座標的位置,都須要減去橫縱座標偏移量再除以縮放的比例獲取原始比例.代碼再也不贅述.

11、全屏

以chrome瀏覽器的爲例, 不一樣瀏覽器都元素放縮有着不一樣的api

fullScreen() {
      if (this.changeScreen === "全") {
        this.changeScreen = "關";
        let root = document.getElementById("svgContent");
        root.webkitRequestFullScreen();
      } else {
        this.changeScreen = "全";
        document.webkitExitFullscreen();
      }
    }
複製代碼

document.getElementById('svgContent').webkitRequestFullScreen() 將該元素全屏。 document.webkitExitFullScreen() 退出全屏.

12、橡皮筋選框

橡皮筋選框的思路是, 拖動一個div模態框,獲取左上和右下的座標, 改變兩座標內的節點的選取狀態便可.

<div :class="choice.paneNode.indexOf(item.id) !== -1 ? 'pane-node-content selected' : 'pane-node-content'">
      choice: {
        paneNode: [], // 選取的節點下標組
        index: -1,
        point: -1 // 選取的點數的下標
      },
複製代碼

選取狀態爲組件的狀態,故放在組件管控,不走vuex. 框選只須要把選擇元素的id push到paneNode裏便可.

selAreaStart(e) {
      // 框選節點開始 在mousedown的時候調用
      this.currentEvent = "sel_area_ing";
      const x =
        (e.x - this.initPos.left - (sessionStorage["svg_left"] || 0)) /
        this.svgScale;
      const y =
        (e.y - this.initPos.top - (sessionStorage["svg_top"] || 0)) /
        this.svgScale;
      this.simulate_sel_area = {
        left: x,
        top: y,
        width: 0,
        height: 0
      };
    },
    setSelAreaPostion(e) {
      // 框選節點ing  
      const x =
        (e.x - this.initPos.left - (sessionStorage["svg_left"] || 0)) /
        this.svgScale;
      const y =
        (e.y - this.initPos.top - (sessionStorage["svg_top"] || 0)) /
        this.svgScale;
      const width = x - this.simulate_sel_area.left;
      const height = y - this.simulate_sel_area.top;
      this.simulate_sel_area.width = width;
      this.simulate_sel_area.height = height;
    },
    getSelNodes(postions) {
      // 選取框選的節點
      const { left, top, width, height } = postions;
      this.choice.paneNode.length = 0;
      this.DataAll.nodes.forEach(item => {
        if (
          item.pos_x > left &&
          item.pos_x < left + width &&
          item.pos_y > top &&
          item.pos_y < top + height
        ) {
          this.choice.paneNode.push(item.id);
        }
      });
      console.log("目前選擇的節點是", this.choice.paneNode);
    },
複製代碼

this.simulate_sel_area 放置框選模態框的起點座標及高寬,傳遞給組件使用便可.

十3、 事件整理

截至目前,咱們項目裏充斥着大量的事件,這裏咱們能夠經過currentEvent來控制事件行爲, 經過監聽觸發對應事件,進行事件分發.

/**
     * 事件分發器
     */
    dragIng(e) {
     // 事件發放器 根據currentEvent來執行系列事件
      switch (this.currentEvent) {
        case 'dragPane':
          if (e.timeStamp - this.timeStamp > 200) {
            this.currentEvent = "PaneDraging"; // 確認是拖動節點
          };
          break;
        case 'PaneDraging':
           this.setDragFramePosition(e); // 觸發節點拖動
           break;
        case 'dragLink':
          this.setDragLinkPostion(e); // 觸發連線拖動
          break;
        case 'sel_area_ing':
          this.setSelAreaPostion(e); // 觸發框選
          break;
        case 'move_graph':
          this.graphMoveIng(e);
          break;
        default: () => { }
      }
    }
複製代碼

回顧全部內容, 共計三週的時間完成模型可視化需求的實現與組件抽離, 但願能給有須要的同仁以淺顯的幫助,全部代碼並不是最佳實踐,只願拋磚而引玉。

具體代碼可前往github查看點擊跳轉:https://github.com/murongqimiao/DAGBoard.

或前往zhanglizhong.cn查看DEMO

相關文章
相關標籤/搜索