iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 12 篇

Day 12:Canvas 畫布實戰:把六大維度真的畫出來

  • 分享至 

  • xImage
  •  

前言

DAY 11 把六大維度的分數存下來了。

使用者每做完一題,程式就會默默幫他把分數累積起來。

最後可能得到:

日常:5
效率:8
社交:3
心態:6
放鬆:9
職涯:4

到這裡為止,其實資料已經有了。

但是問題來了。

如果我最後只跟使用者說:

「恭喜你,你的效率是 8 分。」

然後再列一排:

日常 5
效率 8
社交 3
心態 6
放鬆 9
職涯 4

……

是不是有點太像 Excel?

都已經叫做「賽博紓壓診所」了,結果最後給我一張成績單。

不行。

這樣不夠賽博。

所以今天 Day 12,我們要開始處理一件看起來很玄學,但其實就是數學的事情:

把一堆數字畫成一張六角雷達圖。

而且這次不靠套件。

直接自己用 HTML5 Canvas 畫。


核心觀念:Canvas 到底可以拿來做什麼?

Canvas 可以把它想成一塊空白的電子畫布。

HTML 負責把這塊畫布放到網頁上,而 JavaScript 則負責告訴它:

「我要在哪裡畫線。」

例如:

ctx.beginPath();
ctx.moveTo(100, 100);
ctx.lineTo(200, 200);
ctx.stroke();

這樣就可以畫出一條線。

所以如果我們把:

日常
效率
社交
心態
放鬆
職涯

分別放在一個六角形的六個方向,

再把每個維度的分數換算成距離,

最後把六個點連起來……

就會得到:

六角雷達圖。

大概就是:

             職涯
              ▲
             / \
            /   \
       放鬆 /     \ 日常
          /   ●   \
         /         \
       心態 ------- 效率
             社交

當然,實際畫出來會漂亮很多。

至少希望如此。


動手寫 Code:建立 Canvas 雷達圖

今天我不會把那一大串題庫全部再貼一次。

因為昨天已經負責把分數算好了。

今天只需要接收:

userScores

然後把它轉換成圖形。

這次的完整示範會包含:

  • Canvas 畫布
  • 六大維度
  • 六角形背景網格
  • 維度名稱
  • 分數換算
  • 雷達圖資料區域
  • 中心點
  • 動態重新繪製

1. HTML:先放一塊畫布

首先,在 HTML 裡準備一個 Canvas。

<!DOCTYPE html>
<html lang="zh-Hant">

<head>

    <meta charset="UTF-8">

    <meta name="viewport"
          content="width=device-width, initial-scale=1.0">

    <title>賽博紓壓診所|六角雷達圖</title>

    <style>

        * {
            box-sizing: border-box;
        }

        body {
            margin: 0;
            padding: 0;

            min-height: 100vh;

            display: flex;
            justify-content: center;
            align-items: center;

            background: #111827;

            color: white;

            font-family:
                Arial,
                "Microsoft JhengHei",
                sans-serif;
        }

        .card {
            width: 90%;
            max-width: 700px;

            padding: 30px;

            background: #1f2937;

            border-radius: 20px;

            box-shadow:
                0 0 25px rgba(0, 255, 255, 0.2);

            text-align: center;
        }

        h1 {
            margin-bottom: 5px;

            color: #67e8f9;
        }

        .subtitle {
            color: #9ca3af;

            margin-bottom: 20px;
        }

        canvas {
            width: 100%;
            max-width: 600px;

            height: auto;

            display: block;

            margin: 0 auto;
        }

    </style>

</head>


<body>

    <div class="card">

        <h1>🧠 賽博紓壓診所</h1>

        <p class="subtitle">
            六大心理維度分析
        </p>

        <canvas
            id="radarChart"
            width="600"
            height="600">
        </canvas>

    </div>


    <script>

        // Day 11 計算完成後的結果
        // 這裡先使用測試資料

        const userScores = {

            "日常": 5,
            "效率": 8,
            "社交": 3,
            "心態": 6,
            "放鬆": 9,
            "職涯": 4

        };


        const canvas =
            document.getElementById("radarChart");

        const ctx =
            canvas.getContext("2d");


        function drawRadarChart(scores) {

            const dimensions =
                Object.keys(scores);

            const values =
                Object.values(scores);


            const centerX =
                canvas.width / 2;

            const centerY =
                canvas.height / 2;


            const radius = 200;

            const maxScore = 10;

            const total =
                dimensions.length;


            // 清空畫布

            ctx.clearRect(
                0,
                0,
                canvas.width,
                canvas.height
            );


            /*
             * 第一層:
             * 畫六角形背景
             */

            ctx.beginPath();


            for (
                let i = 0;
                i < total;
                i++
            ) {

                const angle =
                    -Math.PI / 2
                    +
                    (Math.PI * 2 * i)
                    / total;


                const x =
                    centerX
                    +
                    Math.cos(angle)
                    * radius;


                const y =
                    centerY
                    +
                    Math.sin(angle)
                    * radius;


                if (i === 0) {

                    ctx.moveTo(x, y);

                }
                else {

                    ctx.lineTo(x, y);

                }

            }


            ctx.closePath();


            ctx.strokeStyle =
                "#67e8f9";

            ctx.stroke();


            /*
             * 第二層:
             * 畫六條放射線
             */

            for (
                let i = 0;
                i < total;
                i++
            ) {

                const angle =
                    -Math.PI / 2
                    +
                    (Math.PI * 2 * i)
                    / total;


                const x =
                    centerX
                    +
                    Math.cos(angle)
                    * radius;


                const y =
                    centerY
                    +
                    Math.sin(angle)
                    * radius;


                ctx.beginPath();

                ctx.moveTo(
                    centerX,
                    centerY
                );

                ctx.lineTo(x, y);

                ctx.strokeStyle =
                    "#374151";

                ctx.stroke();

            }


            /*
             * 第三層:
             * 畫不同分數等級的六角形
             */

            const levels = 5;


            for (
                let level = 1;
                level <= levels;
                level++
            ) {

                const levelRadius =
                    radius
                    * level
                    / levels;


                ctx.beginPath();


                for (
                    let i = 0;
                    i < total;
                    i++
                ) {

                    const angle =
                        -Math.PI / 2
                        +
                        (Math.PI * 2 * i)
                        / total;


                    const x =
                        centerX
                        +
                        Math.cos(angle)
                        * levelRadius;


                    const y =
                        centerY
                        +
                        Math.sin(angle)
                        * levelRadius;


                    if (i === 0) {

                        ctx.moveTo(x, y);

                    }
                    else {

                        ctx.lineTo(x, y);

                    }

                }


                ctx.closePath();


                ctx.strokeStyle =
                    "#374151";

                ctx.stroke();

            }


            /*
             * 第四層:
             * 計算使用者的六個資料點
             */

            ctx.beginPath();


            values.forEach(
                (value, i) => {

                    const angle =
                        -Math.PI / 2
                        +
                        (Math.PI * 2 * i)
                        / total;


                    const valueRadius =
                        radius
                        * value
                        / maxScore;


                    const x =
                        centerX
                        +
                        Math.cos(angle)
                        * valueRadius;


                    const y =
                        centerY
                        +
                        Math.sin(angle)
                        * valueRadius;


                    if (i === 0) {

                        ctx.moveTo(x, y);

                    }
                    else {

                        ctx.lineTo(x, y);

                    }

                }
            );


            ctx.closePath();


            /*
             * 填滿雷達圖
             */

            ctx.fillStyle =
                "rgba(103, 232, 249, 0.25)";

            ctx.fill();


            /*
             * 畫出雷達圖外框
             */

            ctx.strokeStyle =
                "#67e8f9";

            ctx.lineWidth = 3;

            ctx.stroke();


            /*
             * 第五層:
             * 畫出每個資料點
             */

            values.forEach(
                (value, i) => {

                    const angle =
                        -Math.PI / 2
                        +
                        (Math.PI * 2 * i)
                        / total;


                    const valueRadius =
                        radius
                        * value
                        / maxScore;


                    const x =
                        centerX
                        +
                        Math.cos(angle)
                        * valueRadius;


                    const y =
                        centerY
                        +
                        Math.sin(angle)
                        * valueRadius;


                    ctx.beginPath();

                    ctx.arc(
                        x,
                        y,
                        5,
                        0,
                        Math.PI * 2
                    );


                    ctx.fillStyle =
                        "#67e8f9";

                    ctx.fill();

                }
            );


            /*
             * 第六層:
             * 顯示六個維度名稱
             */

            ctx.font =
                "16px Microsoft JhengHei";


            ctx.fillStyle =
                "white";


            dimensions.forEach(
                (dimension, i) => {

                    const angle =
                        -Math.PI / 2
                        +
                        (Math.PI * 2 * i)
                        / total;


                    const labelRadius =
                        radius + 35;


                    const x =
                        centerX
                        +
                        Math.cos(angle)
                        * labelRadius;


                    const y =
                        centerY
                        +
                        Math.sin(angle)
                        * labelRadius;


                    ctx.textAlign = "center";

                    ctx.textBaseline = "middle";


                    ctx.fillText(
                        dimension,
                        x,
                        y
                    );

                }
            );


            /*
             * 中心點
             */

            ctx.beginPath();

            ctx.arc(
                centerX,
                centerY,
                4,
                0,
                Math.PI * 2
            );


            ctx.fillStyle =
                "#67e8f9";

            ctx.fill();

        }


        // 開始繪製

        drawRadarChart(userScores);

    </script>

</body>

</html>

2. 為什麼是六角形?

這裡其實是今天最重要的數學概念之一。

因為我們有六個維度。

所以每個維度都需要一個方向。

我們可以把整個圓分成:

360° ÷ 6 = 60°

也就是每個維度之間相差 60 度。

在 JavaScript 裡,角度不是直接使用「60 度」。

而是使用弧度。

所以會看到:

Math.PI * 2

這其實就是:

360°

因此每個維度的角度可以透過:

const angle =
    -Math.PI / 2
    +
    (Math.PI * 2 * i)
    / total;

計算出來。

一開始使用:

-Math.PI / 2

是為了讓第一個維度從「正上方」開始。

不然第一個點可能會從右邊開始。

雖然程式不會壞掉。

但我覺得看起來就是怪怪的www


3. 把「分數」變成「距離」

接下來才是真正有趣的地方。

我們手上的資料是:

日常:5
效率:8
社交:3
心態:6
放鬆:9
職涯:4

但是 Canvas 根本不知道「8 分」要畫在哪裡。

所以我們必須先把分數轉成半徑。

假設:

const radius = 200;
const maxScore = 10;

那麼:

const valueRadius =
    radius
    * value
    / maxScore;

就可以把分數換算成距離。

例如:

10 分 → 200
5 分  → 100
2 分  → 40

這樣程式就知道每個點到底要離中心多遠。

結論:雷達圖沒有什麼魔法。

是:

分數 → 距離 → 座標 → 連線。


4. 使用三角函數算出座標

接下來就會看到今天很有數學味的部分:

const x =
    centerX
    +
    Math.cos(angle)
    * valueRadius;

const y =
    centerY
    +
    Math.sin(angle)
    * valueRadius;

這裡的 Math.cos() 和 Math.sin(),就是拿來計算:

「在這個角度上,距離中心這麼遠的地方,到底在哪裡?」

最後就會得到:

X 座標
+
Y 座標

然後 Canvas 就知道:

「喔,原來這個人的效率 8 分要畫在這裡。」


5. 把六個點連起來

當六個點都算出來之後,就可以把它們連起來:

ctx.beginPath();

values.forEach((value, i) => {

    const angle =
        -Math.PI / 2
        +
        (Math.PI * 2 * i)
        / total;

    const valueRadius =
        radius
        * value
        / maxScore;

    const x =
        centerX
        +
        Math.cos(angle)
        * valueRadius;

    const y =
        centerY
        +
        Math.sin(angle)
        * valueRadius;


    if (i === 0) {

        ctx.moveTo(x, y);

    }
    else {

        ctx.lineTo(x, y);

    }

});

ctx.closePath();

這裡的概念其實很直白:

第一個點
↓
第二個點
↓
第三個點
↓
第四個點
↓
第五個點
↓
第六個點
↓
回到第一個點

這樣就形成了自己的六邊形。


6. 讓雷達圖不只是「幾條線」

如果只畫線,其實看起來還是有點寒酸。

所以我又加了一層半透明填色:

ctx.fillStyle =
    "rgba(103, 232, 249, 0.25)";

ctx.fill();

這樣使用者的數據範圍就會變成一個完整的區域。

也就是:

只有線
    ↓
有一個可以被看見的「形狀」

這個形狀其實很有意思。

不同的人做完測驗之後,六個分數不同,最後畫出來的形狀也會完全不一樣。

有人可能比較平均。

有人可能某個方向特別突出。

也有人可能……

六個方向都很奇怪。

那大概就代表我們的賽博紓壓診所又有新病例了kkk


開發心得與設計考量

今天寫 Canvas 的時候,我最大的感覺其實是:

原來畫一個看起來很簡單的雷達圖,實際上是在背後花費不少心力。

一開始看到:

Math.cos()
Math.sin()
Math.PI

我的第一個反應其實不是:

「哇!好有趣!」

而是:

「完了,又來。」

但真的把它拆開之後,反而沒有想像中那麼可怕。

因為我們其實一直在做同一件事情:

取得分數
↓
換算距離
↓
計算角度
↓
得到座標
↓
畫出來

而且這次也讓我更明顯感受到:

前面資料結構怎麼設計,真的會影響後面有多痛苦。

如果我昨天沒有把六個維度統一放在:

userScores

裡面,

今天可能就要寫一大堆:

dailyScore
efficiencyScore
socialScore
...

然後再一個一個指定位置。

想到就累。

現在只要:

Object.keys(userScores)

就可以直接把六個維度抓出來。

資料結構整理好之後,後面的 Canvas 就可以直接吃。

這也是我覺得這次專題滿有趣的地方。

前面看起來只是「把資料存起來」。

結果到了今天,這些資料突然真的開始有形狀了。


今日結語與明日預告

Day 11 的時候,我們讓系統開始「記得」使用者做了什麼。

今天 Day 12,則是讓系統開始「看得見」這些結果。

整個流程現在變成:

使用者回答問題
        ↓
取得 scores
        ↓
累加 userScores
        ↓
六大維度分數
        ↓
計算角度
        ↓
計算座標
        ↓
Canvas
        ↓
六角雷達圖

原本只是:

日常:5
效率:8
社交:3
心態:6
放鬆:9
職涯:4

現在終於變成一張真的可以看的圖。

而且這還只是第一版。

因為目前的雷達圖雖然已經畫得出來,但我看著它還是覺得:

「嗯……可以,但好像還不夠像一個真的網站。」

背景有了。

六角形有了。

分數也有了。

但是如果讓它動起來呢?

如果使用者做完測驗之後,雷達圖可以從中心慢慢展開?

如果不同維度的分數可以有自己的動畫?

如果最後還可以根據最高分,顯示一個專屬的「賽博人格診斷結果」?

這樣好像才比較有意思。

所以到了下一篇 Day 13,我們就不只是讓資料「畫出來」。

而是開始讓它:

動起來。

從靜態的六角雷達圖,進化成真正有互動感的結果頁。

畢竟都已經叫賽博紓壓診所了,

總不能每次都只會:

「您好,您的效率是 8 分。」

那也太無趣了。

下一篇,繼續把它搞得更像一個真的系統。

我們 Day 13 見!


上一篇
Day 11:維度計分機制:如何將零碎選項匯聚成數據
下一篇
Day 13:讓雷達圖動起來:從靜態結果到真正的互動感
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言