Open Little-Gee opened 3 years ago
注:d3 v6 版本的写法和之前的有些不一样,兼容性也不一样,水平有限,写的不太好请见谅
首先,通过 Vue Cli 初始化一个项目,把多余的东西删掉
安装 d3
npm install d3 -S
这里的版本是 6.5.0
src 下新建文件 flow-chart.vue
// flow-chart.vue <template> <div></div> </template> <script> export default { data() { return {}; } }; </script> <style lang="less"></style>
然后在 App 中使用这个组件
// App.vue <template> <div id="app"> <flow-chart /> </div> </template> <script> import flowChart from './flow-chart.vue'; export default { name: 'App', components: { flowChart } }; </script> <style lang="less"> body, div { margin: 0; padding: 0; } html { height: 100%; } body { height: 100%; font-family: sans-serif; color: #666; line-height: 1.5; } </style>
这样整个项目初始化就算完成了
给 flow-chart.vue 中的 div 增加 class,就叫 chart-container 吧
// flow-chart.vue <div class='chart-container' > </div > .chart-container { width: 800px; height: 500px; margin: 0 auto; border: 1px solid #dfdfdf; }
emmm,好像有点丑,我给框框上面再加个标题,不重要,我就不写了
正题:给我们的 div 中添加 svg,svg 中添加 g,作为外面包裹的,g 里面再增加两个 g,一个是拿来放连线的,一个是拿来放节点的,节点的放下面是为了不被连线遮住(g 元素就是一个容器,里面可以放别的元素,你就理解为拿来分组用的箱子吧)
// flow-chart.vue <div class='chart-container'> <svg id='svg'> <g id='container'> <g id='connectionsGroup'></g> <g id='nodesGroup'></g> </g> </svg> </div>
给外面的 svg 加上样式
// flow-chart.vue #svg { height: 100%; width: 100%; }
这样基本的东西就有了,就当作是整个画布吧
在 App.vue 中 添加节点信息,通过 props 的方式传给子组件,为了对称一点,就弄 5 个吧
// App.vue <flow-chart :nodes="nodes" /> data() { return { nodes: [ { id: 1, name: 'AAAAAAAAAAAAAAAAAAAAA', positionX: 50, positionY: 100 }, { id: 2, name: 'BBB', positionX: 550, positionY: 100 }, { id: 3, name: 'CCC', positionX: 50, positionY: 300 }, { id: 4, name: 'DDD', positionX: 550, positionY: 300 }, { id: 5, name: 'EEE', positionX: 300, positionY: 200 } ] }; }
// flow-chart.vue props: { nodes: { type: Array, default: () => [] } },
这样节点信息就有了,可是怎么让它们显示呢?
现在轮到 d3 正式出场了
// flow-chart.vue import * as d3 from 'd3'; // ... data() { return { d3Nodes: null }; }, mounted() { this.d3Nodes = d3.select('#nodesGroup').selectAll('g'); this.updateGraph(); }, methods: { updateGraph() { // 节点 this.d3Nodes = this.d3Nodes.data(this.nodes, d => d.id); const newGs = this.d3Nodes.enter().append('g'); newGs.append('rect').attr('class', 'node-wrap').attr('rx', 4).attr('ry', 4); newGs.each(function (d) { d3.select(this) .attr('transform', () => `translate(${d.positionX}, ${d.positionY})`) .append('text') .attr('x', 24) .attr('y', 20) .attr('class', 'node-text') .text(() => d.name) .each(function () { const nodeText = d3.select(this); let textLength = nodeText.node().getComputedTextLength(); let text = nodeText.text(); while (textLength > 120 && text.length > 0) { text = text.slice(0, -1); nodeText.text(`${text}...`); textLength = nodeText.node().getComputedTextLength(); } }); }); } } // ... .node-wrap { width: 180px; height: 32px; fill: white; stroke: #e0e3e7; stroke-width: 1px; } .node-text { font-size: 12px; }
我们先引入 d3,然后添加一个变量 d3Nodes 表示用 d3 获取的所有的节点,在 mounted 中先选取了节点外面的 g 元素,再选取这个元素里面所有的 g 元素(当然,目前这里啥也没有)
然后调一个函数 updateGraph
这个函数里先通过 props 传进来的 this.nodes 以及 data() 方法进行数据链接,再调用 enter() 和 append() 新建 g 元素,这样 this.d3Nodes 里就有相应的数据了,这样同时每一个 g 元素就是一个节点,里面存放节点的东西
接着给节点里面加一个方块,及其样式(这里注意一下,因为这些是动态生成的,不受 scoped 样式影响,实际用的时候要避免污染其他样式)
接着是设置节点位置,就是节点 g 元素的位置,再 append 一个 text,给节点加上名称,太长了就显示...(哦,这个不重要
有了节点之后,我们继续为节点添加端点,方便后续连线功能的实现
首先给节点增加端点信息
// App.vue nodes: [ { id: 1, name: 'AAAAAAAAAAAAAAAAAAAAA', positionX: 50, positionY: 100, + inputPorts: [ + { id: 1 } + ], + outputPorts: [ + { id: 2 }, + { id: 3 } + ] }, { id: 2, name: 'BBB', positionX: 550, positionY: 100, + inputPorts: [ + { id: 4 } + ], + outputPorts: [ + { id: 5 } + ] }, { id: 3, name: 'CCC', positionX: 50, positionY: 300, + inputPorts: [ + { id: 6 } + ], + outputPorts: [ + { id: 7 } + ] }, { id: 4, name: 'DDD', positionX: 550, positionY: 300, + inputPorts: [ + { id: 8 } + ], + outputPorts: [ + { id: 9 } + ] }, { id: 5, name: 'EEE', positionX: 300, positionY: 200, + inputPorts: [ + { id: 10 } + ], + outputPorts: [ + { id: 11 } + ] } ]
然后绘制端点
// flow-chart.vue updateGraph() { + const self = this; // 节点 this.d3Nodes = this.d3Nodes.data(this.nodes, d => d.id); const newGs = this.d3Nodes.enter().append('g'); newGs.append('rect').attr('class', 'node-wrap').attr('rx', 4).attr('ry', 4); newGs.each(function (d) { d3.select(this) .attr('transform', () => `translate(${d.positionX}, ${d.positionY})`) .append('text') .attr('x', 24) .attr('y', 20) .attr('class', 'node-text') .text(() => d.name) .each(function () { const nodeText = d3.select(this); let textLength = nodeText.node().getComputedTextLength(); let text = nodeText.text(); while (textLength > 120 && text.length > 0) { text = text.slice(0, -1); nodeText.text(`${text}...`); textLength = nodeText.node().getComputedTextLength(); } }); + d.inputPorts.forEach((port, index) => { + const cx = (180 / (d.inputPorts.length + 1)) * (index + 1); + const cy = 0; + const endpoint = d3.select(this).append('circle'); + self.addEndpointEvent(endpoint, cx, cy); + }); + d.outputPorts.forEach((port, index) => { + const cx = (180 / (d.outputPorts.length + 1)) * (index + 1); + const cy = 32; + const endpoint = d3.select(this).append('circle'); + self.addEndpointEvent(endpoint, cx, cy); + }); }); }, + addEndpointEvent(endpoint, cx, cy) { + endpoint + .attr('cx', cx) + .attr('cy', cy) + .attr('r', 7) + .attr('class', 'endpoint') + .on('mouseover', () => { + endpoint.classed('active', true); + }) + .on('mouseout', () => { + endpoint.classed('active', false); + }); + } // ... @primary-color: #409eff; .endpoint { fill: white; stroke: @primary-color; stroke-width: 1px; opacity: 0.5; cursor: crosshair; &.active { stroke: @primary-color; opacity: 1; } &:hover { fill: @primary-color; stroke: fade(@primary-color, 40%); stroke-width: 7px; } }
通过端点数量计算位置,然后加了点样式,这里 opacity 设置 0.5 是为了调试的时候效果看的清楚一点,之后设置为 0,就是平时不显示,移上去才显示
节点有了,端点也有了,接下来我们添加连线
首先添加连线信息,也是通过 props 传过去
// App.vue <flow-chart :nodes="nodes" :connections="connections" /> // ... connections: [ { id: 1, sourceId: 1, targetId: 5, inputPortId: 3, outputPortId: 10 }, { id: 2, sourceId: 2, targetId: 4, inputPortId: 5, outputPortId: 8 } ]
然后添加连线
// flow-chart.vue props: { nodes: { type: Array, default: () => [] }, + connections: { type: Array, default: () => [] } }, data() { return { d3Nodes: null, + d3Connections: null }; }, mounted() { this.d3Nodes = d3.select('#nodesGroup').selectAll('g'); + this.d3Connections = d3.select('#connectionsGroup').selectAll('g'); this.updateGraph(); }, // ... updateGraph() { // ... // 连线 this.d3Connections = this.d3Connections.data(this.connections, d => d.id).enter().append('path'); this.d3Connections.each(function (d) { const sourceNode = self.nodes.find(node => node.id === d.sourceId); const targetNode = self.nodes.find(node => node.id === d.targetId); if (!sourceNode || !targetNode) { return; } const inputPortIndex = sourceNode.outputPorts.findIndex(port => port.id === d.inputPortId); const outputPortIndex = targetNode.inputPorts.findIndex(port => port.id === d.outputPortId); if (inputPortIndex < 0 || outputPortIndex < 0) { return; } const sourceX = sourceNode.positionX + (180 / (sourceNode.outputPorts.length + 1)) * (inputPortIndex + 1); const sourceY = sourceNode.positionY + 32; const targetX = targetNode.positionX + (180 / (targetNode.inputPorts.length + 1)) * (outputPortIndex + 1); const targetY = targetNode.positionY; const path = `M${sourceX},${sourceY}L${targetX},${targetY}`; d3.select(this) .attr('d', path) .style('fill', 'none') .style('stroke', 'red') .style('stroke-width', '2'); }); }
和节点一样,首先定义存放连线的变量,在 mounted 中获取元素,在 updateGraph 中进行数据链接
然后我们通过连线的 sourceId 和 targetId 找到节点,通过 inputPortId 和 outputPortId 找到节点上的端点(为了方便,这边只考虑一个节点的输出端点连到另一个节点的输入端点)
然后通过节点和端点,计算出起始位置和结束位置,这样我们就可以生成一条线了
// flow-chart.vue const path = `M${sourceX},${sourceY}L${targetX},${targetY}`;
这行代码指的是画线,M 表示的是移动画笔的起始位置,然后 L 命令就是 "Line to" 的意思,这样就会在当前位置和新位置之间画一条线(有需要的话可以接着写 L 命令,这样就可以接着上一个终点连续画线了,不过目前这不需要)
然后给这条线设定样式,连线就出来了
连线是有了,不过这么连好像太丑了点,把它弄成贝塞尔曲线吧
在 svg path 里,有两种贝塞尔曲线:三次贝塞尔曲线和二次贝塞尔曲线,分别对应命令 C 和 Q,这里我们用 C 命令编写三次贝塞尔曲线(也可以用 d3 里 path 的 bezierCurveTo 生成)
// flow-chart.vue // ... - const path = `M${sourceX},${sourceY}L${targetX},${targetY}`; + const path = self.getBezierPath(sourceX, sourceY, targetX, targetY); // ... getBezierPath(sourceX, sourceY, targetX, targetY) { const dx = 10; const dy = 60; const cpx1 = sourceX - dx; const cpy1 = sourceY + dy; const cpx2 = targetX + dx; const cpy2 = targetY - dy; const path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY}`; return path; } // ...
这样看着稍微好一点,可是右边那个明明可以用直线的,变成曲线后反正怪怪的,稍微修改一下
// flow-chart.vue getBezierPath(sourceX, sourceY, targetX, targetY) { - const dx = 10; - const dy = 60; + const absX = Math.abs(targetX - sourceX); + let dx = parseInt(absX / 100, 10); + if (dx > 10) { + dx = 10; + } + if (targetX < sourceX) { + dx = -dx; + } + const absY = Math.abs(targetY - sourceY); + let dy = parseInt(absY / 3, 10); + if (dy < 60) { + dy = 60; + } + if (dy > 150) { + dy = 150; + } const cpx1 = sourceX - dx; const cpy1 = sourceY + dy; const cpx2 = targetX + dx; const cpy2 = targetY - dy; const path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY}`; return path; }
这样就舒服多了
曲线是有了,可是方向呢?我们再添加一个箭头表示方向
// flow-chart.vue <div class="chart-container"> <svg id="svg"> + <defs> + <marker id="markerArrow" markerWidth="5" markerHeight="4" refX="0" refY="2" orient="auto"> + <path d="M 0,0 L 5,2 L 0,4 Z" style="fill: red" /> + </marker> + </defs> <g id="container"> <g id="connectionsGroup"></g> <g id="nodesGroup"></g> </g> </svg> </div> // ... d3.select(this) .attr('d', path) .style('fill', 'none') .style('stroke', 'red') .style('stroke-width', '2') + .attr('marker-end', 'url(#markerArrow)'); // 连线箭头 // ... - const path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY}`; + const path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY - 10}`; // ...
defs 元素是指需要重复使用的图形元素,就像这里箭头被多次使用一样
marker 元素定义了在特定的元素上绘制的箭头或者多边标记图形,在这里特定元素是 path
marker 元素里 path 路径中的 Z 命令是闭合路径命令,就是从终点再连回起点
这样我们就有箭头了
但是仔细看的话,会发现箭头其实不是正的,有一点歪
这是因为我们画的是曲线,方向不是完全向下的,那么我们稍微调一下方向
// flow-chart.vue - const path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY - 10}`; + let path = `M${sourceX},${sourceY}C${cpx1},${cpy1},${cpx2},${cpy2},${targetX},${targetY - 11}`; + path = `${path}L${targetX},${targetY - 11}L${targetX},${targetY - 10}`;
在连线末尾弄出方向向下的一小段,这样箭头就不会歪了
// flow-chart.vue data() { return { + d3Svg: null, + d3G: null, d3Nodes: null, d3Connections: null }; }, mounted() { + this.d3Svg = d3.select('#svg'); + this.d3G = d3.select('#container'); this.d3Nodes = d3.select('#nodesGroup').selectAll('g'); this.d3Connections = d3.select('#connectionsGroup').selectAll('g'); this.updateGraph(); + // 缩放 && 移动 + const zoom = d3 + .zoom() + .scaleExtent([0.5, 3]) + .on('zoom', event => { + this.d3G.attr('transform', `translate(${event.transform.x},${event.transform.y}) scale(${event.transform.k})`); + }) + .on('start', () => { + this.d3Svg.style('cursor', 'move'); + }) + .on('end', () => { + this.d3Svg.style('cursor', 'auto'); + }); + this.d3Svg.call(zoom).on('dblclick.zoom', null); },
缩放和移动还是很方便的,直接调用现成的就行(缩放的是最外面的 g 元素,不是 svg),我们设置缩放范围为 0.5-3 倍,开始和结束的时候可以做其他事,比如这里的修改鼠标样式 需要注意的是 v6 版本中 event 变成参数的形式使用了,之前的版本是直接用 d3.event
// flow-chart.vue updateGraph() { // ... this.d3Nodes = this.d3Nodes.data(this.nodes, d => d.id); const newGs = this.d3Nodes.enter().append('g'); + newGs.call(this.gDrag()); newGs.append('rect').attr('class', 'node-wrap').attr('rx', 4).attr('ry', 4); // ... } // ... gDrag() { return d3 .drag() .on('drag', (event, d) => { d.positionX += event.dx; d.positionY += event.dy; this.updateGraph(); }); }
给节点添加绑定一个移动的函数,改变节点的位置,然后重新调 updateGraph 函数(这里我偷懒直接修改对象里的属性了,严格来说应该 \$emit 出去,在父组件修改)
但是就这么调用 updateGraph 的话,节点和线只会越来越多,就像下面这样
我们需要的是在原基础上修改
// flow-chart.vue updateGraph() { // ... newGs.each(function (d) { d3.select(this) - .attr('transform', () => `translate(${d.positionX}, ${d.positionY})`) .append('text') .attr('x', 24) .attr('y', 20) .attr('class', 'node-text') .text(() => d.name) .each(function () { const nodeText = d3.select(this); let textLength = nodeText.node().getComputedTextLength(); let text = nodeText.text(); while (textLength > 120 && text.length > 0) { text = text.slice(0, -1); nodeText.text(`${text}...`); textLength = nodeText.node().getComputedTextLength(); } }); // ... }); + this.d3Nodes = newGs.merge(this.d3Nodes).attr('transform', d => `translate(${d.positionX}, ${d.positionY})`); - this.d3Connections = this.d3Connections.data(this.connections, d => d.id).enter().append('path'); + this.d3Connections = this.d3Connections.data(this.connections, d => d.id); + const newConnection = this.d3Connections.enter().append('path'); + this.d3Connections = newConnection.merge(this.d3Connections); // ... }
这里我们通过 d3 的 merge()方法来将新旧合并,因为用 data() 进行数据链接的时候,唯一标识是 id,所以只要 id 不变,就不会创建新的节点或连线了
可以看到,移动节点后,只是修改了原节点,并没有新建
讲下思路:先弄一个单独的 path 作为拖出的连线,与普通的连线区别开。然后给端点绑定拖拽事件(这里设定只允许输出端点拖出来),鼠标按下时拖出连线,然后移动到另一个节点的输入端点上,鼠标释放,添加连线
首先添加一个 path,作为拖出的连线,然后用一个变量 d3DragConnection 来保存
// flow-chart.vue <g id="container"> <g id="connectionsGroup"></g> <g id="nodesGroup"></g> + <path id="dragConnection"></path> </g> // ... data() { return { d3Svg: null, d3G: null, d3Nodes: null, d3Connections: null, + d3DragConnection: null }; },
接着在 mounted 中给调拖出来的线加点样式以及箭头,这边为了好分辨一些,我弄成了天蓝色,不过箭头还是之前那个红的
// flow-chart.vue mounted() { this.d3Svg = d3.select('#svg'); this.d3G = d3.select('#container'); this.d3Nodes = d3.select('#nodesGroup').selectAll('g'); this.d3Connections = d3.select('#connectionsGroup').selectAll('g'); + this.d3DragConnection = d3 + .select('#dragConnection') + .style('fill', 'none') + .style('stroke', 'skyblue') + .style('stroke-width', '2') + .attr('marker-end', 'url(#markerArrow)'); // ... },
然后给端点绑定拖拽事件
data() { return { d3Svg: null, d3G: null, d3Nodes: null, d3Connections: null, d3DragConnection: null, + mousedownEndpoint: null, + mouseoverEndpoint: null }; }, // ... d.inputPorts.forEach((port, index) => { const cx = (180 / (d.inputPorts.length + 1)) * (index + 1); const cy = 0; const endpoint = d3.select(this).append('circle'); - self.addEndpointEvent(endpoint, cx, cy); + self.addEndpointEvent(port, endpoint, cx, cy, 'inputPort'); }); d.outputPorts.forEach((port, index) => { const cx = (180 / (d.outputPorts.length + 1)) * (index + 1); const cy = 32; const endpoint = d3.select(this).append('circle'); - self.addEndpointEvent(endpoint, cx, cy); + self.addEndpointEvent(port, endpoint, cx, cy, 'outputPort'); }); // ... - addEndpointEvent(endpoint, cx, cy) { + addEndpointEvent(port, endpoint, cx, cy, type) { endpoint .attr('cx', cx) .attr('cy', cy) .attr('r', 7) .attr('class', 'endpoint') + .on('mousedown', (event, d) => { + event.stopPropagation(); + if (type === 'outputPort') { + this.mousedownEndpoint = { + ...port, + nodeId: d.id + }; + } + }) - .on('mouseover', () => { + .on('mouseover', (event, d) => { endpoint.classed('active', true); + this.mouseoverEndpoint = { + ...port, + nodeId: d.id + }; }) .on('mouseout', () => { endpoint.classed('active', false); }) + .call(this.endpointDrag(cx, cy, type)); }, // ... endpointDrag(cx, cy, type) { return d3 .drag() .on('drag', (event, d) => { if (type === 'inputPort') { return; } const sourceX = d.positionX + cx; const sourceY = d.positionY + cy; // 获取移动和缩放后的坐标 const transform = d3.zoomTransform(this.d3G.node()); const targetXY = transform.invert(d3.pointer(event, this.d3Svg.node())); const bezierPath = this.getBezierPath(sourceX, sourceY, targetXY[0], targetXY[1]); this.d3DragConnection.attr('d', bezierPath); }) .on('end', () => { if (this.mousedownEndpoint && this.mouseoverEndpoint) { const newConnection = { id: Date.now(), sourceId: this.mousedownEndpoint.nodeId, targetId: this.mouseoverEndpoint.nodeId, inputPortId: this.mousedownEndpoint.id, outputPortId: this.mouseoverEndpoint.id }; this.$emit('addConnection', newConnection); this.mousedownEndpoint = null; } this.d3DragConnection.attr('d', null); }); }
我们在拖拽事件中,drag 的时候给之前的拖拽连线设置属性 d,这样就有拖出来的效果了。这里先获取了移动和缩放后的坐标,因为移动和缩放后的坐标会发生变化,不转换的话位置会发生偏移,然后我们通过 d3.pointer() 获取鼠标的位置(以前的版本用的是 d3.mouse()),并通过 d3.zoomTransform() 来获取转换后的坐标
为了在拖拽的 end 回调中获取端点的信息,我们在端点 mousedown 时设置 this.mousedownEndpoint,在 mouseover 时设置 this.mouseoverEndpoint,在 drag 的 end 回调中通过两者来确定连线的信息,最后在 mouseout 时将 this.mouseoverEndpoint 清空,这样鼠标没在端点释放的话,就不会连线
连线完成或者没完成,都在 drag 的 end 回调中删掉拖拽连线的属性,这样线就不会一直存在了
然后在父组件添加连线
// App.vue - <flow-chart :nodes="nodes" :connections="connections" /> + <flow-chart :nodes="nodes" :connections="connections" @addConnection="addConnection" /> methods: { addConnection(connection) { this.connections.push(connection); } }
父组件添加完连线后,子组件需要重新绘制,我们加一个 watch
// flow-chart.vue watch: { connections() { this.updateGraph(); } },
我们完善一下之前的样式,增加一点节点 hover 时的样式,将端点的 opacity 改为 0,鼠标移动到节点上时才显示,以及端点拖拽时显示
// flow-chart.vue updateGraph() { // ... const newGs = this.d3Nodes.enter().append('g'); newGs + .on('mouseover', function () { + d3.select(this).select('.node-wrap').classed('active', true); + d3.select(this).selectAll('.endpoint').classed('active', true); + }) + .on('mouseout', function () { + d3.select(this).select('.node-wrap').classed('active', false); + d3.select(this).selectAll('.endpoint').classed('active', false); + }) .call(this.gDrag()); // ... }, // ... endpointDrag(cx, cy, type) { return d3 .drag() .on('drag', (event, d) => { // ... this.d3DragConnection.attr('d', bezierPath); + d3.selectAll('.endpoint').classed('active', true); }) .on('end', () => { // ... this.d3DragConnection.attr('d', null); + d3.selectAll('.endpoint').classed('active', false); }); } // ... .node-wrap { width: 180px; height: 32px; fill: white; stroke: #e0e3e7; stroke-width: 1px; + &.active { + fill: fade(@primary-color, 5%); + stroke: @primary-color; + } } // ... .endpoint { fill: white; stroke: @primary-color; stroke-width: 1px; - opacity: 0.5; + opacity: 0; cursor: crosshair; // ... }
// App.vue <div class="toolbar"> <button @click="add">添加</button> </div> // ... add() { this.nodes.push({ id: Date.now(), name: Math.random() * 100, positionX: Math.random() * 800, positionY: Math.random() * 500, inputPorts: [ { id: Date.now() + Math.random() * 1000 } ], outputPorts: [ { id: Date.now() + Math.random() * 1000 } ] }); } // ... .toolbar { margin-bottom: 10px; text-align: center; }
watch: { connections() { this.updateGraph(); }, + nodes() { + this.updateGraph(); + } },
就在父组件里增加一个就行了,子组件里 watch 一下(偷懒用了 Date.now() 和 Math.random())
// App.vue <div class="toolbar"> <button @click="add">添加</button> + <button @click="deleteNode">删除</button> </div> - <flow-chart :nodes="nodes" :connections="connections" @addConnection="addConnection" /> + <flow-chart ref="flowChart" :nodes="nodes" :connections="connections" @addConnection="addConnection" /> methods: { addConnection(connection) { this.connections.push(connection); + this.$refs.flowChart.updateGraph(); }, add() { this.nodes.push({ id: Date.now(), name: Math.random() * 100, positionX: Math.random() * 800, positionY: Math.random() * 500, inputPorts: [ { id: Date.now() + Math.random() * 1000 } ], outputPorts: [ { id: Date.now() + Math.random() * 1000 } ] }); + this.$refs.flowChart.updateGraph(); }, + deleteNode() { + const { selectedNodeId } = this.$refs.flowChart; + if (!selectedNodeId) { + return; + } + this.nodes = this.nodes.filter(item => item.id !== selectedNodeId); + this.connections = this.connections.filter(item => item.sourceId !== selectedNodeId && item.targetId !== selectedNodeId); + this.$nextTick(() => { + this.$refs.flowChart.updateGraph(); + }); + } }
// flow-chart.vue data() { return { d3Svg: null, d3G: null, d3Nodes: null, d3Connections: null, d3DragConnection: null, mousedownEndpoint: null, mouseoverEndpoint: null, + selectedNodeId: null }; }, - watch: { - connections() { - this.updateGraph(); - }, - nodes() { - this.updateGraph(); - } - }, // ... updateGraph() { // ... this.d3Nodes = this.d3Nodes.data(this.nodes, d => d.id); + this.d3Nodes.exit().remove(); const newGs = this.d3Nodes.enter().append('g'); // ... newGs .on('mouseover', function () { d3.select(this).select('.node-wrap').classed('active', true); d3.select(this).selectAll('.endpoint').classed('active', true); }) - .on('mouseout', function () { + .on('mouseout', function (event, d) { + if (self.selectedNodeId !== d.id) { d3.select(this).select('.node-wrap').classed('active', false); d3.select(this).selectAll('.endpoint').classed('active', false); + } }) + .on('click', function (event, d) { + d3.selectAll('.node-wrap').classed('active', false); + self.selectedNodeId = d.id; + d3.select(this).select('.node-wrap').classed('active', true); + }) .call(this.gDrag()); } // ... this.d3Connections = this.d3Connections.data(this.connections, d => d.id); + this.d3Connections.exit().remove(); const newConnection = this.d3Connections.enter().append('path'); this.d3Connections = newConnection.merge(this.d3Connections);
删除也是差不多的,就是在父组件删掉对应的数据,删掉节点的同时也要删除连线;在子组件里,数据链接之后,将 exit() 的数据 remove() 即可
为了防止多次调用 updateGraph,就把 wathc 删了,在父组件用 ref 调用子组件方法
然后上面加了点选中的样式,不重要…
最后,项目地址
注:d3 v6 版本的写法和之前的有些不一样,兼容性也不一样,水平有限,写的不太好请见谅
1.初始化项目
首先,通过 Vue Cli 初始化一个项目,把多余的东西删掉
安装 d3
这里的版本是 6.5.0
src 下新建文件 flow-chart.vue
然后在 App 中使用这个组件
这样整个项目初始化就算完成了
2.添加 svg
给 flow-chart.vue 中的 div 增加 class,就叫 chart-container 吧
emmm,好像有点丑,我给框框上面再加个标题,不重要,我就不写了
正题:给我们的 div 中添加 svg,svg 中添加 g,作为外面包裹的,g 里面再增加两个 g,一个是拿来放连线的,一个是拿来放节点的,节点的放下面是为了不被连线遮住(g 元素就是一个容器,里面可以放别的元素,你就理解为拿来分组用的箱子吧)
给外面的 svg 加上样式
这样基本的东西就有了,就当作是整个画布吧
3.添加节点
在 App.vue 中 添加节点信息,通过 props 的方式传给子组件,为了对称一点,就弄 5 个吧
这样节点信息就有了,可是怎么让它们显示呢?
现在轮到 d3 正式出场了
我们先引入 d3,然后添加一个变量 d3Nodes 表示用 d3 获取的所有的节点,在 mounted 中先选取了节点外面的 g 元素,再选取这个元素里面所有的 g 元素(当然,目前这里啥也没有)
然后调一个函数 updateGraph
这个函数里先通过 props 传进来的 this.nodes 以及 data() 方法进行数据链接,再调用 enter() 和 append() 新建 g 元素,这样 this.d3Nodes 里就有相应的数据了,这样同时每一个 g 元素就是一个节点,里面存放节点的东西
接着给节点里面加一个方块,及其样式(这里注意一下,因为这些是动态生成的,不受 scoped 样式影响,实际用的时候要避免污染其他样式)
接着是设置节点位置,就是节点 g 元素的位置,再 append 一个 text,给节点加上名称,太长了就显示...(哦,这个不重要
4.给节点添加端点
有了节点之后,我们继续为节点添加端点,方便后续连线功能的实现
首先给节点增加端点信息
然后绘制端点
通过端点数量计算位置,然后加了点样式,这里 opacity 设置 0.5 是为了调试的时候效果看的清楚一点,之后设置为 0,就是平时不显示,移上去才显示
5.添加连线
节点有了,端点也有了,接下来我们添加连线
首先添加连线信息,也是通过 props 传过去
然后添加连线
和节点一样,首先定义存放连线的变量,在 mounted 中获取元素,在 updateGraph 中进行数据链接
然后我们通过连线的 sourceId 和 targetId 找到节点,通过 inputPortId 和 outputPortId 找到节点上的端点(为了方便,这边只考虑一个节点的输出端点连到另一个节点的输入端点)
然后通过节点和端点,计算出起始位置和结束位置,这样我们就可以生成一条线了
这行代码指的是画线,M 表示的是移动画笔的起始位置,然后 L 命令就是 "Line to" 的意思,这样就会在当前位置和新位置之间画一条线(有需要的话可以接着写 L 命令,这样就可以接着上一个终点连续画线了,不过目前这不需要)
然后给这条线设定样式,连线就出来了
5.1 贝塞尔曲线
连线是有了,不过这么连好像太丑了点,把它弄成贝塞尔曲线吧
在 svg path 里,有两种贝塞尔曲线:三次贝塞尔曲线和二次贝塞尔曲线,分别对应命令 C 和 Q,这里我们用 C 命令编写三次贝塞尔曲线(也可以用 d3 里 path 的 bezierCurveTo 生成)
这样看着稍微好一点,可是右边那个明明可以用直线的,变成曲线后反正怪怪的,稍微修改一下
这样就舒服多了
5.2 添加箭头
曲线是有了,可是方向呢?我们再添加一个箭头表示方向
defs 元素是指需要重复使用的图形元素,就像这里箭头被多次使用一样
marker 元素定义了在特定的元素上绘制的箭头或者多边标记图形,在这里特定元素是 path
marker 元素里 path 路径中的 Z 命令是闭合路径命令,就是从终点再连回起点
这样我们就有箭头了
但是仔细看的话,会发现箭头其实不是正的,有一点歪
这是因为我们画的是曲线,方向不是完全向下的,那么我们稍微调一下方向
在连线末尾弄出方向向下的一小段,这样箭头就不会歪了
6.画布缩放和移动
缩放和移动还是很方便的,直接调用现成的就行(缩放的是最外面的 g 元素,不是 svg),我们设置缩放范围为 0.5-3 倍,开始和结束的时候可以做其他事,比如这里的修改鼠标样式 需要注意的是 v6 版本中 event 变成参数的形式使用了,之前的版本是直接用 d3.event
7.节点移动
给节点添加绑定一个移动的函数,改变节点的位置,然后重新调 updateGraph 函数(这里我偷懒直接修改对象里的属性了,严格来说应该 \$emit 出去,在父组件修改)
但是就这么调用 updateGraph 的话,节点和线只会越来越多,就像下面这样
我们需要的是在原基础上修改
这里我们通过 d3 的 merge()方法来将新旧合并,因为用 data() 进行数据链接的时候,唯一标识是 id,所以只要 id 不变,就不会创建新的节点或连线了
可以看到,移动节点后,只是修改了原节点,并没有新建
8.端点拖出连线
讲下思路:先弄一个单独的 path 作为拖出的连线,与普通的连线区别开。然后给端点绑定拖拽事件(这里设定只允许输出端点拖出来),鼠标按下时拖出连线,然后移动到另一个节点的输入端点上,鼠标释放,添加连线
首先添加一个 path,作为拖出的连线,然后用一个变量 d3DragConnection 来保存
接着在 mounted 中给调拖出来的线加点样式以及箭头,这边为了好分辨一些,我弄成了天蓝色,不过箭头还是之前那个红的
然后给端点绑定拖拽事件
我们在拖拽事件中,drag 的时候给之前的拖拽连线设置属性 d,这样就有拖出来的效果了。这里先获取了移动和缩放后的坐标,因为移动和缩放后的坐标会发生变化,不转换的话位置会发生偏移,然后我们通过 d3.pointer() 获取鼠标的位置(以前的版本用的是 d3.mouse()),并通过 d3.zoomTransform() 来获取转换后的坐标
为了在拖拽的 end 回调中获取端点的信息,我们在端点 mousedown 时设置 this.mousedownEndpoint,在 mouseover 时设置 this.mouseoverEndpoint,在 drag 的 end 回调中通过两者来确定连线的信息,最后在 mouseout 时将 this.mouseoverEndpoint 清空,这样鼠标没在端点释放的话,就不会连线
连线完成或者没完成,都在 drag 的 end 回调中删掉拖拽连线的属性,这样线就不会一直存在了
然后在父组件添加连线
父组件添加完连线后,子组件需要重新绘制,我们加一个 watch
9.改点样式
我们完善一下之前的样式,增加一点节点 hover 时的样式,将端点的 opacity 改为 0,鼠标移动到节点上时才显示,以及端点拖拽时显示
10.增加节点
就在父组件里增加一个就行了,子组件里 watch 一下(偷懒用了 Date.now() 和 Math.random())
11.删除节点
删除也是差不多的,就是在父组件删掉对应的数据,删掉节点的同时也要删除连线;在子组件里,数据链接之后,将 exit() 的数据 remove() 即可
为了防止多次调用 updateGraph,就把 wathc 删了,在父组件用 ref 调用子组件方法
然后上面加了点选中的样式,不重要…
最后,项目地址