内容简介:最近公司需要做一个内部使用的机器学习平台,其中有一部分需求可以抽象为有向无环图,一边踩坑一边把研发过程记录了一下(其实是搜不到高耦合业务的成品轮子 ),如果有类似需求,不妨泡杯枸杞,慢慢读完此篇.教程实现的内容有:
首发简书, 此为合并整理版.代码链接放在文末
最近公司需要做一个内部使用的机器学习平台,其中有一部分需求可以抽象为有向无环图,一边踩坑一边把研发过程记录了一下(其实是搜不到高耦合业务的成品轮子 ),如果有类似需求,不妨泡杯枸杞,慢慢读完此篇.
教程实现的内容有:
模型节点的拖动, 建立关系(连线)
![[简易版]有向无环图(DAG)前端可视化](https://img1.tuicool.com/eIjmYjq.jpg!web)
模型节点外部操作(节点的增删,前端实现的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)来自于vue-x, 在后文会对vue-x进行统一讲解.
七、节点的删除
删除节点使用右键调出选项框,这里我们可以监听元素的右键行为,并禁掉所有默认行为.
<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直接调用vue-x的delNode即可
八、 连线,节点的删除及vue-x的使用
为了组件分的更加细致,方便组件间的数据共享,引入vue-x作为本项目的数据承接.多组件共同使用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),笔者不建议放入vue-x进行管控,建议放入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使用了vue-x来管控 , 是想证明, 组件的状态管理, 没有统一规范, 但是依然强烈建议state交给组件, 数据(data)交给vue-x.
↓↓
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 }, 复制代码
由于是vue-x管控,所以在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
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Remote
Jason Fried、David Heinemeier Hansson / Crown Business / 2013-10-29 / CAD 26.95
The “work from home” phenomenon is thoroughly explored in this illuminating new book from bestselling 37signals founders Fried and Hansson, who point to the surging trend of employees working from hom......一起来看看 《Remote》 这本书的介绍吧!
在线进制转换器
各进制数互转换器
HSV CMYK 转换工具
HSV CMYK互换工具