Little-Gee / blog

15 stars 11 forks source link

Vue2 + d3 流程图 #14

Open Little-Gee opened 3 years ago

Little-Gee commented 3 years ago

注:d3 v6 版本的写法和之前的有些不一样,兼容性也不一样,水平有限,写的不太好请见谅

1.初始化项目

首先,通过 Vue Cli 初始化一个项目,把多余的东西删掉

1

安装 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>

这样整个项目初始化就算完成了

2.添加 svg

给 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%;
}

这样基本的东西就有了,就当作是整个画布吧

2

3.添加节点

在 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,给节点加上名称,太长了就显示...(哦,这个不重要

3

4.给节点添加端点

有了节点之后,我们继续为节点添加端点,方便后续连线功能的实现

首先给节点增加端点信息

// 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,就是平时不显示,移上去才显示

4

5.添加连线

节点有了,端点也有了,接下来我们添加连线

首先添加连线信息,也是通过 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 命令,这样就可以接着上一个终点连续画线了,不过目前这不需要)

然后给这条线设定样式,连线就出来了

5

5.1 贝塞尔曲线

连线是有了,不过这么连好像太丑了点,把它弄成贝塞尔曲线吧

在 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;
}

// ...
6

这样看着稍微好一点,可是右边那个明明可以用直线的,变成曲线后反正怪怪的,稍微修改一下

// 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;
}

这样就舒服多了

7

5.2 添加箭头

曲线是有了,可是方向呢?我们再添加一个箭头表示方向

// 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 命令是闭合路径命令,就是从终点再连回起点

这样我们就有箭头了

8

但是仔细看的话,会发现箭头其实不是正的,有一点歪

9

这是因为我们画的是曲线,方向不是完全向下的,那么我们稍微调一下方向

// 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}`;

在连线末尾弄出方向向下的一小段,这样箭头就不会歪了

10

6.画布缩放和移动

// 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

11

7.节点移动

// 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 的话,节点和线只会越来越多,就像下面这样

12

我们需要的是在原基础上修改

// 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 不变,就不会创建新的节点或连线了

13

可以看到,移动节点后,只是修改了原节点,并没有新建

8.端点拖出连线

讲下思路:先弄一个单独的 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();
    }
},

14

9.改点样式

我们完善一下之前的样式,增加一点节点 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;

    // ...
}

15

10.增加节点

// 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())

17

11.删除节点

// 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 调用子组件方法

然后上面加了点选中的样式,不重要…

16

最后,项目地址