如何使用svg绘制流程图

您所在的位置:网站首页 vue使用流程图添加节点 如何使用svg绘制流程图

如何使用svg绘制流程图

2024-02-02 07:10| 来源: 网络整理| 查看: 265

一、SVG的简单了解 什么是SVG

在学习 SVG 之前,首先要了解 位图 和 矢量图 的区别。

简单来说:

位图:放大会失真图像边缘有锯齿;是由像素点组成;前端的 Canvas 就是位图效果。 矢量图:放大不会失真;使用 XML 描述图形。

左边是位图,右边是矢量图

image.png

那么 SVG 是什么呢?它是矢量图的其中一种格式。它是用 XML 来描述图形的。

对于初学 SVG 的前端来说,可以简单的理解为 “SVG 是一套新标签”。

所以可以使用 CSS 来设置样式,也可以使用 JS 对 SVG 进行操作。

SVG默认宽高

在 HTML 中使用 SVG ,直接用 标签即可。

在不给 设置宽高时,它的默认宽度是 300px ,默认高度是 150px ,所以在使用svg的时候都需要指定 SVG的宽和高(可通过百分比设置)。

基础图形 矩形Rect

矩形使用 标签,默认填充色是黑色,当只设置宽高时,渲染出来的矩形就是黑色的矩形。

x: 左上角x轴坐标 y: 左上角y轴坐标 width: 宽度 height: 高度 rx: 圆角,x轴的半径 ry: 圆角,y轴的半径

image.png

圆形Circle

圆形使用 标签,基础属性有:

cx: 圆心在x轴的坐标 cy: 圆心在y轴的坐标 r: 半径

image.png

椭圆Ellipse

椭圆使用 标签,基础属性有:

cx: 圆心在x轴的坐标 cy: 圆心在y轴的坐标 rx: x轴的半径 ry: y轴的半径

image.png

直线Line

直线使用 标签,基础属性有:

x1: 起始点x坐标 y1: 起始点y坐标 x2: 结束点x坐标 y2: 结束点y坐标 stroke: 描边颜色

image.png

路径Path

其实在 SVG 里,所有基本图形都是 的简写。所有描述轮廓的数据都放在 d 属性里,d 是 data 的简写。 d 属性又包括以下主要的关键字(注意大小写!):

M: 起始点坐标,moveto 的意思。每个路径都必须以 M 开始。M 传入 x 和 y 坐标,用逗号或者空格隔开。 L: 轮廓坐标,lineto 的意思。L 是跟在 M 后面的。它也是可以传入一个或多个坐标。大写的 L 是一个绝对位置。 l: 这是小写 L,和 L 的作用差不多,但 l 是一个相对位置。 H: 和上一个点的Y坐标相等,是 horizontal lineto 的意思。它是一个绝对位置。(水平线) h: 和 H 差不多,但 h 使用的是相对定位。 V: 和上一个点的X坐标相等,是vertical lineto 的意思。它是一个绝对位置。(垂直线) v: 这是一个小写的 v ,和大写 V 的差不多,但小写 v 是一个相对定位。 Z: 关闭当前路径,closepath 的意思。它会绘制一条直线回到当前子路径的起点。

stroke-dasharray 属性

用于虚线创建,参数为一组数字集合,代表实现和虚线交替的长度 stroke-dasharray = '10' stroke-dasharray = '10, 5' stroke-dasharray = '20, 10, 5'

image.png

stroke-dasharray为一个参数时: 其实是表示虚线长度和每段虚线之间的间距

如:stroke-dasharray = '10' 表示:虚线长10,间距10,然后重复 虚线长10,间距10

两个参数或者多个参数时:一个表示长度,一个表示间距

如:stroke-dasharray = '10, 5' 表示:虚线长10,间距5,然后重复 虚线长10,间距5

如:stroke-dasharray = '20, 10, 5' 表示:虚线长20,间距10,虚线长5,接着是间距20,虚线10,间距5,之后开始如此循环。

示例:

利用这个属性可以做出好看的动画效果

线段从无到有,由短变长

1.gif

动画绘制指定路径

2.gif

图标的动画效果

3.gif

实现思路:

@keyframes move { 0%{ stroke-dasharray: 0 300px; // 300px 为路径的有效长度 } 100%{ stroke-dasharray: 300px 0; } }

鼠标移入的时候设置路径动画由stroke-dasharray: 0 300px ☞ stroke-dasharray: 300px 0 实现虚线的可见部分和空白部分长度的动态更新。

其中300px是路径path的有效长度(这里设置的长度大于等于路径长度都可以实现)。

如何获取路径的有效长度:==getTotalLength==

document.querySelector('.st0').getTotalLength() 曲线-椭圆弧路径Path 什么是椭圆弧?

前面讲到的 直线路径 path 是比较好理解的,它把所有点都用直线连接起来即可。只要确定2个点就可以画出一根线段。 但如果只用两个点,可以产生无数条曲线。所以需要添加更多的参数来确定如何绘制一条曲线。而在种种方法中,我认为 椭圆弧曲线 是最简单的。

椭圆弧曲线,顾名思义就是和椭圆有关的。如果在椭圆上选择两个点,就可以截取2条曲线。

image.png

比如这样,红线处两个点就把椭圆截成了两端弧线。

椭圆弧公式

在 SVG 中可以使用 path 配合 A属性 绘制椭圆弧。

A(rx, ry, xr, laf, sf, x, y) rx: 椭圆X轴半径 ry: 椭圆Y轴半径 xr: 椭圆旋转角度 laf: 是否选择弧长较长的那一段。0: 短边(小于180度); 1: 长边(大于等于180度) sf: 是否顺时针绘制。0: 逆时针; 1: 顺时针 x: 终点X轴坐标 y: 终点Y轴坐标

上面的公式中并没有开始点,开始点是由 M 决定的。

也就是说,确定2个点,再确定椭圆半径,就可画出2个椭圆。

image.png

通过开始点和结束点裁切,可以得到4条弧线,也就是说2个点可以确定2个相同旋转角度的椭圆的位置,可以切出4条弧线。

image.png

文本元素Text

SVG 可以使用 标签渲染文本。文本是有 “基线” 概念的,这个概念和 CSS (vertical-align)的一样。

基础版

和 Canvas 一样,SVG 的文本对齐方式是以第一个字基线的左下角为基准。

image.png

Hello 数据中台

可以看到,文字跑去左上角了。但这并不是我们想要的效果。

SVG 如果没设置字号,它会跟随父元素的字号,一直往上跟跟跟上去。

如果我们想看到文本,就需要将文字往下移动 16px,因为本文的对齐方式是以第一个字的基线的左下角为参考,默认的位置坐标是 (0, 0) ,现在要将y轴坐标改成 16px 才能完整显示文本。

image.png

Hello 数据中台 font-weight 粗体 normal: 默认(非粗体) bold: 粗体 text-decoration 装饰线 none:默认 underline: 下划线 overline: 上划线 line-through: 删除线

image.png

text-anchor 水平对齐方式

可以通过 text-anchor 属性设置文本水平对齐方式(默认 start)。

如果本子是从左向右书写,那这几个参数的意思就是:

start: 左对齐 middle: 居中对齐 end: 右对齐

image.png

dominant-baseline 垂直对齐方式

可以通过 dominant-baseline 属性设置文本垂直对齐方式 (默认 auto 基线对齐)

auto: 默认的对齐方式,保持与父元素相同的配置。 text-after-edge: 在基线上方 middle: 居中基线 text-before-edge: 在基线下方

image.png

二、D3.js 简介

D3.js(D3或Data-DrivenDocuments)是一个使用动态图形,基于数据操作文档,进行数据可视化的JavaScript程序库。D3帮助您通过使用HTML、SVG和CSS使数据栩栩如生,产生交互式的数据展示效果——分层条形图、动画树状图、力导向图、等高线、散点图⋯⋯。且D3提供了现代浏览器的全部功能,无需将束缚在特定框架中,可以与Vue、React等结合使用,提供强大的可视化组件和数据驱动的DOM操作方法。

元素的选择和数据绑定 元素选择

D3可以非常简洁地操作HTML中的DOM元素,我们通过d3.select()(选择第一个找到的元素)或d3.selectAll()(选择所有找到的元素)选择元素后返回了对象,这就是选择集。我们可以根据元素的不同特性来选择出想要的对象,根据属性值、class、id等都可以进行选择。 而多次连续调用的.style()等函数被称为链式语法,和JQuery中的语法颇为类似。此处我们调用.style()改变了元素的样式,而D3还可以提供设置属性(.attr())、添加(.append())、更改文本内容(.text())等方法,能满足用户大部分的需求。

//选择所有

标签的网页元素 var e = d3.select("body").selectAll("p"); //将颜色样式改为blue,用链式语法继续将文本大小修改为72px e.style("color","blue").style("font-size","72px"); 数据绑定

D3可以将数据绑定到DOM上去(DOM能将HTML文档表达为树结构,数据绑定与DOM绑定即是让HTML标签与数据进行绑定)。

比如,让的段落元素p标签与字符串变量“Hello”绑定,绑定后,当需要依靠该数据操作元素时,会更为方便。

D3中有两个函数可以绑定数据:

datum():绑定一个数据到选择集上。 data():绑定一个数组到选择集上,数组的各项值分别与选择集的各元素绑定。(更常用)

举一个例子,当前有三个段落元素如下:

张三 李四 王五

方式一:使用**datum()**绑定

假设有一个字符串“China”,可将其分别与三个段落p元素绑定:

var str = "China"; var body = d3.select("body"); var p = body.selectAll("p"); p.datum(str); /** * @param { } d 绑定数据 * @param { } i 当前节点下标 * @param { } nodes 当前选中的所有节点集合 */ p.text(function(d, i, nodes){ return i + ":" + d; });

绑定数据后,使用此数据来修改三个段落元素的内容,其结果为:

0:China 1:China 2:China

方式二:使用**data()**绑定

有一个数组vararr=["a","b","c"];,接下来要分别将数组的各元素绑定到三个段落元素上。

绑定后,其对应关系应为张三-a,李四-b,王五-c。我们调用data()函数绑定数据,并替换三个段落元素的字符串为被绑定的字符串,代码如下:

var arr = ["a","b","c"]; var body = d3.select("body"); var p = body.selectAll("p"); p.data(arr).text(function(d, i, nodes){ return d; }); 插入和删除元素 插入元素

selection**.append**(type)** **

如果指定的 type 为字符串则创建一个以此字符串为标签名的元素,并将其追加到选择集元素列表中。如果选择集为 enter selection 则将其添加到下一个同胞节点之前。后续的 enter 选择集将和新的元素一起被插入到 DOM 中。但是要注意的是当绑定的数据发生顺序变化时仍然需要使用 selection.order 来同步更新元素的次序。(比如新元素与之前绑定的数据元素之间的次序发生变化)

如果 type 为函数则会为每个选中的元素执行,并传递当前绑定的元素 d,当前索引 i 以及当前分组 nodes,函数内部 this 指向当前 DOM 元素(nodes[i]). 函数应该返回一个元素用来被添加到 DOM 中(通常在函数内部创建一个新元素节点返回,但是也可能会返回一个已有的元素)。例如为每个 p 标签中添加一个 div 元素:

d3.selectAll("p").append("div");

等价于:

d3.selectAll("p").append(function() { return document.createElement("div"); });

等价于:

d3.selectAll("p").select(function() { return this.appendChild(document.createElement("div")); });

无论是 type 是字符串还是返回 DOM 元素的函数,都会返回一个新的包含被添加元素的选择集。每个新的元素都会继承当前元素的数据(如果有的话)。

selection.insert(type[, before])

如果 type 为字符串则为选择集中每个选中的插入一个指定类型(标签名)的元素,插入的位置为第一个匹配 before 选择条件的元素。例如使用 :first-child 会将新的元素插入到第一个子元素的位置。如果没有指定 before 则默认为 null。(按 bound data(数据绑定) 次序添加元素考虑使用 selection.append.) type 和 before 都可以使用函数代替,函数会为选择集中的每个元素调用,并传递当前元素绑定的数据 d,索引 i 以及当前分组 nodes,函数内部 this 指向当前的 DOM 元素(nodes[i]). type 函数应该返回一个被插入的元素,before 函数有应该返回当前元素的子元素用来定位被插入元素的位置。例如为每个 p 元素插入 DIV 元素:

d3.selectAll("p").insert("div");

等价于:

d3.selectAll("p").insert(function() { return document.createElement("div"); });

等价于:

d3.selectAll("p").select(function() { return this.insertBefore(document.createElement("div"), null); }); 删除元素

selection.remove()

从当前文档中移除选中的元素。返回的选择集(被移除的元素)已经与文档脱离。

Enter、Update、Exit enter()

返回 enter 选择集: 没有对应 DOM 节点的数据的占位节点. (对于不是通过 selection.data 返回的选择集 enter 选择集为空) enter 选择集通常在数据比节点多时用来创建缺失的节点。比如根据以下数据创建 DIV 元素:

var div = d3.select("body") .selectAll("div") .data([4, 8, 15, 16, 23, 42]) .enter().append("div") .text(function(d) { return d; });

如果 body 初始为空,则上述代码会创建 6 个新的 DIV 元素并依次添加到 body 中,并且将其文本内容设置为对应的数值:

4 8 15 16 23 42

从概念上来讲,enter 选择集的占位符是一个指向父元素的指针(上述例子中为 body)。enter 选择集通常仅仅用来添加元素,并且在添加完元素之后与 update 选择集进行 merged, 这样的话数据的修改可以同时应用于 enter 的元素和 update 的元素。

exit()

返回 exit 选择集: 没有对应数据的已经存在的 DOM 节点。(对于不是通过 selection.data 返回的选择集 exit 选择集为空)

exit 选择集通常用来移除多余的元素。例如使用新的数据更新 DIV 元素:

div = div.data([1, 2, 4, 8, 16, 32], function(d) { return d; });

因为指定了 key 函数(恒等函数), 并且新的数据包含数值 [4, 8, 16] 能匹配到已经存在的元素, update 选择集包含三个 DIV 元素。保留已经存在的并且能匹配数据的元素,然后通过 enter 选择集为 [1, 2, 32] 添加新的元素:

div.enter().append("div").text(function(d) { return d; });

同样的, 移除与 [15, 23, 42] 绑定的元素:

div.exit().remove(); 三、逻辑结构图

【逻辑结构图】如何进行布局计算

image.png

逻辑结构图如上图所示,子节点在父节点的右侧,然后父节点相对于子节点总体来说是垂直居中的。

节点初始化布局

d3.js支持对层次数据进行可视化的一些布局算法。

hierarchy

在计算层次布局之前,你需要一个根节点。如果你的数据已经是层次结构,比如 JSON。 根据指定的层次结构数据构造一个根节点。指定的数据 data 必须为一个表示根节点的对象。比如:

const root = { "name": "Eve", "children": [ { "name": "Cain" }, { "name": "Seth", "children": [ { "name": "Enos" }, { "name": "Noam" } ] }, { "name": "Abel" }, { "name": "Awan", "children": [ { "name": "Enoch" } ] }, { "name": "Azura" } ] } const hierarchydata = d3.hierarchy(root, d => d.children) nodes = hierarchydata.descendants() // 返回节点数据list links = hierarchydata.links() // 返回边节点list

返回的节点和每一个后代会被附加如下属性:

node.data - 关联的数据,由 constructor 指定. node.depth - 当前节点的深度, 根节点为 0. node.height - 当前节点的高度, 叶节点为 0. node.parent - 当前节点的父节点, 根节点为 null. node.children - 当前节点的孩子节点(如果有的话); 叶节点为 undefined. node.value - 当前节点以及 descendants(后代节点) 的总计值; 可以通过 node.sum 和 node.count 计算.

这个时候返回的每个节点数据的坐标以及宽和高都是无效的,我们得需要通过自己的算法重新计算出每个节点的x/y坐标以及宽高。

节点宽高计算

在拿到每个node节点的时候只有节点的文字信息并没有节点的宽高数据,所以我们需要第一次遍历预绘制文字节点获取到文字在画布中绘制出来后所占的宽和高。

预绘制获取宽高 定义一个svg画布,宽和高可以随意越小越好,并且定位到屏幕之外。 定义一个Text类,在该画布上绘制文字并且拿到文字的宽和高 class Text { constructor (text, fontSize, fontWeight) { this.text = text this.fontSize = fontSize this.fontWeight = fontWeight this.render() } render () { this.node = select('svg') .append('text') .attr('font-size', this.fontSize) .attr('font-weight', this.fontWeight) .text(this.text) } bbox () { const clientRect = this.node.node().getBoundingClientRect() this.node.remove() return clientRect } }

此时可以获取到该节点的文字宽度和高度,若需要获取整个节点的宽高则加上上下和左右边距即可。

节点定位 获取节点的x坐标

首先根节点我们把它定位到画布中间的位置,然后遍历子节点,那么子节点的left就是根节点的left +根节点的width+它们之间的间距 marginX,如下图所示:

image.png

然后再遍历每个子节点的子节点(其实就是递归遍历)以同样的方式进行计算left,这样一次遍历完成后所有节点的left值就计算好了,可以初始化根节点的x/y坐标

function firstWalk (nodes) { nodes.forEach(node => { node.x = node.parent.x + node.parent.width + marginX }) } 获取节点的y坐标

接下来是top,首先最开始也只有根节点的top是确定的,那么子节点怎么根据父节点的top进行定位呢?上面说过每个节点是相对于其所有子节点居中显示的,那么如果我们知道所有子节点的总高度,那么第一个子节点的top也就确定了:

firstChildNode.top = (node.top + node.height / 2) - childrenAreaHeight / 2

如图所示:

image.png

第一个子节点的top确定了,其他节点只要在前一个节点的top上累加即可。

如何计算节点的 childrenAreaHeight?

// 第一次遍历 function firstWalk (nodes) { nodes.forEach(node => { node.childrenAreaHeight = (node.children || []).reduce((prev, cur) => { return prev + cur.height }, 0) + (len - 1) * 16 } }) }

这一步可以和上面计算节点的X坐标放在一起,只要遍历一遍就可以了。

接下来开启第二轮遍历,这轮遍历可以计算所有节点的top。

// 第二次遍历 function secondWalk (nodes) { nodes.forEach(node => { if (hasChild(node)) { const y = node.y + node.height / 2 - node.childrenAreaHeight / 2 let startY = y node.children.forEach(n => { n.y = startY startY += n.height + marginY }) } }) }

事情到这里并没有结束,请看下图:

image.png

可以看到对于每个节点来说,位置都是正确的,但是,整体来看就不对了,因为发生了重叠,原因很简单,因为【二级节点1】的子节点太多了,子节点占的总高度已经超出了该节点自身的高,因为【二级节点】的定位是依据【二级节点】的总高度来计算的,并没有考虑到其子节点,解决方法也很简单,再来一轮遍历,当发现某个节点的子节点所占总高度大于其自身的高度时,就让该节点前后的节点都往外挪一挪,比如上图,假设子节点所占的高度比节点自身的高度多出了100px,那我们就让【二级节点2】向下移动50px,如果它上面还有节点的话也让它向上移动50px,需要注意的是,这个调整的过程需要一直往父节点上冒泡,比如:

image.png

【子节点1-2】的子元素总高度明显大于其自身,所以【子节点1-1】需要往上移动,这样显然还不够,假设上面还有【二级节点0】的子节点,那么它们可能也要发生重叠了,而且下方的【子节点2-1-1】和【子节点1-2-3】显然挨的太近了,所以【子节点1-1】自己的兄弟节点调整完后,父节点【二级节点1】的兄弟节点也需要同样进行调整,上面的往上移,下面的往下移,一直到根节点为止:

// 第三次遍历 function thirdWalk (nodes) { nodes.forEach(node => { const difference = node.childrenAreaHeight - node.height if (difference > 0) { updateBrothers(node, difference / 2) } }) }

updateBrothers用来向上递归移动兄弟节点:

function updateBrothers (node, addHeight) { if (node.parent) { const childrenList = node.parent.children // 找到自己处于第几个节点 const index = childrenList.findIndex(item => item === node) childrenList.forEach((item, _index) => { if (item === node) return let _offset = 0 if (_index < index) { // 上面的节点往上移 _offset = -addHeight } else if (_index > index) { // 下面的节点往下移 _offset = addHeight } // 移动节点 item.y += _offset // 节点自身移动了,还需要同步移动其所有下级节点 if (hasChild(item)) { updateChildren(item.children, 'y', _offset) } }) updateBrothers(node.parent, addHeight) } }

更新所有子节点的坐标:

// 更新节点的所有子节点的位置 function updateChildren (children, prop, offset) { children.forEach((item) => { item[prop] += offset if (hasChild(item)) { updateChildren(item.children, prop, offset) } }) }

到此【逻辑结构图】的整个布局计算就完成了,当然,有一个小小小的问题:

image.png

就是严格来说,某个节点可能不再相对于其所有子节点居中了,而是相对于所有子孙节点居中。

节点连线

节点定位好了,接下来就要进行连线,把节点和其所有子节点连接起来,连线风格有很多,可以使用直线,也可以使用曲线,直线的话很简单,因为所有节点的left、top、width、height都已经知道了,所以连接线的转折点坐标都可以轻松计算出来:

image.png

也可以通过曲线连接(二次贝塞尔曲线、三次贝塞尔曲线)

image.png

这种简单的曲线可以使用二次贝塞尔曲线,起点坐标为根节点的中间点:

let x1 = root.left + root.width / 2 let y1 = root.top + root.height / 2

终点坐标为各个子节点的左侧中间:

let x2 = node.left let y2 = node.top + node.height / 2

那么只要确定一个控制点即可,具体这个点可以自己调节,找一个看的顺眼的位置即可,最终选择在两个点的中点选择作为控制点。

let cx = x1 + (x2 - x1) * 0.5 let cy = y1 + (y2 - y1) * 0.5

接下来给加个渲染连线的方法即可:

export function renderNewEdges (links) { const enter = edgeContainer .selectAll('g') .data(links) .enter() .append('g') enter .append('path') .attr('d', d => { const sx = d.source.width + d.source.x const sy = d.source.y + d.source.height const tx = d.target.x const ty = d.target.y + d.target.height / 2 const cx = sx + (tx - sx) * 0.5 const cy = sy + (ty - sy) * 0.5 return `M${sx} ${sy} Q${cx} ${cy} ${tx} ${ty}` }) .attr('stroke-linecap', 'round') .attr('stroke', d => d.source.style.lineStyle.fill) .attr('stroke-width', 2) .attr('fill', 'none') } 画布拖动和缩放

首先看一下基本结构:

image.png image.png

整个区域是一个svg画布,有效图形部分全都通过一个 g 标签包裹,我们只要对这个g标签进行缩放平移就可以完成画布的缩放。

#d3.zoom()

创建一个新的缩放行为,并返回该行为。zoom 既是一个对象又是一个函数,通过情况下通过 selection.call 来应用到元素上。

import { zoom as d3Zoom } from 'd3-zoom' function structureZoom () { const zoom = d3Zoom() // 画布缩放范围 .scaleExtent([0.1, 10]) // 事件过滤器,在按下ctrl键的时候通过鼠标左键和滚轮分别进行平移和缩放 .filter(event => event.ctrlKey && event.buttons -event.deltaY * (event.deltaMode ? 120 : 1) / 500) .on('start', function (event) { console.log(event, 'start') }) .on('zoom', function (event) { // mindContainer为整个容器g标签 mindContainer.attr('transform', () => { // 主要缩放功能 eventTransform = event.transform return eventTransform }) }) .on('end', function () { console.log(event, 'end') }) return zoom } function transformXmindGraph () { const zoom = structureZoom() // 选择svg画布通过.call调用 zoom svg = select('#zx-xmind-map-svg') .attr('xmlns', 'http://www.w3.org/2000/svg') .attr('xmlns:xlink', 'http://www.w3.org/1999/xlink') .call(zoom) // 禁用鼠标双击方法画布操作 .on('dblclick.zoom', null) }

3.gif

画布缩略图 思路

等比缩放再次绘制(如果不考虑类名影响,可以直接通过selection.clone克隆整个画布,然后对其进行缩放),下方有效视图的绿色区域始终保持相对画布居中,蓝色区域可以平移和缩放。

image.png

红色区域等比缩放整个画布

蓝色区域等比缩放可视窗口

绿色区域等比缩放有效视图

蓝色边框位置计算

selection.getBoundingClientRect

企业微信截图_16819785318847.png

保持x1 / x , y1 / y 的值(ratio)和画布缩放的比例相同即可。

通过x3 - x2获取到x4的值,再由缩放比例 x * ratio 可以得到x1,在通过x4 - x1就可以计算Left值,同理可以得到Top值。

3.gif



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3