首發簡書, 此爲合併整理版.代碼連接放在文末
html
最近公司須要作一個內部使用的機器學習平臺,其中有一部分需求能夠抽象爲有向無環圖,一邊踩坑一邊把研發過程記錄了一下(實際上是搜不到高耦合業務的成品輪子🤷),若是有相似需求,不妨泡杯枸杞,慢慢讀完此篇.前端
教程實現的內容有:vue
模型節點的拖動, 創建關係(連線) node
模型節點外部操做(節點的增刪,前端實現的DAG環檢測) git
模型整圖的平面移動(全圖放縮,選框,全屏等) github
初接需求, 考慮使用svg與canvas實現此內容,綜合來看:web
名稱 | svg | canvas |
---|---|---|
圖像質量 | 矢量圖隨意縮放 | 位圖,縮放失真 |
事件驅動 | 基於dom元素,綁定事件easy | 腳本驅動,事件配置不靈活 |
性能 | 同上,故渲染元素過多會形成卡頓 | 性能極高,更有離屏canvas將來趨勢 |
適用場景 | 交互行爲較多量級較少圖像 | 超多重複元素的渲染 |
學習成本 | 相對簡單 | 上手有必定成本 |
故,總體選用svg,且目前市面上基於svg實現的成品有不少, 好比墨刀,processon,noflo,和阿里系的諸多平臺,在部分場景下的表現至關優秀(固然也方便隨時扒開代碼學習寫法啦~)vuex
書接前文,切回正題chrome
{ 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] } 複製代碼
請忽略靈魂繪圖師的抽象,一切基於數據驅動,模型節點只須要仿照上圖與後端研發交互便可.
<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
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
和上一步相似,咱們也是經過監聽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的時候判斷連入了哪一個元素
隨着內容的增多,咱們須要把全部內容整合, 基於耦合內容對組件進行分割,具體可看目錄結構
·拖動前判斷當前狀況下可否拖動, 拖動的元素攜帶的節點類型,節點名稱等參數
·拖動中模擬的節點隨鼠標進行位移,將參數賦值給模擬的節點
·拖動中止判斷鬆手位置是否在畫板中, ( 更改模型數據 | 調用後臺接口 )
因此咱們須要一個可以全屏移動的模擬元素 如圖 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進行統一講解.
<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便可
爲了組件分的更加細緻,方便組件間的數據共享,引入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('禁止成環') } } 複製代碼
上面的代碼爲節點的增長,其中添加了一個是否成環的檢測, 不斷遞歸節點, 從目標節點身上尋找節點路徑,若是循環次數超過節點總數, 則證實出現了環,取消操做.
在實際項目中, 每一步操做均可以傳給後端,所以前端沒有很大計算量,由後端同窗負責放在緩存中計算
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的初始位置,來更改當前畫布位置
關於座標計算的問題放在整圖縮放裏講, 迴歸座標計算須要考慮縮放倍數
同十一, 經過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); }, 複製代碼
全部用獲得座標的位置,都須要減去橫縱座標偏移量再除以縮放的比例獲取原始比例.代碼再也不贅述.
以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() 退出全屏.
橡皮筋選框的思路是, 拖動一個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 放置框選模態框的起點座標及高寬,傳遞給組件使用便可.
截至目前,咱們項目裏充斥着大量的事件,這裏咱們能夠經過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