上篇<原生Canvas绘制饼图>介绍了如何使用Canvas来绘制环图,这篇用SVG标签来实现一下。 html
上面是完整效果图,下面来看看具体实现。git
以上是会用到的几个SVG标签,详细说明能够看看菜鸟教程SVG或者MDN的SVG教程算法
SVG是一种用来描述二维矢量图形的XML标记语言,因此SVG标签不能使用document.createElement直接建立(浏览器没法识别)。须要使用document.createElementNS建立一个具备指定的命名空间URI和限定名称的元素,SVG的命名空间是'http://www.w3.org/2000/svg'。这里将建立SVG标签操做写在了createSvgTag函数里。下面先新建一个svg元素:canvas
/** * 将建立环图的全部操做写在drawPie函数内,配置一些默认参数 * @param {Element} element [插入SVG的元素,缺省直接插入到body] * @param {Number} R [外弧起终点计算半径] * @param {Number} r [内弧起终点计算半径] * @param {Number} width [画布宽度] * @param {Number} height [画布高度] * @param {Array} data [图表数据] */ function drawPie({element, R = 140, r = 100,width = 450,height = 400,data = []} = {}) { let w = width; let h = height; //将width、height赋值给w、h let origin = [w / 2, h / 2]; //以画布的中心点,做为环图的原点 //建立一个svgs标签 let svg = createSvgTag('svg', { 'width': w + 'px', 'height': h + 'px', 'viewBox': `0 0 ${w} ${h}`, }); (element && element.appendChild) ? element.appendChild(svg) : document.body.appendChild(svg);//插入到DOM /** * 将建立SVG标签写成一个函数 * @param {tring} tagName [标签名] * @param {Object} attributes [标签属性] * @return {Element} svg标签 */ function createSvgTag (tagName, attributes) { let tag = document.createElementNS('http://www.w3.org/2000/svg', tagName) for (let attr in attributes) { tag.setAttribute(attr, attributes[attr]) } return tag; } }) //调用 drawPie({ data: [{ cost: 4.94, category: '通信', color: "#e95e45", }, { cost: 4.78, category: '服装美容', color: "#20b6ab", }, { cost: 4.00, category: '交通出行', color: "#ef7340", }, { cost: 3.00, category: '饮食', color: "#eeb328", }, { cost: 49.40, category: '其余', color: "#f79954", }, { cost: 28.77, category: '生活日用', color: "#00a294", }] })
<path>元素的属性d用于定义路径,属性d其实是一个字符串,包含了一系列路径描述。这些路径由下面这些指令组成:Moveto,Lineto,Curveto,Arcto,ClosePath。
咱们会用到的指令有:segmentfault
详情MDN path元素d属性。
咱们须要用path绘制以下的路径:
如图:份额的绘制是先使用M命令移动到P0,L命令绘制一条直线到P1,A命令从P1画弧到P2,L命令从P2绘制一条直线到P3,A命令从P3绘制一条弧线到P0,最后Z命令关闭路径。而后咱们只要填充这个路径就能够完成每项份额绘制了。这里咱们须要求出4个点的绝对坐标,如何计算这四个坐标?
如图,经过三角函数,咱们就能够计算出每一个点的位置。咱们已知原点O坐标(画布中点)、外环半径R和内环半径r(咱们本身给定);再经过计算出每项数据的弧度占比,咱们就能够获得每项数据的起始弧度(即上一项的结束弧度,第一项为0)和结束弧度(起点+项数据的弧度占比)。x值为原点x+sin(angel)半径,y值为原点y-cos(angel)半径
这里将计算点坐标的运算写在evaluateXY函数中,以下:数组
/** * 计算Xy坐标 * @param {[type]} r [半径] * @param {[type]} angel [角度] * @param {[type]} origin [原点坐标] * @return {[Array]} 坐标 */ function evaluateXY (r, angel, origin) { return [origin[0] + Math.sin(angel) * r, origin[0] - Math.cos(angel) * r] }
接下来,咱们遍历数据,计算出每项数据的四个点:浏览器
//遍历计算每项数据 for(let v of data) { let itemData = Object.assign({}, v);//copy一遍,不直接修改原数据 eAngel = sAngel + v.cost / total * 2 * Math.PI; //计算结束弧度 itemData.arclineStarts = [ evaluateXY(r, sAngel, origin), //计算P0坐标 evaluateXY(R, sAngel, origin), //计算P1坐标 evaluateXY(R, eAngel, origin), //计算P2坐标 evaluateXY(r, eAngel, origin) //计算P3坐标 ]; itemData.LargeArcFlag = (eAngel - sAngel) > Math.PI ? '1' : '0';//大于Math.PI须要画大弧,不然画小弧 drawData.push(itemData);//保存到drawData数组中,绘制时遍历 sAngel = eAngel;//将下一项数据的起始弧度设置为当前项的结束弧度 }
下面,遍历drawData,绘制环图:app
//遍历计算每项数据,进行绘制 for(let v of drawData) { let P = v.arclineStarts; let path = createSvgTag('path', { 'fill': v.color, //设置填充色 'stroke': 'black', 'stroke-width': '0', //画笔大小为零 /** * d属性设置路径字符串 * M ${P[0][0]} ${P[0][1]} 移动画笔到P0点 * L ${P[1][0]} ${P[1][1]} 绘制一条直线到P1 * A ${R} ${R} 0 ${v.LargeArcFlag} 1 ${P[2][0]} ${P[2][1]} 绘制外环弧到P2,R为外半径,v.LargeArcFlag画大弧仍是小弧 * L ${P[3][0]} ${P[3][1]} 绘制一条直线到P3 * A ${r} ${r} 0 ${v.LargeArcFlag} 0 ${P[0][0]} ${P[0][1]} 绘制内环弧到P0点 * z 关闭路径 */ 'd': `M ${P[0][0]} ${P[0][1]} L ${P[1][0]} ${P[1][1]} A ${R} ${R} 0 ${v.LargeArcFlag} 1 ${P[2][0]} ${P[2][1]} L ${P[3][0]} ${P[3][1]} A ${r} ${r} 0 ${v.LargeArcFlag} 0 ${P[0][0]} ${P[0][1]} z` }) svg.appendChild(path); //添加到画布中 }
到这里,就已经绘制出以下环图了:
记得还须要把相关变量先声明一下。svg
下面咱们须要绘制线条和文字。 函数
<polyline>绘制线条须要的数据
<text>绘制文字:调整事后的线束点,就是文字的位置。
如下是完整代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>svg-pie</title> </head> <body> <div id="svgWrap" class="svg-wrap"></div> <script> /** * 将建立环图的全部操做写在drawPie函数内,配置一些默认参数 * @param {Element} element [插入SVG的元素,缺省直接插入到body] * @param {Number} R [外弧起终点计算半径] * @param {Number} r [内弧起终点计算半径] * @param {Number} width [画布宽度] * @param {Number} height [画布高度] * @param {Array} data [图表数据] */ function drawPie({element, R = 140, r = 100,width = 450,height = 400,data = []} = {}) { let w = width; let h = height; //将width、height赋值给w、h let origin = [w / 2, h / 2]; //原点坐标 let drawData = [];//保存遍历后可直接绘制的数据 let sAngel = 0;//保存每项数据的起始点角度 let eAngel = sAngel;//保存每项数据的结束角点度 let cAngel ;//保存每项数据的中点角度 let leftPoints = []; //保存在左边的点 let rightPoints= []; //保存在右边的点,分出左右是为了计算两点垂直间距是否靠太近 let tAngel = Math.PI * 2; let minPadding = 40; //设置数据项两点最小间距 //total保存总花费,用于计算数据项占比 let total = data.reduce(function(v, item) { return v + item.cost; }, 0) //建立svg标签,设置画布 let svg = createSvgTag('svg', { 'width': w + 'px', 'height': h + 'px', 'viewBox': `0 0 ${w} ${h}`, }); //遍历计算每项数据,生成绘制数据 for(let v of data) { let itemData = Object.assign({}, v);//copy一遍,不直接修改原数据 let isLeft = false; eAngel = sAngel + v.cost / total * tAngel;//计算结束弧度 itemData.arclineStarts = [ evaluateXY(r, sAngel, origin), //计算P0坐标 evaluateXY(R, sAngel, origin), //计算P1坐标 evaluateXY(R, eAngel, origin), //计算P2坐标 evaluateXY(r, eAngel, origin) //计算P3坐标 ]; //大于Math.PI须要画大弧,不然画小弧 itemData.LargeArcFlag = (eAngel - sAngel) > Math.PI ? '1' : '0'; //计算线条起始点公位置 itemData.lineStart = evaluateXY(R, sAngel + (eAngel - sAngel)/2, origin); //线条起点x值小于原点x值,在左侧,不然在右侧 itemData.isLeft = isLeft = itemData.lineStart[0] < origin[0]; //根据线条起点左右,设置结束点 itemData.lineEnd = [(isLeft ? 0 : w), itemData.lineStart[1]]; //线条起点y值小于原点y值,在上部,不然在下部,用于确实过挤往上/下移动 itemData.top = itemData.lineStart[1] < origin[1]; //根据线条起点左右,添加到leftPoints/rightPoints,用于处理过挤 isLeft? leftPoints.push(itemData) : rightPoints.push(itemData); drawData.push(itemData)//保存到drawData数据中,绘制时遍历 sAngel = eAngel;//将下一项数据的起始弧度设置为当前项的结束弧度 } makeUseable(rightPoints); //处理右边挤在一块儿的状况 makeUseable(leftPoints.reverse(), true); //处理左边挤在一块儿的状况,为何要翻转一下,看makeUseable函数 //遍历drawData开始绘制 for(let v of drawData) { let P = v.arclineStarts;//将path路四个点变量,赋值给变量p //建立path标签(份额) let path = createSvgTag('path', { 'fill': v.color, //设置填充色 'stroke': 'black', 'stroke-width': '0', //画笔大小为零 /** * d属性设置路径字符串 * M ${P[0][0]} ${P[0][1]} 移动画笔到P0点 * L ${P[1][0]} ${P[1][1]} 绘制一条直线到P1 * A ${R} ${R} 0 ${v.LargeArcFlag} 1 ${P[2][0]} ${P[2][1]} 绘制外环弧到P2,R为外半径,v.LargeArcFlag画大弧仍是小弧 * L ${P[3][0]} ${P[3][1]} 绘制一条直线到P3 * A ${r} ${r} 0 ${v.LargeArcFlag} 0 ${P[0][0]} ${P[0][1]} 绘制内环弧到P0点 * z 关闭路径 */ 'd': `M ${P[0][0]} ${P[0][1]} L ${P[1][0]} ${P[1][1]} A ${R} ${R} 0 ${v.LargeArcFlag} 1 ${P[2][0]} ${P[2][1]} L ${P[3][0]} ${P[3][1]} A ${r} ${r} 0 ${v.LargeArcFlag} 0 ${P[0][0]} ${P[0][1]} z` }) //设置线条点 let linePoints = v.lineStart[0] + ' ' + v.lineStart[1]; //设置起点 //若是有折点,添加折点 v.turingPoints && (linePoints += ',' + v.turingPoints[0] + ' ' + v.turingPoints[1]); //设置结束点 linePoints += ',' + v.lineEnd[0] + ' ' + v.lineEnd[1]; //建立polyline标签(线条) let polyline = createSvgTag('polyline', { points: linePoints, style: `fill:none;stroke:${v.color};stroke-width:.5` }) //建立text标签,显示花费 let cost = createSvgTag("text", { 'x': v.lineEnd[0], 'y': v.lineEnd[1], 'dy': -2, style: `fill:${v.color};font-size:12px;text-anchor: ${v.isLeft? 'start':'end'};` }) cost.innerHTML = v.cost; //建立text标签,显示花费分类 let category = createSvgTag("text", { 'x': v.lineEnd[0], 'y': v.lineEnd[1], 'dy': 14, style: `fill:${v.color};font-size:12px;text-anchor: ${v.isLeft? 'start':'end'};` }) category.innerHTML = v.category; svg.appendChild(path); //path(份额)添加到画布中 svg.appendChild(polyline);//polyline(线条)添加到画布中 svg.appendChild(cost);//text(花费)添加到画布中 svg.appendChild(category);//text(花费分类)添加到画布中 } (element && element.appendChild) ? element.appendChild(svg) : document.body.appendChild(svg); //插入到DOM return svg; /** * 计算Xy坐标 * @param {[type]} r [半径] * @param {[type]} angel [角度] * @param {[type]} origin [原点坐标] * @return {[Array]} 坐标 */ function evaluateXY (r, angel, origin) { return [origin[0] + Math.sin(angel) * r, origin[0] - Math.cos(angel) * r] } /** * 将建立SVG标签写成一个函数 * @param {tring} tagName [标签名] * @param {Object} attributes [标签属性] * @return {Element} svg标签 */ function createSvgTag (tagName, attributes) { let tag = document.createElementNS('http://www.w3.org/2000/svg', tagName) for (let attr in attributes) { tag.setAttribute(attr, attributes[attr]) } return tag; } function isUseable(arr) { // 判断是否会有数据挤在一块儿(两点最小间距是否都大于等于minPadding) if (arr.length <= 1) return true; return arr.every(function(p, index, arr) { if (index === arr.length-1) { //由于是当前项和下一项比较,因此index === arr.length-1直接返回true return true; } else { /** * 判断当前数据项结束点:p.lineEnd[1] * 和下一数据项结束点垂直间距是否大于等于最小间距:minPadding * 只有数据线条结束点垂直间距大于等于最小间距,才会返回true */ return arr[index + 1].lineEnd[1] - p.lineEnd[1] >= minPadding; } }) } function makeUseable(arr, left) {// 处理挤在一块儿的状况 let diff, turingAngel, x, maths = Math.sin,diffH, l; /** * 这里的思路是 * 若是数据是非可用的(会挤在一块儿,isUseable判断) * 就一直循环移动数据,直至可用 * 可能会有更好的算法,我这鱼木脑壳只想到这种的 * 欢迎你们提供更好的思路或算法 */ while (!isUseable(arr)) { //每次循环处理一次,直至数据不会挤在一块儿 for (let i = 0, len = arr.length - 1; i < len; i++) { //遍历数组 diff = arr[i + 1].lineEnd[1] - arr[i].lineEnd[1]; //计算两点垂直间距 if (diff < minPadding) { //小于最小间距,表示会挤到一块儿 if (arr[i].top && arr[i + 1].top) { //是在上部的点,向上移动 /** * 判断当前的点是否还能够向上移动 * 上方第一个点最往上只能够移动到y值为0 * 以后依次最往上只能移动动y值为:i * minPadding * 因此下面判断应该是:arr[i].lineEnd[1] - (minPadding - diff) > i * minPadding */ /** * 上面左边leftPoints的点须要翻转一下的缘由是 * 左边leftPoints的点最上面的点是排在最后的 */ if (arr[i].lineEnd[1] - (minPadding - diff) > 0 && arr[i].lineEnd[1] > i * minPadding) { //当前点还能向上移动 //向上移动到不挤(知足最小间距) arr[i].lineEnd[1] = arr[i].lineEnd[1] - (minPadding - diff); } else { //当前点不向上移动到知足最小间距的位置 //先把当前点移动到可以移动的最上位置 arr[i].lineEnd[1] = i * minPadding; //再把下个点移动,使知足最小间距 arr[i + 1].lineEnd[1] = arr[i + 1].lineEnd[1] + (minPadding - diff); } } else { //是在下部的点,向下移动 /** * 判断当前点的下个点是否还能够向下移动 * 下方最后一个点最往下只能够移动到y值为h,即图表高度 * 以前的点依次最往下只能移动动y值为:h - (len - i - 1) * minPadding * 因此下面判断应该是:arr[i + 1].lineEnd[1] + (minPadding - diff) < h - (len - i - 1) * minPadding */ if (arr[i + 1].lineEnd[1] + (minPadding - diff) < h && arr[i + 1].lineEnd[1] < h - (len - i - 1) * minPadding) { //当前点的下个点还能向下移动 //当前点的下个点向下移动到不挤(知足最小间距) arr[i + 1].lineEnd[1] = arr[i + 1].lineEnd[1] + (minPadding - diff) } else { //当前点的下个点不能向下移动 //先把当前点的下个点向下移动可以移动的最下位置 arr[i + 1].lineEnd[1] = h - (len - i - 1) * minPadding; //再把当前点移动,使知足最小间距 arr[i].lineEnd[1] = arr[i].lineEnd[1] - (minPadding - diff); } } break; //每次移动完成直接退出循环,判断一次是否已经不挤 } } } /** * 遍历已经可用的数据 * 起点和结束点不在同一水平线上 * 须要设置折点 * 设置折点为线束点水平距离40像素的位置 */ for (let i = 0, len = arr.length; i < len; i++) { if (arr[i].lineStart[1] !== arr[i].lineEnd[1]) { arr[i].turingPoints = [arr[i].lineEnd[0] + (left ? 40 : -40) , arr[i].lineEnd[1]]; } } } } drawPie({ element: document.getElementById('svgWrap'), data: [{ cost: 4.94, category: '通信', color: "#e95e45", }, { cost: 4.78, category: '服装美容', color: "#20b6ab", }, { cost: 4.00, category: '交通出行', color: "#ef7340", }, { cost: 3.00, category: '饮食', color: "#eeb328", }, { cost: 49.40, category: '其余', color: "#f79954", }, { cost: 28.77, category: '生活日用', color: "#00a294", }] }) </script> </body> </html>
也能够查看gitee。