首發簡書, 此為合併整理版.程式碼連結放在文末
最近公司需要做一個內部使用的機器學習平臺,其中有一部分需求可以抽象為有向無環圖,一邊踩坑一邊把研發過程記錄了一下(其實是搜不到高耦合業務的成品輪子?),如果有類似需求,不妨泡杯枸杞,慢慢讀完此篇.
教程實現的內容有:
模型節點的拖動, 建立關係(連線)
模型節點外部操作(節點的增刪,前端實現的DAG環檢測)
模型整圖的平面移動(全圖放縮,選框,全屏等)
關於前端視覺化的技術選型.
初接需求, 考慮使用svg與canvas實現此內容,綜合來看:
名稱 | svg | canvas |
---|---|---|
影象質量 | 向量圖隨意縮放 | 點陣圖,縮放失真 |
事件驅動 | 基於dom元素,繫結事件easy | 指令碼驅動,事件配置不靈活 |
效能 | 同上,故渲染元素過多會造成卡頓 | 效能極高,更有離屏canvas未來趨勢 |
適用場景 | 互動行為較多量級較少影象 | 超多重複元素的渲染 |
學習成本 | 相對簡單 | 上手有一定成本 |
故,整體選用svg,且目前市面上基於svg實現的成品有很多, 比如墨刀,processon,noflo,和阿里系的諸多平臺,在部分場景下的表現相當優秀(當然也方便隨時扒開程式碼學習寫法啦~)
書接前文,切回正題
一、節點的實現
對應節點程式碼(初版)為{
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後)優化為:
{
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的時候判斷連入了哪個元素
五、整合以上步驟, 元件抽離
隨著內容的增多,我們需要把所有內容整合, 基於耦合內容對元件進行分割,具體可看目錄結構
所有的連線變成arrow元件,只繼承座標位置用以渲染 simulateFrame和simulateArrow只動態繼承拖拽時的座標,用以模擬拖拽效果六、節點拖拽新增的實現
程式導向來看, 節點拖動無非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進行統一講解.
七、節點的刪除
刪除節點使用右鍵調出選項框,這裡我們可以監聽元素的右鍵行為,並禁掉所有預設行為. <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的使用
為了元件分的更加細緻,方便元件間的資料共享,引入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('禁止成環')
}
}
複製程式碼
上面的程式碼為節點的增加,其中新增了一個是否成環的檢測, 不斷遞迴節點, 從目標節點身上尋找節點路徑,如果迴圈次數超過節點總數, 則證明出現了環,取消操作.
在實際專案中, 每一步操作都可以傳給後端,因此前端沒有很大計算量,由後端同學負責放在快取中計算
九、 整圖拖動的實現
整圖拖動的實現 把整圖放進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的初始位置,來更改當前畫布位置
關於座標計算的問題放在整圖縮放裡講, 迴歸座標計算需要考慮縮放倍數
十、 整圖縮放的實現 & 當前滑鼠位置計算原始座標
同十一, 通過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