边写边学系列(三) —— 使用 SVG.js 绘制产品自定义图表

2,925 阅读12分钟

系列目录

【一】:使用 apidoc,搞定自动化文档

【二】:使用 Express-Validator进行后端校验

【三】:使用 SVG.js 绘制产品自定义图表

前言

最近接到了一个小需求,需要画一个数据漏斗图,原本也没什么,因为本来项目里就有 chart 相关需求,无非就是用现成的漏斗图改巴改巴就完事了。

产品的PRD大概长这个样子:

我想像的并且 chart 官网给的示例是这样的:

UI设计出来的是这个样子的:

文本内容涉及到内部项目,我就马赛克了,虽然放出来也没啥问题😄

经过一番激烈的讨论,观点如下:

我:人家有现成的漏斗图,我直接拿来用十分钟就搞定了,节约项目时间
UI:我这设计属于原创,呕心沥血设计出来的,最好按照这个实现
产品:我觉得UI设计的挺好看,咱们不能和其他家一样
我:可以😊(心中一万匹不知名动物奔腾,一个B端项目要什么原创)

好了,结论出来了,按照UI来吧,那么就开始想方案:

第一种 - 纯Dom实现:看起来结构并不是很复杂,如果纯用dom来实现我觉得是可能的,层叠方案设计好一层一层放置元素应该是可以实现的。

第二种 - Canvas绘制:,虽说看起来很简单,但是里面涉及到了很多多边形和箭头线段,如果使用 Dom + CSS 可能能实现,但是一定是大费周章,所以可能 Canvas 相比来说更容易一些。

第三种 - SVG绘制:SVG和Canvas一样,都是进行绘制,只不过方式不同而已。而这里为什么选择SVG。一方面,因为SVG不依赖于像素,放大缩小不会失真,更适合处理图表类;另一方面,Canvas 多多少少使用过,而 SVG 还没真正使用过(基于SVG的ICON就不算了)。既然 UI 选择了原创,那么我也觉得这个小需求也值得我原创成长一下。因此选择了SVG。

SVG Canvas
优点 矢量图,不依赖于像素,放大缩小不会失真。以 Dom 的形式表示,事件绑定由浏览器直接分发到节点上。 定制型更强,可以绘制绘制任何自己想要的东西。非 Dom 结构形式,用 JavaScript 进行绘制,涉及到动画性能较高。
缺点 Dom 形式,涉及到大量更新 Dom 以及动画的时候,性能较低。 事件分发由canvas处理,绘制的内容的事件需要自己做处理。依赖于像素,无法高效保真,画布较大时候性能较低。

本文记录了一天时间内,SVG学习实践系列,仅供大家参考,最后我实现出来的效果是这样的:

看起来基本一致哈~😄

SVG 基础

这里我就介绍几个特别常用的吧,简单介绍一下使用方法,因为官方文档写的十分详细,各个API的用法。唯一的缺点就是是英文的,所以在这里我就简单的把几个常用的介绍一下,其他的大家业务场景使用到了再去翻API就行了。

svg.js API地址

yarn add svg.js

初始化

import React, { Component } from 'react';
import SVG from 'svg.js';

class API extends Component {
  componentDidMount() {
    // 初始化,获取svg document
    const draw = SVG('api_container').size('100%', '400px');
    ....
  }
  render() {
    return (
      <div id='api_container' />
    )
  }
}

export default API;

获取到 SVG Document 之后,我们就可以使用它进行绘制,获取它需要的是元素 id,所以一般我们在componentDidMount这个生命周期进行初始化获取。

画直线 —— line(x1, y1, x2, y2)

首先,我们来使用最简单的 API 来画一条直线,两点确定一条直线,依次输入两点的(x,y)坐标即可。

const line = draw.line(0, 100, 100, 0);
line.stroke({ color: '#f06', width: 10 });

效果如上图所示,并且,所有的 API 均为链式调用(如果你熟悉jQuery),那么一定不会陌生,所以上面代码也可以直接写成。

draw.line(0, 100, 100, 0)
    .stroke({ color: '#f06', width: 10 });

起始位置 —— move(x, y)

上面直线,很容易就画出来了,也就是从(0, 100) -> (100, 0),不过呢,我画的时候不一定非要在起始位置画吧,有可能我就想在画布中间,那怎么办?没错,有设置起始位置的API —— move

// 从(100, 100)开始画
draw.line(0, 100, 100, 0)
    .move(100, 100)
    .stroke({ color: '#f06', width: 10 });

如图,可以看到,起始位置也可以设置,并且代码的位置因为是链式,所以放在哪里都是可以的。

画矩形 —— rect(width, heigth)

画矩形就更简单了,只需要传入你想要画的长和宽就行了,当然你也可以从任何地方开始画。

// 比如,我也想从(100, 100)画
draw.rect(140, 140).move(100, 100);

画出来是黑色,也就是默认填充是黑色,我希望变成蓝色,那么可以使用fill('blue')

一般来说,与颜色相关的 API 有两种,fillstroke,fill一般是填充色,stroke一般是描边色。

注意画的顺序

不知道注意到没有,上面我们的矩形把直线覆盖住了,嗯没错,如果位置重叠,它的顺序是后画的会覆盖在先画的上面,这与 CSS 层叠样式规则很像。我们将二者顺序调换一下再看一下。

componentDidMount() {
    const draw = SVG('api_container').size('100%', '400px');
    // 画矩形
    draw.rect(140, 140).move(100, 100).fill('blue');
    // 画直线
    const line = draw.line(0, 100, 100, 0).move(100, 100);
    line.stroke({ color: '#f06', width: 10 });
}

这里想说明的就是,当你需要在绘制的图形内部画文字的时候,绘制的先后顺序一定不能乱,一定是先画图形,再画文本。

画圆形 —— circle(r)

画圆就更简单了,只需要给出半径就行。

// 从(200, 200)开始画一个半径是100的红边橘色圆
draw.circle(100)
    .move(200, 200)
    .fill('orange')
    .stroke({ color: 'red', width: 4 });

画多边形 —— polygon(pointStr|pointArr)

绘制多边形也很简单,只不过画的过程需要开发者设计好,参数接受两种类型第一种是用逗号隔开的坐标,坐标x y用空格间隔,第二种是点二维数组。

// 字符串参数绘制三角形 
draw.polygon('300 300, 360 240, 360 360');
// 点坐标绘制矩形
draw.polygon([[400, 400], [440, 400], [500, 300], [400, 300]])
  .fill('green');

画多边线段 —— polyline(pointStr|pointArr)

绘制多边曲线与上面多边形相似,只不过就是用线段展示,可以理解为中空。

// 绘制多边曲线
draw.polyline('0,0 100,50 50,100').fill('none').stroke({ width: 1 })

画图片 —— image(url|path)

draw.image('https://cdn.img42.com/4b6f5e63ac50c95fe147052d8a4db676.jpeg')
    .size(60, 60) // 设置绘制的长宽
    .move(500, 100);

画文本 —— text(str)

绘制文本就是,直接绘制的文字放进来就行

draw.text('我是被绘制的文本')
    .move(600, 200)
    .stroke({ color: 'yellow' })
    .font({ size: 20 });

添加事件 —— on

到现在为止,基本简单的都介绍完了,绘制一些基本需求没啥问题了,剩下的就是,我们绘制上去的是 dom,既然是 dom 那么肯定就有事件。svg.js支持绑定各种 dom 事件,写法也是多种多样,这里我们就介绍最通用的on

// 为图片绑定click事件
const draw_image = draw.image('https://cdn.img42.com/4b6f5e63ac50c95fe147052d8a4db676.jpeg')
  .size(60, 60) // 设置绘制的长宽
  .move(500, 100);
draw_image.on('click', function(){
  alert(this.node.getAttribute('href'));
});

其他更多 API 及其使用方法,建议大家去啃官方文档。

绘制图表

上面基本把我这里该用到的API都介绍完了,下面开始言归正传,正式进行产品需求自定义图表的开发工作。下面将从思路到实现逐步讲解。

第一步:设计布局

大概就是上面的设计,分为左中右,上中下三等分。左侧部分,绘制文字;中间部分,绘制图表以及对应的文字;右侧部分,绘制背景板以及箭头文字部分。

第二步:绘制左侧文字区域

function drawStepText(draw) {
    const stepTextGroup = draw.group().move(0, 0);
    const text_step1_label = draw.text('第一步').move(0, 43);
    stepTextGroup.add(text_step1_label);
    const text_step1_content = draw.text('AAAA').move(60, 43).font({ fill: '#5F6369' });
    stepTextGroup.add(text_step1_content);
    const text_step2_label = draw.text('第二步').move(0, 143);
    stepTextGroup.add(text_step2_label);
    const text_step2_content = draw.text('BBBB').move(60, 143).font({ fill: '#5F6369' });
    stepTextGroup.add(text_step2_content);
    const text_step3_label = draw.text('第三步').move(0, 243);
    stepTextGroup.add(text_step3_label);
    const text_step3_content = draw.text('CCCC').move(60, 243).font({ fill: '#5F6369' });
    stepTextGroup.add(text_step3_content);
}
componentDidMount() {
    const draw = SVG('statistics_draw').size('100%', '100%');
    drawStepText(draw);
}
render() {
    return (
      <div id='statistics_draw' className='chart-container'></div>
    )
}

文字内容绘制完成了,这里使用到了draw.group(),其实也没什么,就是对所画内容分一下组,既然是分为三部分,所以这边也就分为三组,方便划分。

第三步:绘制漏斗多边形区域

绘制多变形漏斗区域,其实也挺简单的,只不过复杂之处在于要计算好位置,位置的计算比较费心。代码部分与下方 hover 提示一起讲解。

第四步:设计 hover 提示区域

上面按照计算,漏斗图以及对应的文字已经绘制完成,接下来,最复杂的地方就在这里了。每一个文字前面都有一个 hvoer 提示,鼠标悬浮上去会有个小弹窗。这里的思路很明显要加上on('mouseover', funciton())事件。不过弹窗要怎么展示就是难点了。总不能每个 hover 都事先画出来然后 hover 的时候再显示吧。虽然这样也行,但是一是我觉得复杂,二是如何绘制隐藏并且还有复杂定位的弹窗。

所以,最后我的实现思路:

  • fixed定位全局唯一一个 hover 弹窗,里面内容为空
<div id='statistics_draw' className='chart-container'>
    <div id='hover_container' className='tooltip-container' />
</div>
  • 鼠标移入对应元素的时候,手动计算 hover 弹窗应该出现的位置
const hoverDom = document.getElementById('hover_container');
const { left, top, width } = dom.getBoundingClientRect();
hoverDom.style.left = `${left + width / 2}px`;
hoverDom.style.top = `${top - 6}px`;
hoverDom.innerHTML = `${TYPE_TEXT[type]}`;
const arrowDom = document.createElement('div');
arrowDom.classList = `tooltip-arrow`;
hoverDom.appendChild(arrowDom);
hoverDom.style.display = 'block';

这里用到了一个很重要的 DOM API —— getBoundingClientRect(),用于获取 dom 的绝对位置坐标以及长宽各种参数。最后实现的效果:

  • 鼠标移入显示,鼠标移出小时消失
image_referral_per.on('mouseover', function() {
    const dom = this.node;
    showHoverDom(dom, 'referral_per');
});
image_referral_per.on('mouseleave', function() {
    document.getElementById('hover_container').style.display = 'none';
});

绘制矩形区域代码:

function drawPolygonArea(draw, data) {
  /* 第一个多边形及文字 */
  const polygonGroup = draw.group().move(140, 0);
  const polygon_transform = draw.polygon('0 290, 120 290, 180 210,0 210').fill('#ACD3FA');
  polygonGroup.add(polygon_transform);
  const text_transform1 = draw.text(`付费线索数: ${data.paidNumberTotal}`)
    .move(30, 260).fill(color_white).font({ size: 12 });
  polygonGroup.add(text_transform1);
  const image_transform1 = draw.image('/static/imgs/question-circle.png', 14, 14).move(10, 256);
  image_transform1.on('mouseover', function() {
    const dom = this.node;
    showHoverDom(dom, 'transform1');
  });
  image_transform1.on('mouseleave', function() {
    document.getElementById('hover_container').style.display = 'none';
  });
  polygonGroup.add(image_transform1);
  /* 第二个多边形及文字 */
  ...
  /* 第三个多边形及文字 */
  ...
}

第五步:绘右侧阴影区域

从上面的 UI 图,我们可以看出来,右侧除了箭头文字,还有一个渐变的背景色。因此,我们在绘制文字之前,需要先绘制三块渐变背景色。

这里还要多介绍一个渐变色 API —— gradient, 嗯,没错,就是处理相应的 CSS 渐变色操作的。

// 绘制右侧背景色
function drawShadowArea(draw) {
  const { width } = document.getElementById('statistics_draw').getBoundingClientRect();
  // 因为是自动填满右边界,需要手动计算右边界位置
  const shadow_right = width - 260;
  const shadowGroup = draw.group().move(260, 0);
  const gradient = draw.gradient('linear', function(stop) {
    stop.at(0, 'rgba(255,255,255,0)');
    stop.at(1, 'rgba(247,247,247,1)');
  });
  const shadow_area1 = draw.polygon(`0 290, ${shadow_right} 290, ${shadow_right} 210, 60 210`).fill(gradient);
  shadowGroup.add(shadow_area1);
  const shadow_area2 = draw.polygon(`70 190, ${shadow_right} 190, ${shadow_right} 110, 130 110`).fill(gradient);
  shadowGroup.add(shadow_area2);
  const shadow_area3 = draw.polygon(`140 90, ${shadow_right} 90, ${shadow_right} 10, 200 10`).fill(gradient);
  shadowGroup.add(shadow_area3);
}

可以看到,背景色块出来了,这里使用的依然是多边形绘制,因为要与左侧多边互补才行。

第六步:绘制右侧箭头文字区域

最后只剩下右侧的箭头以及文字了,思路如下:

  • 折线采用polyline进行绘制
  • 折线结尾的箭头采用polygon绘制三角形
  • 文字以及 hover 复用上面第二部分内容即可

剩下的就是复杂的定位计算了。

function drawArrowLine(draw, data) {
  /* 第一条线 + 文字 */
  const arrowTextGroup = draw.group().move(260, 0);
  const polyline_paid_rate = draw.polyline([[40, 250], [340, 250], [340, 270], [30, 270]])
    .fill('none').stroke({ color: '#e2e2e4', width: 1 });
  const polygon_paid_rate = draw.polygon([[22, 270], [30, 265], [30, 275]]).fill('#e2e2e4');
  const image_paid_transform = draw.image('/static/imgs/question-circle-black.png', 12, 12).move(350, 254);
  image_paid_transform.on('mouseover', function() {
    const dom = this.node;
    showHoverDom(dom, 'paid_transform');
  });
  image_paid_transform.on('mouseleave', function() {
    document.getElementById('hover_container').style.display = 'none';
  });
  const text_paid_transform = draw.text(`付费转化率:${(data.paidConversionRateTotal * 100).toFixed(2)}%`)
    .move(364, 256).fill('#5F6369').font({ size: 12 });
  arrowTextGroup.add(polyline_paid_rate)
    .add(polygon_paid_rate)
    .add(image_paid_transform)
    .add(text_paid_transform);
  ...
}

最后效果,上面也看到了:

总结

本文从实际需求触发,从零开始学习SVG基础并实践使用开发,期间所历时间较短,可能理解不是很深,各位看官不喜勿喷。

代码地址

疑问:如何在元素内部绘制 text?

在多边形区域内绘制文字的时候,我在想一件事,正常来说,如果多边形区域作为父元素,在内部绘制文本节点 text,这样的画岂不是会简单很多?那么出来的svg元素应该就是下面这段代码的样子:

<svg>
    <rect> // 矩形
        <text></text> // 矩形内的文本
    </rect>
    <text>矩形外的文本</text>
</svg>

而我在使用svg.js以及阅读文档,并没有发现这种使用方式。都是通过绘制的先后顺序来确定文本位置的,也就是下面这样。

<svg>
    <rect></rect> // 矩形
    <text></text> // 矩形内的文本,通过x y 确定位置
    <text></text> // 矩形外的文本,通过x y确定位置
</svg>

当然,因为只是简单看看就直接上手了,所以可能是我不知道如何使用,如果大家有用过并且知道的,可以留言给个提示~万分感谢。