原生Canvas繪製餅圖,我是不是被騙程式碼了

shufujia發表於2019-02-16

  這回算是真明白了什麼叫"林子大了什麼鳥都有!"之前就有聽說面試騙程式碼的情況,但也僅僅只是聽說。這回是真親身遇到了。來來來,自帶小板凳,準備好瓜子。好好看看我被騙的經歷。順便也看看使用原生Canvas繪製餅圖,使用外掛(比如Echart)也就分分鐘的事情,但多瞭解一些原生的東西,總不會有錯的。
  正文開始.....

我是不是被騙程式碼了???

  還是前段時間面試時發生的事情。3月21號晚八點,此時心態已處於第三階段(詳情可檢視面試總結),突然收到一封郵件,如下:
  
郵件1
  
  巧了,3月22有兩場面試,還是兩家我覺得不錯的公司(南方+、愛範兒科技),我誤以為就是這兩家其中一家的測試。
  熬到22號兩點,餅圖倒是畫出來了,只是線條還有很大問題。當時的想法是通過計算位置,使用div來畫線條。這有兩個問題:一是無法實現拆線;二是會不準。因為白天還有面試,所以就直接發了半成品過去,並詢問是什麼公司。對話如下:
  
郵件2
  
  居然還沒約面試,只有想會是哪家公司呢?反正沒往騙程式碼上想!3月23,繼續嘗試了一下,線條也通過canvas來繪製,解決了之前的兩個問題,還處理考慮擠一起的需求,算得上已經實現需求。效果如下:
效果
  3月23晚上,傳送過去。3月25晚上,收到回覆確是這樣:

你好,舒同學。看了你的作品,能否再完善一下?因為這是仿支付寶的餅圖,所以希望是適配於移動裝置的,另外APP裡的Webview好像要在6.0以上才支援es6語法,想把它轉成es5語法的,麻煩舒同學了

  到這裡我才開始覺得不對勁。 為啥要ES6轉ES5,又體現不了什麼技術能力,又不是實際使用;手機適配的問題,我這大小是可配置的並沒有寫死 。所以,馬上詢問是什麼公司。回覆如下:

林老師,測試題的目的應該就是了解一下應聘者的能力。我想,題目做到現在,我大概的程式碼風格和技術能力,你應該瞭解了。
請問貴公司是?

  然後。。。然後就再沒收到回覆。。。。
  這裡我才想到自己是不是被騙程式碼了?可現在都不敢相信呀,這種程式碼也有人騙麼?可如果不是,難道我這程式碼寫得太low了,所以連個面試機會都拿不到?
  所以,這裡貼上程式碼,分享一下生Canvas繪製餅圖的想法,同時也讓大家幫忙看看,這樣的程式碼能不能得到一次面試機會呀![笑哭]*10
  
  
  

餅圖繪製程式碼

稍微有些難的幾個點:

  1. 會用到三角函式各種計算座標,如果早已忘記,需要回頭看看;
  2. 如何處理點會擠在一的情況;
  3. canvas的畫弧方法arc的0度是從笛卡座標的90度開始,角度不一致需要區分;

  下面是完整的程式碼,有完成的註釋,程式碼比註釋還多。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>餅圖</title>
</head>
<body>
    
    <script>
        /**
         * 繪製餅圖函式
         * 使用到的ES6語法有函式預設引數、解構、字元模板
         * 如果不熟悉,可以看看阮老師的《ECMAScript 6 入門》 
         * 網址 http://es6.ruanyifeng.com/
         * 函式的預設引數
         * r 圓環的圓半徑  data 資料項
         * width 圖表寬度 height 圖表高度
         */
        function addPie({r = 100,width = 450,height = 400,data = []} = {}) {

            let cns = document.createElement('canvas'); //建立一個canvas
            let ctx = cns.getContext('2d'); //獲取canvas操作物件
            let w = width; 
            let h = height; //將width、height賦值給w、h
            let originX = w / 2; //原點x值
            let originY = h / 2; //原點y值
            let points = []; //用於儲存資料項線條起點座標
            let leftPoints = []; //儲存在左邊的點
            let rightPoints = []; //儲存在右邊的點,分出左右是為了計算兩點垂直間距是否靠太近
            let fontSize = 12; //設定字型大小,畫素
            
            //total儲存總花費,用於計算資料項佔比
            let total = data.reduce(function(v, item) {
                return v + item.cost;
            }, 0)

             /**
              * sAngel 起始角弧度
              * arc方法繪製弧線/圓時,弧的圓形的三點鐘位置是 0 度
              * 也就是0弧度對應笛卡座標的90度位置
              * 為了讓餅圖從笛卡座標的0度開始
              * 起始角弧度需要設定為-.5 * Math.PI
              */
            let sAngel = -.5 * Math.PI; 
            let eAngel = -.5 * Math.PI; //結束角弧度,初始值等於sAngel
            let aAngel = Math.PI * 2; //整圓弧度,用於計算資料項弧度
            let pointR = r + 10; //計算線條起始點的半徑
            let minPadding = 30; //設定資料項兩點最小間距

            //設定canvas和畫布大小
            cns.width = ctx.width = w; 
            cns.height = ctx.height = h;


            let cAngel; //資料項中間位置的弧度值,用於計算線條起始點

            for (let i = 0, len = data.length; i < len; i++) { /* 繪製不同消費的份額 */

                /**
                 * 計算結束角弧度
                 * 等於上一項資料起始弧度值(sAngel)
                 * 加資料佔比(data[i].cost/total)乘以整圓弧度(aAngel)
                 */
                eAngel = sAngel + data[i].cost/total * aAngel ; 

                //畫弧
                _drawArc(ctx, {
                    origin: [originX, originY],
                    color: data[i].color,
                    r,
                    sAngel,
                    eAngel
                })

                /**
                 * 計算cAngel值
                 * cAngel是用於計算線條起始點
                 * 等於當前資料項的起始弧度:sAngel
                 * 加上當前資料項所佔弧度的一半:(eAngel - sAngel) / 2
                 * 因為arc方法0弧度對應笛卡座標的90度位置,我們讓sAngel從 -0.5 * Math.PI開始的
                 * 所以cAngel還要加 0.5 * Math.PI
                 */
                cAngel = 0.5 * Math.PI + sAngel + (eAngel - sAngel) / 2;

                /**
                 * 儲存每個資料項線條的起始點
                 * 根據三角函式
                 * 已知半徑/斜邊長:pointR, 通過正弦函式可以計算出對邊長度
                 * 原點x座標加對邊長度,就是線條起始點x座標
                 * 通過餘弦函式可以計算出鄰邊長度
                 * 原點y座標減鄰邊長度,就是線條起始點y座標
                 */
                points.push([originX + Math.sin(cAngel) * pointR, originY - Math.cos(cAngel) * pointR])

                sAngel = eAngel; //設定下一資料項的起始角度為當前資料項的結束角度

            }




            for (let i = 0, len = points.length; i < len; i++) { /* 繪製起始點的小圓點,並分出左右 */

                // 繪製起始點的小圓點
                _drawArc(ctx, {
                    origin: points[i],
                    color: data[i].color,
                    r: 2
                })

                if (points[i][0] < originX) { /* x座標小於原點x座標,在左邊 */
                    leftPoints.push({
                        point: points[i],
                        /**
                         * top標記座標是否在y軸正方向(是不是在上方)
                         * 用於判斷當兩點擠在一起時,是優先向下還是向上移動線條線束點座標
                         */
                        top: points[i][1] < originY, //y座標小於原點y座標。表示在上方
                        /**
                         * endPoint儲存線條結束點座標
                         * y值不變,在左邊時結束點x為零
                         */
                        endPoint: [0, points[i][1]] 
                    });
                } else { /* 否則在右邊*/
                    rightPoints.push({
                        point: points[i],
                        top: points[i][1] < originY, //y座標小於原點y座標。表示在上方
                        endPoint: [w, points[i][1]] //y值不變,在右邊時結束點x為圖表寬度w
                    });
                }
            }






            

            _makeUseable(rightPoints); //處理右邊擠在一起的情況

            _makeUseable(leftPoints.reverse(), true); //處理左邊擠在一起的情況
            leftPoints.reverse(); //為什麼要翻轉一下,看_makeUseable函式

            
            let i = 0;
            for (let j = 0, len = rightPoints.length; j < len; j++) { // 繪製右側線條、文字
                _drawLine(ctx, {data:data[i], point:rightPoints[j], w, direct: 'right'});
                i++;
            }

            for (let j = 0, len = leftPoints.length; j < len; j++) { // 繪製左側線條、文字
                _drawLine(ctx, {data:data[i], point:leftPoints[j], w});
                i++;
            }

            /* 再繪製一個圓蓋住餅圖,實現圓環效果 */
            _drawArc(ctx, {
                origin: [originX, originY],
                r: r / 5 * 3
            })

            document.body.appendChild(cns); /* 新增到body中 */



            /* 畫弧函式 */
            function _drawArc(ctx, {color = '#fff',origin = [0, 0],r = 100,sAngel = 0, eAngel = 2 * Math.PI}) {
                ctx.beginPath(); //開始
                ctx.strokeStyle = color; //設定線條顏色
                ctx.fillStyle = color; //設定填充色
                ctx.moveTo(...origin); //移動原點
                ctx.arc(origin[0], origin[1], r, sAngel, eAngel); //畫弧
                ctx.fill(); //填充
                ctx.stroke();//繪製已定義的路徑,可省略
            }

            /* 畫線和文字 函式 */
            function _drawLine (ctx, {direct='left',data={},point={},w = 200}) {

                ctx.beginPath(); //開始
                ctx.moveTo(...point.point); //移動畫筆到線條起點
                ctx.strokeStyle = data.color; //設定線條顏色
                if (point.turingPoint) //存在折點 
                    ctx.lineTo(...point.turingPoint); //畫一條到折點的線
                ctx.lineTo(...point.endPoint);//畫一條到結束點的線
                ctx.stroke();//繪製已定義的路徑
                ctx.font = `${fontSize}px 微軟雅黑`; //設定字型相關
                ctx.fillStyle = '#000'; //設定字型顏色
                ctx.textAlign = direct;//設定文字對齊方式
                //繪製資料項花費文字,垂直上移兩個畫素
                ctx.fillText(data.cost,direct === 'left'?0:w, point.endPoint[1] - 2);
                //繪製資料項名稱,垂直下移fontSize個畫素
                ctx.fillText(data.category, direct === 'left'?0:w, point.endPoint[1] + fontSize);
            }

            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.endPoint[1]
                         * 和下一資料項結束點垂直間距是否大於等於最小間距:minPadding
                         * 只有資料線條結束點垂直間距大於等於最小間距,才會返回true
                         */
                        return arr[index + 1].endPoint[1]  - p.endPoint[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].endPoint[1] - arr[i].endPoint[1]; //計算兩點垂直間距

                        if (diff < minPadding) { //小於最小間距,表示會擠到一起

                            if (arr[i].top && arr[i + 1].top) { //是在上部的點,向上移動

                                /**
                                 * 判斷當前的點是否還可以向上移動
                                 * 上方第一個點最往上只可以移動到y值為0
                                 * 之後依次最往上只能移動動y值為:i * minPadding 
                                 * 所以下面判斷應該是:arr[i].endPoint[1] - (minPadding - diff) > i * minPadding
                                 */
                                /**
                                 * 上面左邊leftPoints的點需要翻轉一下的原因是
                                 * 左邊leftPoints的點最上面的點是排在最後的
                                 */
                                if (arr[i].endPoint[1] - (minPadding - diff) > 0 && arr[i].endPoint[1] > i * minPadding) {
                                    //當前點還能向上移動
                                    //向上移動到不擠(滿足最小間距)
                                    arr[i].endPoint[1] = arr[i].endPoint[1] - (minPadding - diff);
                                } else {
                                    //當前點不向上移動到滿足最小間距的位置
                                    //先把當前點移動到能夠移動的最上位置
                                    arr[i].endPoint[1] = i * minPadding;
                                    //再把下個點移動,使滿足最小間距
                                    arr[i + 1].endPoint[1] = arr[i + 1].endPoint[1] + (minPadding - diff);
                                }

                            } else {
                                //是在下部的點,向下移動
                                /**
                                 * 判斷當前點的下個點是否還可以向下移動
                                 * 下方最後一個點最往下只可以移動到y值為h,即圖表高度
                                 * 之前的點依次最往下只能移動動y值為:h - (len - i - 1) * minPadding
                                 * 所以下面判斷應該是:arr[i + 1].endPoint[1] + (minPadding - diff) < h - (len - i - 1) * minPadding
                                 */
                                if (arr[i + 1].endPoint[1] + (minPadding - diff) < h && arr[i + 1].endPoint[1] < h - (len - i - 1) * minPadding) {
                                     //當前點的下個點還能向下移動
                                    //當前點的下個點向下移動到不擠(滿足最小間距)
                                    arr[i + 1].endPoint[1] = arr[i + 1].endPoint[1] + (minPadding - diff)
                                } else {
                                    //當前點的下個點不能向下移動
                                    //先把當前點的下個點向下移動能夠移動的最下位置
                                    arr[i + 1].endPoint[1] = h - (len - i - 1) * minPadding;
                                    //再把當前點移動,使滿足最小間距
                                    arr[i].endPoint[1] = arr[i].endPoint[1] - (minPadding - diff);
                                }
                            }

                            break; //每次移動完成直接退出迴圈,判斷一次是否已經不擠
                        }
                    }
                }


                /**
                 * 遍歷已經可用的資料 
                 * 起點和結束點不在同一水平線上
                 * 需要設定折點
                 * 這裡通過設定折線角度,計算出折點位置
                 * 回頭一想,其實可以用更簡單的方法,想複雜了
                 */
                for (let i = 0, len = arr.length; i < len; i++) { 

                    //起點和結束點y值不等,則不在同一水平線,需要設定折點
                    if (arr[i].point[1] !== arr[i].endPoint[1]) { 

                        turingAngel = 1 / 3 * Math.PI; //預設折線角度設定60度
                        //計算出起點和結束點高度差
                        diffH = arr[i].endPoint[1] - arr[i].point[1]; 
                        //計算出起點和結束點水平距離l
                        l = Math.abs(arr[i].endPoint[0] - arr[i].point[0]); 

                        /**
                         * x 這裡的本意是
                         * 想計算出折點和起始點的水平距離x
                         * 因為起始點到折點的水平距離
                         * 不能大於起始點到結束的水平距離-40(留40放文字)
                         * 通過x可以確定折點的x座標值
                         * 所以已知對邊和角度,應該使用正切函式求鄰邊邊長
                         * 這裡卻使用了正弦求了斜邊
                         */
                        x = Math.abs(maths(turingAngel) * diffH);

                        /**
                         * 如果始點到折點的水平距離
                         * 大於起始點到結束的水平距離-40(留40放文字)
                         * 減小角度,計算新折點
                         */
                        while (x > (l - 40)) { 
                            turingAngel /= 2;
                            x = maths(turingAngel) * (arr[i].endPoint[1] - arr[i].point[1]);
                        }
                        //通過x可以確定折點的x座標值,y座標就是結束點的y座標
                        arr[i].turingPoint = [arr[i].point[0] + (left ? -x : x), arr[i].endPoint[1]]
                    }
                }
            }

        }

        //呼叫繪圖函式
        addPie({
            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>

寫在最後

  因為是單個測試題目,所以沒有用圖表庫。之所以沒用SVG去實現,是因為之前只有接觸過canvas。不過,後續真可以考慮使用svg來實現一下。

相關文章