先老實招認一件事。
從我把結果頁刻出來的那天開始,畫面上就一直放著這一行:
<p>今日綜合腦內電池耗損率</p>
<div class="result-score" id="score-display">0%</div>
那個 0% 是我寫死的。
一開始 DAY 1 就有提到過,後來分數累加做好了、雷達圖畫出來了、處方籤也會開了,結果那個 耗損率 還是靜靜地躺在那邊,我沒有理它。
昨天病歷本做完,我又盯著 localStorage 裡的紀錄看,突然有點心虛。
日常 5 效率 8 社交 3 心態 -5 放鬆 9 職涯 4
這一筆,到底算累還是不累?
六個數字我自己都看不出來,更別說使用者了。而且 Day 1 我就把「腦內電池」寫進專題的初衷裡,做到第 16 天,診所居然沒有任何地方告訴你電池剩多少。
所以今天我們要
把那個假的 0%,換成真的算出來的數字。
最直覺的想法是:把六個維度全部加起來,總分越低越累。
但我寫到一半就卡住了。
假設有人總分是 14,這樣算累嗎?
同一個分數,放在不同的範圍裡,意思完全不一樣。
所以要算耗損率,得先知道這份題庫「最慘」和「最爽」分別是幾分。
我們的題庫每題都有三個選項,每個選項背後的 scores 有加有扣。要算某一題的範圍,就把每個選項的分數先加總,再挑最小和最大的:
Q1:A = 1、B = 1、C = 3 → 範圍 1 ~ 3
Q2:大吉 = 5、中吉 = 4、大凶 = -5 → 範圍 -5 ~ 5
Q3:A = 4、B = 3、C = 4 → 範圍 3 ~ 4
Q4:A = 5、B = 4、C = 3 → 範圍 3 ~ 5
Q5:神籤 = 7、平籤 = 6、凶籤 = -7 → 範圍 -7 ~ 7
把每題的最小值加起來,就是「整份測驗最慘的總分」;最大值加起來,就是「最爽的總分」:
最慘:1 + (-5) + 3 + 3 + (-7) = -5
最爽:3 + 5 + 4 + 5 + 7 = 24
也就是說,目前這份題庫的分數範圍是 -5 ~ 24。
有了這兩個端點,就能把任何總分換成比例:
比例 = (你的總分 - min) / (max - min) // 越接近 1 越爽
耗損率 = (1 - 比例) × 100
用一個實際的例子:如果某人總分是 14
比例 = (14 - (-5)) / (24 - (-5)) = 19 / 29 ≈ 0.655
耗損率 = (1 - 0.655) × 100 ≈ 34%
總分拿到 -5 就是耗損 100%(直接關機),拿到 24 就是 0%(電力滿格)。
這裡我特別留意一件事:min 和 max 我沒有手動寫死,是讓程式自己掃題庫算出來的。 之後題目從 5 題擴充到 10 題,或是調整某個選項的分數,這套公式完全不用改。
另外求籤題也不用特別處理。不管最後會隨機抽到哪一支籤,它的範圍都已經包含在「最小到最大」裡面了。
今天一樣抽離其他介面,只保留計算的核心。題庫直接用專題裡真實的 5 題(包含兩題求籤),這樣算出來的數字才是「這個診所真的會出現的數字」。
我還做了一件事:把每一題的得分記下來(answerLog),結果頁最下面會顯示計算過程,讓數字看起來不是憑空變出來的。
<!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: 500px;
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); }
.ghost { background-color: #90a4ae; font-size: 0.85rem; padding: 9px; }
.ghost:hover { background-color: #78909c; }
/* 原本那個寫死 0% 的位置 */
.result-label { color: #546e7a; font-size: 0.95rem; margin: 0; }
.result-score { font-size: 3rem; font-weight: bold; color: #1e88e5; margin: 5px 0 10px; }
.battery {
width: 100%;
height: 26px;
border: 3px solid #263238;
border-radius: 10px;
background: #eceff1;
overflow: hidden;
margin-bottom: 15px;
}
.battery-fill {
height: 100%;
width: 0;
transition: width 1s ease, background-color 1s ease;
}
.status {
background: #e3f2fd;
border-radius: 14px;
padding: 14px;
font-weight: bold;
color: #1e88e5;
margin-bottom: 15px;
}
/* 計算過程 */
.calc {
text-align: left;
background: #fdfdfd;
border: 2px dashed #90caf9;
border-radius: 12px;
padding: 12px 14px;
font-size: 0.85rem;
color: #263238;
line-height: 1.8;
}
.calc .row { display: flex; justify-content: space-between; }
.calc .sum { border-top: 1px dashed #90caf9; margin-top: 6px; padding-top: 6px; font-weight: bold; }
#history { list-style: none; padding: 0; margin: 0; text-align: left; }
#history li {
background: #fdfdfd;
border: 2px dashed #90caf9;
border-radius: 12px;
padding: 10px 14px;
margin-bottom: 8px;
font-size: 0.85rem;
color: #263238;
line-height: 1.6;
}
#history .time { color: #546e7a; font-size: 0.8rem; }
#history .drain { color: #d32f2f; font-weight: bold; }
.empty { color: #90a4ae; text-align: center; }
</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">
<p class="result-label">今日綜合腦內電池耗損率</p>
<div class="result-score" id="score-display">0%</div>
<div class="battery"><div class="battery-fill" id="fill"></div></div>
<div class="status" id="status"></div>
<div class="calc" id="calc"></div>
<button class="btn" onclick="restart()">再測一次</button>
</div>
<h4>📜 歷史紀錄(最多 5 筆)</h4>
<ul id="history"></ul>
<button class="btn ghost" onclick="clearHistory()">清空病歷</button>
</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 } }
]}
];
const STORAGE_KEY = "cyberClinicHistory"; // 跟 Day 15 同一本病歷
const MAX_RECORDS = 5;
let current = 0;
let userScores = {};
let answerLog = []; // 每一題實際拿到的總分
let locked = false; // 抽籤停頓期間,避免重複點擊
function resetState() {
current = 0;
answerLog = [];
userScores = {};
dimensions.forEach(d => userScores[d] = 0);
}
// 把一個 scores 物件裡的數字全部加起來
function sumScores(scores) {
return Object.values(scores).reduce((a, b) => a + b, 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];
answerLog.push(sumScores(scores));
current++;
if (current < quizData.length) loadQuestion();
else finishQuiz();
}
// ---------- 今天的重點:耗損率演算法 ----------
// 步驟 1:每一題各自算出「最慘」與「最爽」
function getQuestionRanges() {
return quizData.map(q => {
const totals = q.options.map(opt => sumScores(opt.scores));
return { min: Math.min(...totals), max: Math.max(...totals) };
});
}
// 步驟 2:把總分換算成 0~100 的耗損率
function calcDrain(total, min, max) {
if (max === min) return 50; // 避免除以 0
const ratio = (total - min) / (max - min); // 越接近 1 越爽
const drain = Math.round((1 - ratio) * 100);
return Math.max(0, Math.min(100, drain)); // 夾在 0~100
}
function getStatus(drain) {
if (drain <= 30) return "🟢 電量充足:今天的你狀態不錯,可以放心衝。";
if (drain <= 60) return "🟡 省電模式:還撐得住,但別再亂接新任務了。";
if (drain <= 85) return "🟠 低電量警告:建議立刻找地方充電,手搖飲算充電。";
return "🔴 即將關機:請放下手邊所有事情,現在。";
}
function finishQuiz() {
const ranges = getQuestionRanges();
const min = ranges.reduce((s, r) => s + r.min, 0);
const max = ranges.reduce((s, r) => s + r.max, 0);
const total = answerLog.reduce((s, n) => s + n, 0);
const drain = calcDrain(total, min, max);
saveRecord(drain);
showResult(drain, total, min, max, ranges);
renderHistory();
}
// ---------- 顯示結果 ----------
function showResult(drain, total, min, max, ranges) {
document.getElementById("quiz-area").classList.add("hidden");
document.getElementById("result-area").classList.remove("hidden");
// 電池條顯示「剩餘電量」,越累越短、顏色越紅
const fill = document.getElementById("fill");
fill.style.width = "0%";
setTimeout(() => {
fill.style.width = (100 - drain) + "%";
fill.style.backgroundColor = `hsl(${120 * (1 - drain / 100)}, 70%, 45%)`;
}, 50);
countUp(drain);
document.getElementById("status").innerText = getStatus(drain);
// 計算過程:讓數字有跡可循
const rows = answerLog.map((score, i) =>
`<div class="row"><span>Q${i + 1} 得 ${score} 分</span><span>範圍 ${ranges[i].min} ~ ${ranges[i].max}</span></div>`
).join("");
document.getElementById("calc").innerHTML = rows +
`<div class="row sum"><span>總分 ${total}</span><span>整份範圍 ${min} ~ ${max}</span></div>` +
`<div class="row"><span>耗損率 = (1 − (${total} − ${min}) ÷ ${max - min}) × 100</span><span>${drain}%</span></div>`;
}
// 數字跟著電池一起跑(Day 13 的緩動公式)
function countUp(target) {
const el = document.getElementById("score-display");
const start = performance.now();
const duration = 1000;
function step(now) {
const t = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
el.innerText = Math.round(target * eased) + "%";
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function restart() {
resetState();
document.getElementById("result-area").classList.add("hidden");
document.getElementById("quiz-area").classList.remove("hidden");
loadQuestion();
}
// ---------- 病歷本(Day 15 的內容,多存一個 drain 欄位) ----------
function loadHistory() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveRecord(drain) {
const weakest = Object.entries(userScores)
.reduce((min, cur) => cur[1] < min[1] ? cur : min)[0];
const record = {
time: new Date().toLocaleString("zh-TW", { hour12: false }),
scores: { ...userScores },
weakest: weakest,
drain: drain // 今天新增的欄位
};
const history = loadHistory();
history.unshift(record);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, MAX_RECORDS)));
} catch (e) {
console.warn("病歷存檔失敗:", e);
}
}
function clearHistory() {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
renderHistory();
}
function renderHistory() {
const list = document.getElementById("history");
const history = loadHistory();
if (history.length === 0) {
list.innerHTML = '<li class="empty">病歷本是空的</li>';
return;
}
list.innerHTML = history.map(rec => {
// Day 15 存的舊病歷沒有 drain,要顯示「—」而不是 undefined
const drainText = typeof rec.drain === "number" ? rec.drain + "%" : "—";
return `
<li>
<div class="time">🕒 ${rec.time}</div>
<div>耗損率:<span class="drain">${drainText}</span> 最缺:${rec.weakest}</div>
</li>`;
}).join("");
}
window.onload = () => {
resetState();
loadQuestion();
renderHistory();
};
</script>
</body>
</html>
存成 .html 打開,先認真答一次,再故意挑最累的選項答一次,看電池會掉到哪裡。求籤那兩題是隨機的,多玩幾輪,你會發現同樣的選擇也可能算出不一樣的數字,這也是我很喜歡的地方。
1. 為什麼要一題一題算範圍,不直接加總全部?
一開始我想偷懶,把所有選項的分數丟進同一個陣列,直接抓最小最大。結果算出來的 max 是「所有選項裡最大的那一個」,跟「每題各挑最好的加起來」根本是兩回事,比例整個歪掉。
正確的邏輯是:每一題只能選一個選項,所以要先各自算出「這題最慘、最爽」,再把每題的結果加起來。
2. 除以零,還有那個 Math.max(0, Math.min(100, drain))。
(max - min) 如果剛好是 0,整個式子會變成 NaN。現實中不太會發生,但一行 if 就能預防。
另外我也把最後結果夾在 0 到 100 之間。萬一哪天有人改分數、改出一個超出範圍的數字,畫面上至少不會出現「耗損率 -12%」的烏龍事件kkk。
3. 昨天存的舊病歷,今天讀出來會怎樣?
昨天的紀錄裡沒有 drain 這個欄位,如果直接 rec.drain + "%",畫面上會印出很尷尬的 undefined%。所以顯示前要先判斷 typeof rec.drain === "number",沒有的話就顯示「—」。
這是我第一次真的體會到:新增欄位很輕鬆,但舊資料永遠留在瀏覽器裡等自己。
4. 電池條顯示的是「剩餘電量」,數字寫的卻是「耗損率」。
我一開始兩個都用耗損率去畫,結果耗損 90% 的人看到的是一條快要全滿的長條,直覺上反而像電量很足。改成用 100 - drain 去畫寬度之後,才符合電池的直覺:越累,條越短,顏色越紅。
5. 為什麼要特別記 answerLog?
一開始只是想在結果頁顯示計算過程,讓數字看起來不是亂變出來的。做完才發現它還有一個額外的好處:現在我知道每一題各自貢獻了多少分。
這代表之後我可以找出「哪一題讓電池掉最多」。這個想法有點開心,得先留著。
寫今天這篇之前,我以為「演算法」這三個字會很可怕。
結果寫完才發現,核心其實就三行:
const ratio = (total - min) / (max - min);
const drain = Math.round((1 - ratio) * 100);
return Math.max(0, Math.min(100, drain));
真正讓我卡最久的,反而不是程式,而是**「到底什麼叫累」,且該怎麼定義**。
我現在是把六個維度的分數直接加總,代表我預設「六個維度一樣重要」。但實際上,也許「心態」低的人,比「職涯」低的人更需要被關心?我不知道。
所以我必須誠實說:這個公式是我自己訂的,不是什麼心理學上的科學指標。 它只是一個讓數字有意義的方法,目的是讓每次測驗的結果可以互相比較,而不是真的診斷誰的健康狀況。
也因為這樣,我刻意把「算範圍」、「算耗損率」拆成兩個獨立的函式。哪天想改成加權,例如心態算 1.5 倍,只要動加總的地方就好,換算公式完全不用碰。
還有一件事我覺得很有趣。
同樣是那六個數字,之前看起來像一份成績單,現在又多了一句「耗損 34%」,整個人好像就被理解了。因為資訊變多,也因為資訊被壓縮成一句人話。
今天我們終於把那個從 Day 1 掛在結果頁的電池耗損率,換成了真的用題庫算出來的數字。做法很單純:算出每題的最小與最大分數、加總、換算成百分比,還把計算過程直接攤在畫面上。
不過寫完之後,又發現了一個蠻重要的問題。
現在算耗損率,我是把六個維度的分數全部混在一起加總。但仔細看題庫會發現,有的維度分數本來就比較容易拿到高分,有的維度整份題庫根本沒幾個加分的選項。
換句話說,六個維度的「範圍」其實不一樣長。
這在之前的文章裡面也提過很多次!
所以明天 Day 17,我們要來做「多維度分數歸類」:把日常、效率、社交、心態、放鬆、職涯,各自換算成 0 到 100 的分數,放在同一把尺上比較。
尺準備好了,分數歸類就出場啦,我們明天見!