原生Canvas绘制饼图,我是不是被骗代码了

  这回算是真明白了什么叫"林子大了什么鸟都有!"之前就有听说面试骗代码的情况,但也仅仅只是听说。这回是真亲身遇到了。来来来,自带小板凳,准备好瓜子。好好看看我被骗的经历。顺便也看看使用原生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来实现一下。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容