SVG繪製環圖

上篇<原生Canvas繪製餅圖>介紹瞭如何使用Canvas來繪製環圖,這篇用SVG標籤來實現一下。
SVG環圖效果html

上面是完整效果圖,下面來看看具體實現。git

使用的SVG元素

  • <svg>:SVG代碼的開始標籤,至關於建立一個畫布。在svg標籤裏,插入其它SVG元素,來進行繪圖;
  • <path>: path元素用於定義一個路徑,使用path標籤來繪製每項數據的環圖份額;
  • <polyline>:polyline元素用於定義一條曲線,使用polyline繪製每項數據的線條;
  • <text>: text元素用於定義文本,使用text顯示數據的文本信息。

以上是會用到的幾個SVG標籤,詳細說明能夠看看菜鳥教程SVG或者MDN的SVG教程算法

<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>繪製每項數據的環圖份額

<path>元素的屬性d用於定義路徑,屬性d其實是一個字符串,包含了一系列路徑描述。這些路徑由下面這些指令組成:Moveto,Lineto,Curveto,Arcto,ClosePath。
咱們會用到的指令有:segmentfault

  • Moveto(移動畫筆到起始點),語法:'M x,y' 在這裏x和y是絕對座標,分別表明水平座標和垂直座標;
  • Lineto(繪製直線),語法:'L x, y' 在這裏x和y是絕對座標,表示直線的結束點座標;
  • Arcto(繪製弧曲線路徑),語法:'A rx,ry xAxisRotate LargeArcFlag,SweepFlag x,y',rx和ry分別是x和y方向的半徑(繪製圓弧時,rx和ry相等);LargeArcFlag的值肯定是要畫小弧或大弧,0表示畫小弧(即畫兩點之間弧長最小的弧),1表示畫大弧(當弧度大於Math.PI時須要畫大弧);SweepFlag用來肯定畫弧的方向,0逆時針方向,1順時針方向;x和y是目的地的座標;
  • ClosePath(閉合路徑),語法是'Z'或'z';

詳情MDN path元素d屬性
咱們須要用path繪製以下的路徑:
圖1
如圖:份額的繪製是先使用M命令移動到P0,L命令繪製一條直線到P1,A命令從P1畫弧到P2,L命令從P2繪製一條直線到P3,A命令從P3繪製一條弧線到P0,最後Z命令關閉路徑。而後咱們只要填充這個路徑就能夠完成每項份額繪製了。這裏咱們須要求出4個點的絕對座標,如何計算這四個座標?
圖2
如圖,經過三角函數,咱們就能夠計算出每一個點的位置。咱們已知原點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); //添加到畫布中
}

到這裏,就已經繪製出以下環圖了:
圖3
記得還須要把相關變量先聲明一下。svg

<polyline>、<text>繪製線條、文字

下面咱們須要繪製線條和文字。 函數

<polyline>繪製線條須要的數據

  • 起點座標:這裏設置起點爲每項數據的弧線中間位置,經過計算中間位置對應的弧度,就能夠經過三角函數計算出這個點座標;
  • 線束點座標:當線條起點在右側時,線束點就是垂直平行起點圖表最右側位置;當線條起點在左側時,線束點就是垂直平行起點圖表最右左位置;假設起點爲[sx,sy],右左結束點應該就是[w,sy]、[0,sy],w爲圖表寬度;
  • 折點:須要處理數據會擠在一塊兒的狀況,就會改變結束點座標的y值,當起點和結束點y值不相等時,就須要設置折點。

<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

總結一下

  • 難點:難的地方就在弧上各點的計算,須要好好再回憶一下數學的三角函數。
  • 對比canvas:canvas只需有一個標籤,svg實現就在DOM中增長了一堆標籤。這樣一來,svg的優點就在於第項都是一個標籤,你能夠直接針對這個標籤要綁定事件和作修改。好比要實現鼠標稱到某個項,放大這個項,svg只要給每一個path綁定事件,修改當前的這個path就行;而canvas只能在canvase綁定事件,先經過計算鼠標位置來判斷移動到了哪一個份額上,而後再重繪整個canvas;一樣,標籤過多也是svg的缺點,咱們這點標籤其實沒什麼,一旦標籤多起來,確定是會給瀏覽器渲染帶來負擔的。
相關文章
相關標籤/搜索