内容简介:最近公司需要做一个内部使用的机器学习平台,其中有一部分需求可以抽象为有向无环图,一边踩坑一边把研发过程记录了一下(其实是搜不到高耦合业务的成品轮子 ),如果有类似需求,不妨泡杯枸杞,慢慢读完此篇.教程实现的内容有:
首发简书, 此为合并整理版.代码链接放在文末
最近公司需要做一个内部使用的机器学习平台,其中有一部分需求可以抽象为有向无环图,一边踩坑一边把研发过程记录了一下(其实是搜不到高耦合业务的成品轮子 ),如果有类似需求,不妨泡杯枸杞,慢慢读完此篇.
教程实现的内容有:
模型节点的拖动, 建立关系(连线)
模型节点外部操作(节点的增删,前端实现的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
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
C++ Primer Plus
Stephen Prata / 张海龙、袁国忠 / 人民邮电出版社 / 2012-6-19 / 99.00元
C++是在C语言基础上开发的一种集面向对象编程、通用编程和传统的过程化编程于一体的编程语言,是C语言的超集。本书是根据2003年的ISO/ANSI C++标准编写的。通过大量短小精悍的程序详细而全面地阐述了C++的基本概念和技术。全书分为18章和10个附录,分别介绍了C++程序的运行方式、基本数据类型、复合数据类型、循环和关系表达式、分支语句和逻辑操作符、函数重载和函数模板、内存模型和名称空间、类......一起来看看 《C++ Primer Plus》 这本书的介绍吧!