昨天總算把那顆卡在 0% 好幾天的電池救活了,看著耗損率跟著緩動曲線一路跑上去,還滿有成就感的。
寫完之後我把病歷本打開來,順手把六個維度的分數攤開來看:
日常 5 效率 8 社交 3 心態 -5 放鬆 9 職涯 4
看到這排數字,我突然想到一件事。
Day 11 那時候,我做的事情是幫這個測驗裝一顆「計分大腦」:使用者選了什麼,分數就一路累加進 userScores 裡。那篇的重點是分數到底跑哪去了,確保每個選擇都有被記錄下來,不會答完一整輪結果六個維度還是 0。
但今天要處理的,是一個 Day 11 完全沒碰到的問題。
因為我今天多想了一步,回頭去翻題庫,才發現一件事:
這六個維度,能拿到的分數範圍根本不一樣長。
舉個實際的例子:「放鬆」光靠兩題求籤就能衝到 9 分,題庫對它特別大方;但「社交」整份題庫翻遍了,能加到的分數頂多 7 分左右,扣分卻扣得比誰都兇。
也就是說,同樣是拿到 5 分,「放鬆」可能只是普通表現,「社交」搞不好已經是接近滿分的水準。Day 11 的 userScores 老實記錄了這一切,但它沒辦法告訴你「這個 5 分,到底算好還是不好」。
如果直接把這六個原始分數拿去畫雷達圖,形狀看起來會很不公平:某個維度特別突出,可能只是因為題庫給它的分數上限比較高,不是使用者真的在那個維度特別厲害。
雷達圖出來了,但在這之前還有一件沒做的事要先處理:
把六個維度,通通換算成同一把尺——0 到 100 分。
昨天算耗損率,我掃了整份題庫,找出「整體」的最小值跟最大值,只有一組 min、max。
今天邏輯很像,但要拆成六份,每個維度各自掃一次題庫。
以「心態」為例,我得一題一題去看:這一題如果選最差的選項,心態會扣幾分?選最好的呢?
Q1(一般題):選 B 心態 -2,選 A/C 心態 +0 或 +1 → 這題心態範圍 -2 ~ 1
Q2(求籤題):大凶 -5,其他兩支籤沒動到心態 → 這題心態範圍 -5 ~ 0
Q3(一般題):都沒動到心態 → 這題心態範圍 0 ~ 0
Q4(一般題):B 心態 +2,其他沒動 → 這題心態範圍 0 ~ 2
Q5(求籤題):凶籤 -5,平籤 +2 → 這題心態範圍 -5 ~ 2
把每題的最小值加起來,就是「心態」全題庫最慘會扣到幾分;每題的最大值加起來,就是最好能衝到幾分。
程式邏輯跟昨天算耗損率幾乎一模一樣,差別只在昨天是把整個選項的分數加總,今天是只挑出這個選項裡跟某個維度有關的那一格:
const contribution = opt.scores[dimension] || 0;
選項裡如果沒提到這個維度,就當作貢獻 0 分,不會影響範圍。
拿到範圍之後,換算公式跟昨天長得一模一樣:
const ratio = (score - min) / (max - min);
const normalized = Math.round(ratio * 100);
只是這次不是換算成「耗損率」,而是換算成「這個維度的表現分數」,0 分代表這個維度在整份題庫裡表現最差,100 分代表最好。
六個維度各跑一次這個公式,就能得到六個站在同一把尺上、可以互相比較的數字。
今天一樣先跳過其他畫面,把重點放在歸類這件事本身。用長條圖先把結果視覺化:
<!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>
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #e3f2fd;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.card {
background: white;
padding: 30px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%;
max-width: 520px;
text-align: center;
}
h2 { color: #1e88e5; margin: 0 0 15px; }
h3 { color: #263238; font-size: 1.1rem; line-height: 1.5; margin: 10px 0 20px; }
h4 { color: #263238; text-align: left; margin: 25px 0 10px; font-size: 1rem; }
.progress { font-size: 0.85rem; color: #1e88e5; font-weight: bold; }
.hidden { display: none; }
.option-btn {
background-color: #f5f5f5;
border: 2px solid transparent;
border-radius: 14px;
padding: 14px;
width: 100%;
margin-bottom: 12px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
font-size: 0.95rem;
color: #263238;
}
.option-btn:hover { border-color: #1e88e5; background-color: #e3f2fd; transform: scale(1.02); }
.draw-btn {
background-color: #d32f2f;
color: white;
border: none;
border-radius: 50px;
padding: 15px 40px;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
width: 100%;
box-shadow: 0 4px 15px rgba(211,47,47,0.4);
}
#draw-result { margin-top: 15px; font-weight: bold; color: #d32f2f; min-height: 45px; white-space: pre-line; }
.btn {
background-color: #1e88e5;
color: white;
border: none;
border-radius: 50px;
padding: 12px 30px;
font-size: 1rem;
cursor: pointer;
width: 100%;
margin-top: 10px;
transition: 0.2s;
}
.btn:hover { background-color: #1565c0; transform: translateY(-2px); }
.dim-row { text-align: left; margin-bottom: 16px; }
.dim-head {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
color: #263238;
margin-bottom: 4px;
}
.dim-head .raw { color: #90a4ae; font-size: 0.8rem; }
.dim-head .score { font-weight: bold; color: #1e88e5; }
.dim-track {
height: 14px;
background: #eceff1;
border-radius: 7px;
overflow: hidden;
}
.dim-fill {
height: 100%;
width: 0;
border-radius: 7px;
background: linear-gradient(90deg, #64b5f6, #1e88e5);
transition: width 0.8s ease;
}
.calc {
text-align: left;
background: #fdfdfd;
border: 2px dashed #90caf9;
border-radius: 12px;
padding: 12px 14px;
font-size: 0.8rem;
color: #546e7a;
line-height: 1.8;
margin-top: 15px;
}
.calc .row { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="card">
<h2>📐 多維度分數歸類器</h2>
<div id="quiz-area">
<span class="progress" id="progress"></span>
<h3 id="question"></h3>
<div id="options"></div>
<div id="draw-result"></div>
</div>
<div id="result-area" class="hidden">
<div id="dim-container"></div>
<div class="calc" id="calc"></div>
<button class="btn" onclick="restart()">再測一次</button>
</div>
</div>
<script>
const dimensions = ["日常", "效率", "社交", "心態", "放鬆", "職涯"];
const quizData = [
{ type: "normal", question: "早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:",
options: [
{ text: "A. 直接按掉,催眠自己教授今天會請假", scores: { "日常": -2, "放鬆": 3 } },
{ text: "B. 痛苦地爬起來,靠三杯黑咖啡續命", scores: { "效率": 3, "心態": -2 } },
{ text: "C. 傳訊息跟同學求救「幫點名」", scores: { "社交": 2, "心態": 1 } }
]},
{ type: "draw", question: "來到賽博廟宇,面對未知的死線,請誠心搖動籤筒抽出今日運勢:",
options: [
{ text: "【大吉】教授突然宣布延期交件!", scores: { "放鬆": 5 } },
{ text: "【中吉】剛好找到神級開源程式碼,無痛套用。", scores: { "效率": 4 } },
{ text: "【大凶】電腦無預警死機,而且你沒存檔。", scores: { "心態": -5 } }
]},
{ type: "normal", question: "突然多出一個小時空檔,你會怎麼做?",
options: [
{ text: "A. 趕快把今天的待辦事項處理掉", scores: { "效率": 3, "日常": 1 } },
{ text: "B. 躺著滑手機,享受難得的空檔", scores: { "放鬆": 3 } },
{ text: "C. 找朋友聊天", scores: { "社交": 3, "心態": 1 } }
]},
{ type: "normal", question: "遇到完全不會的問題時,你通常會?",
options: [
{ text: "A. 自己研究到搞懂", scores: { "效率": 2, "職涯": 3 } },
{ text: "B. 先放著,等心情好一點再處理", scores: { "放鬆": 2, "心態": 2 } },
{ text: "C. 找朋友或同學一起討論", scores: { "社交": 2, "職涯": 1 } }
]},
{ type: "draw", question: "最後一關,請再次把命運交給賽博籤筒!",
options: [
{ text: "【神籤】突然獲得神秘力量,今天效率爆表!", scores: { "效率": 5, "職涯": 2 } },
{ text: "【平籤】今天適合好好休息,明天再戰。", scores: { "放鬆": 4, "心態": 2 } },
{ text: "【凶籤】打開電腦發現昨天的程式全部消失。", scores: { "心態": -5, "日常": -2 } }
]}
];
let current = 0;
let userScores = {};
let locked = false;
function resetState() {
current = 0;
userScores = {};
dimensions.forEach(d => userScores[d] = 0);
}
function loadQuestion() {
const q = quizData[current];
const box = document.getElementById("options");
box.innerHTML = "";
document.getElementById("draw-result").innerText = "";
document.getElementById("progress").innerText = `Question ${current + 1} of ${quizData.length}`;
document.getElementById("question").innerText = q.question;
if (q.type === "draw") {
const btn = document.createElement("button");
btn.className = "draw-btn";
btn.innerText = "🎲 搖動籤筒";
btn.onclick = () => drawStick(q);
box.appendChild(btn);
} else {
q.options.forEach(opt => {
const btn = document.createElement("button");
btn.className = "option-btn";
btn.innerText = opt.text;
btn.onclick = () => choose(opt.scores);
box.appendChild(btn);
});
}
}
function drawStick(q) {
if (locked) return;
locked = true;
const opt = q.options[Math.floor(Math.random() * q.options.length)];
document.getElementById("draw-result").innerText = `鏘鏘!你抽到了:\n${opt.text}`;
setTimeout(() => { locked = false; choose(opt.scores); }, 1500);
}
function choose(scores) {
for (const dim in scores) userScores[dim] += scores[dim];
current++;
if (current < quizData.length) loadQuestion();
else finishQuiz();
}
function getDimensionRange(dimension) {
let min = 0, max = 0;
quizData.forEach(q => {
const contributions = q.options.map(opt => opt.scores[dimension] || 0);
min += Math.min(...contributions);
max += Math.max(...contributions);
});
return { min, max };
}
function normalize(score, min, max) {
if (max === min) return 50;
const ratio = (score - min) / (max - min);
const result = Math.round(ratio * 100);
return Math.max(0, Math.min(100, result));
}
function finishQuiz() {
document.getElementById("quiz-area").classList.add("hidden");
document.getElementById("result-area").classList.remove("hidden");
renderDimensions();
}
function renderDimensions() {
const container = document.getElementById("dim-container");
const calcLines = [];
container.innerHTML = "";
dimensions.forEach(dim => {
const { min, max } = getDimensionRange(dim);
const raw = userScores[dim];
const normalized = normalize(raw, min, max);
const row = document.createElement("div");
row.className = "dim-row";
row.innerHTML = `
<div class="dim-head">
<span>${dim} <span class="raw">(原始 ${raw} 分,範圍 ${min}~${max})</span></span>
<span class="score">${normalized}</span>
</div>
<div class="dim-track">
<div class="dim-fill" style="width:0%"></div>
</div>
`;
container.appendChild(row);
setTimeout(() => {
row.querySelector(".dim-fill").style.width = normalized + "%";
}, 50);
calcLines.push(
`<div class="row"><span>${dim}:(${raw} − ${min}) ÷ ${max - min}</span><span>${normalized} 分</span></div>`
);
});
document.getElementById("calc").innerHTML =
`<div style="font-weight:bold;margin-bottom:6px;">換算過程</div>` + calcLines.join("");
}
function restart() {
resetState();
document.getElementById("result-area").classList.add("hidden");
document.getElementById("quiz-area").classList.remove("hidden");
loadQuestion();
}
window.onload = () => {
resetState();
loadQuestion();
};
</script>
</body>
</html>
我存成 .html 打開,測完之後仔細比對一下「原始分數」跟旁邊的長條分數,你會發現原始分數明明差不多的兩個維度,換算完之後可能一個 80 分、一個只有 40 分。
1. 為什麼是 opt.scores[dimension] || 0,不是直接抓值?
因為不是每個選項都會動到每個維度。比如「日常」這個維度,題庫裡大概只有兩三個選項會提到它,其他選項的 scores 物件裡根本沒有 "日常" 這個 key。
如果直接寫 opt.scores[dimension],抓不到的時候會拿到 undefined,塞進 Math.min() 會變成 NaN,整個範圍就報銷了。加上 || 0 之後,抓不到就當作這個選項對這個維度沒有影響,範圍才會算得準。
2. max === min 這個維度是不是就沒救了?
理論上如果某個維度整份題庫完全沒被動到,min 跟 max 都會是 0,除法會爆掉。我讓它直接回傳 50 分,當作「沒資料,給個中間值」,而不是讓程式當機或印出 NaN。目前六個維度都至少被動到一次,所以這個分支現在還沒被真的觸發過,但保險還是要留著。
3. 這個歸類跟昨天算耗損率的邏輯,看起來很像,能不能共用?
其實可以。仔細看會發現,「算耗損率」根本就是「歸類」的其中一種特例:耗損率是把所有維度混在一起當成同一個大維度去算範圍,今天則是把每個維度分開各算各的範圍。核心的換算公式完全是同一段:
const ratio = (score - min) / (max - min);
const result = Math.round(ratio * 100);
我今天先把它拆開寫成兩個函式方便解釋,但之後重構的時候應該可以合併成一個共用「換算成百分比」的工具函式,兩邊呼叫方式不一樣而已。這個先記下來,之後會再處理重構。
4. 為什麼結果頁還特地把「換算過程」列出來?
單純顯示 80 分、40 分這種數字,看起來很像我隨便亂給的。把 (原始分 − min) ÷ 範圍 攤開來寫,至少讓人知道這個數字是怎麼跑出來的,不是憑空掉下來的分數。這個習慣是從昨天算耗損率延續下來的,我還滿喜歡的,之後應該會一直留著,除非到最後版面太擠了,我才會打算把它刪掉。
今天寫的時候,一直有種「這不就是昨天做過的事情嗎」的感覺。
後來想了想,對。歸類這件事本來就是同一套邏輯,重複套用了六次而已。 昨天處理的是「整個測驗」這一個大範圍,今天處理的是「六個維度」各自的小範圍,本質上都是同一件事:找出資料可能出現的極限,再把手上這一個值放進去比對。
這讓我第一次真的體會到,寫程式很多時候不是在想新招式,而是在發現同一招可以用在不同地方。
另外,今天的過程也讓我重新確認一件事:如果一開始把 scores 這個物件當成「每個維度各自獨立」在設計,後面不管是加總、算耗損率,還是像今天這樣拆開歸一化,都可以用同一套資料結構直接套公式,完全不用改動題庫本身。
回頭想想,Day 11 那時候糾結該把分數存成物件還是一堆變數,現在看來走對了路。
今天我們讓六個維度不再各憑本事比大小,而是各自掃過題庫、算出自己的範圍,換算成 0 到 100 分,站上同一把尺,之後不管想怎麼比較,都不用擔心某個維度是不是「天生分數比較高」。
尺準備好了,雷達圖也已經能正確地把這些分數畫出來。
不過這幾天埋頭處理分數跟圖表,我倒是把另一個角落冷落了好一陣子——那就是整間診所的音效。
現在點按鈕、搖籤筒、戳泡泡,用的其實都是差不多的合成邏輯,只是頻率跟時間長度稍微調一下而已。混在同一包程式碼裡久了,想單獨調整某一種音效的手感,常常要翻半天才找得到是哪一段在負責。
所以明天 Day 18,我們要停下來做被我忽略的事:
把點擊音、破裂音、求籤音,正式拆成各自獨立的函式,建立一套乾淨的音效分離架構。
明天,讓診所裡的每一種聲音,都有自己的家。我們明天見!