Canvas繪製一個類似老版支付寶信用分儀表盤效果

CRPER發表於2019-03-30

前言

使用了ESM+TS的風格來寫一個類似老版本支付寶信用分的效果(會動!!!);

一開始用的是普通的ES5+的風格來寫,這兩版的程式碼都會展示,

模組的版本增加了一些細節的考慮,有興趣的看官可以看看


效果圖及Demo

具體的效果圖可以在Codesanbox上看

Codesanbox : codesandbox.io/s/4rvo5mwxj…

具體的亮點可以看README

Github: github.com/crper/canva…


能收穫什麼?

程式碼寫了一大堆註釋。

我的實現思路及編碼姿勢,以及一些typescript的用法


程式碼

  • 版本1: 非ESM的風格

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        canvas {
            display: block;
            margin: 0 auto;
            background-image: linear-gradient(to top, #a3bded 0%, #6991c7 100%);
        }

        #test-action {
            width: 200px;
            height: 50px;
            font-weight: 700;
            background-image: linear-gradient(120deg, #a1c4fd 0%, #c2e9fb 100%);
            color: #333;
            font-size: 16px;
            line-height: 50px;
            border-radius: 50px;
            text-align: center;
            cursor: pointer;
            margin: 50px auto;
        }
    </style>
</head>

<body>
<div id="test-action">點選我看隨機效果</div>
<script>
    window.addEventListener('DOMContentLoaded', function () {
        /** @type {HTMLCanvasElement} */
        let canvas = document.createElement('canvas');
        let ctx = canvas.getContext('2d');
        let ratio = window.devicePixelRatio; // 畫素比
        canvas.id = "credit-score";
        canvas.width = 375;
        canvas.height = 375;
        canvas.style.width = "375px";
        canvas.style.height = "375px";
        document.body.appendChild(canvas);

        // 初始化的一些值
        // canvas 預設是逆時針行走,開始的角度到結束角度
        const initParams = {
            w: canvas.width * ratio,  // 畫布的寬度
            h: canvas.height * ratio, // 畫布的高度
            x: canvas.width * ratio / 2, // 圓心座標x,y
            y: canvas.width * ratio / 2, //  圓心座標x,y
            startAngle: 165, // 畫布的起點
            endAngle: 375, // 畫布的結束點
            currentAngle: 165, // 當前的角度
            scoreStart: 450, // 起步分
            scoreTarget: 770, // 目標分
            scoreMin: 450, // 最低分
            scoreMax: 850, // 最高分
            scoreEvaDate: "評估日期:2019-04-01", // 評估日期
            segAngle: 84, // 總角度分成幾等分
            stepAngle() { // 每次走多少度
                return (this.endAngle - this.startAngle) / this.segAngle;
            },
            outerTextSeg: 10,  // 數字範圍等分
            outerText() {
                let textGap = Math.ceil(this.scoreRange() / (this.outerTextSeg - 1));
                let textArr = Array.from(new Array(this.outerTextSeg), ((item, index) => textGap * index + this.scoreMin));
                let textStepAngel = this.angelRange() / (textArr.length - 1);
                let textAngelArr = textArr.map((item, index) => this.startAngle + textStepAngel * index);
                return {
                    textArr,
                    textLenght: textArr.length,
                    textStepAngel,
                    textAngelArr
                };
            },
            scoreRange() { // 分數的範圍
                return this.scoreMax - this.scoreMin;
            },
            angelRange() {  // 角度範圍
                return this.endAngle - this.startAngle
            },
            scoreLevelText(text) {// 信用評級
                let threeRangeScore = Math.ceil(this.scoreRange() / 3);
                if (text) {
                    return text;
                } else {
                    if (this.scoreStart <= this.scoreMin + threeRangeScore) {
                        return '有待提高'
                    }
                    if (this.scoreStart > threeRangeScore && this.scoreStart <= this.scoreMin + threeRangeScore * 2) {
                        return '信用良好'
                    }
                    if (this.scoreStart > this.scoreMin + threeRangeScore * 2 && this.scoreStart <= this.scoreMax) {
                        return '信用極好'
                    }
                }

            },
            style: {
                line: { // 線條顏色控制
                    initColor: "rgba(255, 191, 150, 0.5)",  // 初始化顏色
                    activeColor: "#fff", // 高亮的顏色
                    width: 1  // 線條的出息
                },
                dashLine: {
                    initColor: "rgba(255, 191, 150, 0.5)",  // 初始化顏色
                    activeColor: "#fff", // 高亮的顏色
                    width: 1  // 線條的粗細
                },
                text: {  // 文字顏色
                    outerText: {   // 外環文字
                        fontSize: 12,
                        color: "#fff",
                    },
                    innerText: {
                        score: {
                            fontSize: 36,
                            color: "#fff",
                        },
                        level: {
                            fontSize: 18,
                            color: "#fff",
                        },
                        date: {
                            fontSize: 12,
                            color: "#f2f2f2",
                            fontWeight: "normal"
                        }
                    }
                }
            }
        }

        let pointImg = new Image();
        pointImg.src = "data:image/svg+xml;base64,PHN2ZyAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjE2cHgiIGhlaWdodD0iMjRweCI+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiAgZmlsbD0icmdiKDI1NSwgMjU1LCAyNTUpIiBkPSJNOC4wMDAsMjQuMDA1IEMzLjU4MiwyNC4wMDUgLTAuMDAwLDIwLjUyNSAtMC4wMDAsMTYuMjMwIEMtMC4wMDAsMTEuOTM2IDguMDAwLC0wLjAwNSA4LjAwMCwtMC4wMDUgQzguMDAwLC0wLjAwNSAxNS45OTksMTEuOTM2IDE1Ljk5OSwxNi4yMzAgQzE1Ljk5OSwyMC41MjUgMTIuNDE4LDI0LjAwNSA4LjAwMCwyNC4wMDUgWk04LjAwMCwxMi4wNjUgQzUuNjI1LDEyLjA2NSAzLjcwMCwxMy45MzQgMy43MDAsMTYuMjM5IEMzLjcwMCwxOC41NDQgNS42MjUsMjAuNDEzIDguMDAwLDIwLjQxMyBDMTAuMzc1LDIwLjQxMyAxMi4zMDAsMTguNTQ0IDEyLjMwMCwxNi4yMzkgQzEyLjMwMCwxMy45MzQgMTAuMzc1LDEyLjA2NSA4LjAwMCwxMi4wNjUgWiIvPjwvc3ZnPg=="


        ctx.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);




        // 獲取弧度
        function getRadian(degrees) {
            return Math.PI / 180 * degrees;
        }

        // 獲取度數
        function getDegrees(radian) {
            return 180 / Math.PI * radian
        }

        // 獲取圓邊上點的座標
        function getRadiusPoint(x, y, radius, degrees) {
            return {
                x1: x + radius * Math.cos(degrees),
                y1: y + radius * Math.sin(degrees)
            }
        }

        // 繪製外圍文字
        function drawOuterText(x, y, text, fontSize = 28, color = "#fff", fontWeight = "normal") {
            ctx.beginPath();
            ctx.fillStyle = color;
            ctx.font = `${fontWeight} ${fontSize * ratio}px Microsoft yahei`;
            ctx.textBaseline = 'ideographic';
            ctx.textAlign = "left";
            ctx.fillText(text, x - ctx.measureText(text).width / 2, y);
        }

        // 繪製中心文字
        function drawInnerText(x, y, text, fontSize = 30, color = "#fff", fontWeight = "bold") {

            ctx.save();
            ctx.fillStyle = color;
            ctx.font = `${fontWeight} ${fontSize * ratio}px Microsoft yahei`;
            ctx.textAlign = "center";
            ctx.fillText(`${text}`, x, y);
            ctx.textBaseline = 'ideographic';
            ctx.restore();
        }

        // 畫小圓點
        function drawCircle(x, y, fillColor, mode = false) {
            ctx.beginPath();
            ctx.arc(x, y, initParams.style.line.width * ratio, 0, 2 * Math.PI, mode);
            ctx.fillStyle = fillColor;
            ctx.fill();
        }

        // 畫水滴
        function waterDrop(x = 60, y = 180, rotate = -120) {
            ctx.save();
            ctx.translate(x, y);
            ctx.rotate(getRadian(rotate));
            ctx.drawImage(pointImg, 0, 0, 8 * ratio, 12 * ratio);
            ctx.restore();

        }

        // 移動水滴
        function moveWaterDrop(x, y, radius, angle) {
            const {x1, y1} = getRadiusPoint(x, y, radius, getRadian(angle));
            // 為什麼要加90°,是因為圖片預設是垂直的,我們要扭正他,從座標系的初始值開始
            waterDrop(x1, y1, angle + 90);
        }

        // 畫虛線圓弧
        function drawCircleDashLine({color}, {x, y, radius}) {
            ctx.beginPath();
            for (let i = 1; i <= initParams.segAngle; i++) {
                const {x1, y1} = getRadiusPoint(x, y, radius,  getRadian( initParams.startAngle + initParams.stepAngle() * i));
                drawCircle(x1, y1, color);
            }
        }

        // 畫實線弧
        function drawCircleLine({color}, {x, y, radius}) {
            ctx.beginPath()
            ctx.arc(x, y, radius, getRadian(initParams.startAngle), getRadian(initParams.endAngle));
            ctx.strokeStyle = color;
            ctx.lineCap = "round";
            ctx.lineWidth = 1 * ratio;
            ctx.stroke();
        }

        // 畫外圍文字線
        function drawOuterTextLine({outerText: {textArr, textAngelArr, textStepAngel, textLenght}, color}, {x, y, radius}) {
            // 最外圍文字層
            for (let index = 0; index < textLenght; index++) {
                let angle = getRadian(textAngelArr[index]);
                const {x1, y1} = getRadiusPoint(x, y,radius, angle);
                drawOuterText(x1, y1, textArr[index], initParams.style.text.outerText.fontSize, initParams.style.text.outerText.color)
            }
        }

        // 清除畫布內容
        function clearCanvas() {
            ctx.clearRect(0, 0, initParams.w, initParams.h);
        }


        // 畫底圖,也就是初始化的圖
        function drawBaseMap() {
            const {score, level, date} = initParams.style.text.innerText;
            clearCanvas();
            drawOuterTextLine({
                outerText: initParams.outerText(),
            }, {
                x: initParams.x,
                y: initParams.y,
                radius: initParams.x * 0.82
            });

            drawCircleLine(
                {
                    color: initParams.style.line.initColor
                },
                {
                    x: initParams.x,
                    y: initParams.y,
                    radius: initParams.x * 0.7
                });
            drawCircleDashLine(
                {
                    color: initParams.style.dashLine.initColor,
                }
                ,
                {
                    x: initParams.x,
                    y: initParams.y,
                    radius: initParams.x * 0.6666666
                }
            );


            // 文字位置()
            drawInnerText(initParams.x, initParams.y - 10 * ratio, initParams.scoreStart, score.fontSize, score.color);
            drawInnerText(initParams.x, initParams.y + 20 * ratio, initParams.scoreLevelText(), level.fontSize, level.color);
            drawInnerText(initParams.x, initParams.y + 40 * ratio, initParams.scoreEvaDate, date.fontSize, date.color, date.fontWeight);


            // 覆蓋實線
            drawCircleLine(
                {
                    color: initParams.style.line.activeColor,
                },
                {
                    x: initParams.x,
                    y: initParams.y,
                    radius: initParams.x * 0.7
                });

            // 覆蓋虛線
            drawCircleDashLine(
                {
                    color: initParams.style.dashLine.activeColor,
                }
                ,
                {
                    x: initParams.x,
                    y: initParams.y,
                    radius: initParams.x * 0.6666666
                }
            );

            // 移動小水滴
            moveWaterDrop(initParams.x, initParams.y, initParams.x * 0.6, initParams.currentAngle);

            // 範圍內無限render
            if (initParams.scoreStart < initParams.scoreTarget) {
                // 每次移動角度的度數範圍
                initParams.currentAngle += initParams.stepAngle();

                // 文字變化
                // 求出每次移動角度的度數範圍要走多少分數
                let stepScore = (initParams.scoreRange() * initParams.stepAngle()) / initParams.angelRange();
                initParams.scoreStart = initParams.scoreStart + Math.round(stepScore);
                if (initParams.scoreStart >= initParams.scoreTarget) {
                    initParams.scoreStart = initParams.scoreTarget
                }
                // 求當前角度與分數角度的比較,當前累計的角度小於需要移動到目的地的角度就繼續渲染
                let stepAngle = initParams.startAngle + (initParams.angelRange() * ((initParams.scoreTarget - initParams.scoreMin)) / initParams.scoreRange());
                if (initParams.currentAngle >= stepAngle) return false;
                window.requestAnimationFrame(drawBaseMap);
            }
        }

        let st = setTimeout(() => {
            clearTimeout(st);
            drawBaseMap();
        }, 1000);


        function randomHexColor() { //隨機生成十六進位制顏色
            var hex = Math.floor(Math.random() * 16777216).toString(16); //生成ffffff以內16進位制數
            while (hex.length < 6) { //while迴圈判斷hex位數,少於6位前面加0湊夠6位
                hex = '0' + hex;
            }
            return '#' + hex; //返回‘#’開頭16進位制顏色
        }

        // 重置隨機玩玩
        document.getElementById('test-action').addEventListener('click', function () {
            // 點選重置
            initParams.scoreStart = 450;
            initParams.scoreTarget = Math.round(Math.random() * 400) + 450;
            initParams.currentAngle = 165;
            initParams.segAngle = [42, 84, 168, 336][Math.ceil(Math.random() * 3)];
            initParams.outerTextSeg = [5, 10, 15, 20][Math.ceil(Math.random() * 3)];
            let randomColor = randomHexColor();


            initParams.style = {
                line: { // 線條顏色控制
                    initColor: "rgba(255, 191, 150, 0.5)",  // 初始化顏色
                    activeColor: randomColor, // 高亮的顏色
                    width: Math.random() * 1 + 1  // 線條的出息
                },
                dashLine: {
                    initColor: "rgba(255, 191, 150, 0.5)",  // 初始化顏色
                    activeColor: randomColor, // 高亮的顏色
                    width: Math.random() * 1 + 1   // 線條的出息
                },
                text: {  // 文字顏色
                    outerText: {   // 外環文字
                        fontSize: 12,
                        color: randomColor,
                    },
                    innerText: {
                        score: {
                            fontSize: 36,
                            color: randomColor,
                        },
                        level: {
                            fontSize: 18,
                            color: randomColor,
                        },
                        date: {
                            fontSize: 12,
                            color: randomColor,
                            fontWeight: "normal"
                        }
                    }
                }
            }

            drawBaseMap()

        })
    })

</script>
</body>

</html>

複製程式碼
  • 版本2:已釋出npm,ESM+TS的風格

Code : github.com/crper/canva…

總結

公司有這麼個需求,而我以前沒用過Canvas,只能自行爬坑。

總體來說canvas的標準使用姿勢並不複雜,複雜點在於數學這塊。

寫這個讓我溫習了的高中數學。。。

ESM模組的釋出,用了rollup來打包,很不錯的一個工具,有時間我寫個typescript-rollup-startkit

有不對之處請留言,會及時修復。謝謝閱讀

相關文章