iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

Day 16:那個一直卡在 0% 的電池,今天終於要動了

  • 分享至 

  • xImage
  •  

前言

先老實招認一件事。

從我把結果頁刻出來的那天開始,畫面上就一直放著這一行:

<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,這樣算累嗎?

  • 如果題庫最高只能拿 15,他幾乎滿血
  • 如果題庫最高能拿 50,他其實才剛過半

同一個分數,放在不同的範圍裡,意思完全不一樣。

所以要算耗損率,得先知道這份題庫「最慘」和「最爽」分別是幾分。

我們的題庫每題都有三個選項,每個選項背後的 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 題,或是調整某個選項的分數,這套公式完全不用改。

另外求籤題也不用特別處理。不管最後會隨機抽到哪一支籤,它的範圍都已經包含在「最小到最大」裡面了。


動手寫 Code:讓 0% 變成真的

今天一樣抽離其他介面,只保留計算的核心。題庫直接用專題裡真實的 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 的分數,放在同一把尺上比較。

尺準備好了,分數歸類就出場啦,我們明天見!


上一篇
Day 15:重新整理就失憶?讓診所學會「記住你」
下一篇
Day 17:每個維度都要站在同一把尺上
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言