前言
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
然後把它轉換成圖形。
這次的完整示範會包含:
首先,在 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>
這裡其實是今天最重要的數學概念之一。
因為我們有六個維度。
所以每個維度都需要一個方向。
我們可以把整個圓分成:
360° ÷ 6 = 60°
也就是每個維度之間相差 60 度。
在 JavaScript 裡,角度不是直接使用「60 度」。
而是使用弧度。
所以會看到:
Math.PI * 2
這其實就是:
360°
因此每個維度的角度可以透過:
const angle =
-Math.PI / 2
+
(Math.PI * 2 * i)
/ total;
計算出來。
一開始使用:
-Math.PI / 2
是為了讓第一個維度從「正上方」開始。
不然第一個點可能會從右邊開始。
雖然程式不會壞掉。
但我覺得看起來就是怪怪的www
接下來才是真正有趣的地方。
我們手上的資料是:
日常: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
這樣程式就知道每個點到底要離中心多遠。
結論:雷達圖沒有什麼魔法。
是:
分數 → 距離 → 座標 → 連線。
接下來就會看到今天很有數學味的部分:
const x =
centerX
+
Math.cos(angle)
* valueRadius;
const y =
centerY
+
Math.sin(angle)
* valueRadius;
這裡的 Math.cos() 和 Math.sin(),就是拿來計算:
「在這個角度上,距離中心這麼遠的地方,到底在哪裡?」
最後就會得到:
X 座標
+
Y 座標
然後 Canvas 就知道:
「喔,原來這個人的效率 8 分要畫在這裡。」
當六個點都算出來之後,就可以把它們連起來:
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();
這樣使用者的數據範圍就會變成一個完整的區域。
也就是:
只有線
↓
有一個可以被看見的「形狀」
這個形狀其實很有意思。
不同的人做完測驗之後,六個分數不同,最後畫出來的形狀也會完全不一樣。
有人可能比較平均。
有人可能某個方向特別突出。
也有人可能……
六個方向都很奇怪。
那大概就代表我們的賽博紓壓診所又有新病例了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 見!