iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

Day 23:雷達圖 RWD 與動態字體對應:適應日夜模式與多語系標籤

  • 分享至 

  • xImage
  •  

前言

上一篇總結寫到一半,我手癢把語言切換器拿出來重新戳了幾輪,想說順便檢查一下有沒有漏網之魚。

結果抓到兩個問題,但仔細一查才發現事情比我想的嚴重。

第一個,是雷達圖下面那排維度說明文字。測驗做完,畫面上除了那張六角形的圖之外,下面還會列一排:

日常 72 效率 45 社交 90 心態 30 放鬆 88 職涯 50

這排字是測驗結束之後才由 JavaScript 動態組出來、塞進畫面的,跟 Day 19 做的首頁靜態文字完全是兩回事。而且更麻煩的是,「日常」「效率」這些維度名稱,本身就是資料的 key,從 Day 11 開始就是中文字串,直接存在 userScores 物件裡。切到英文模式,這排字照樣原封不動印出一串中文,比雷達圖上畫的標籤還要明顯。

我原本以為只有這排字有問題,結果順著檢查下去,才發現根本不只這一處:抽籤結果的提示文字、電池耗損率的狀態說明(「🟢 電量充足」那幾句)、病歷本列表裡的「缺乏:○○」,全部都是測驗跑到一半或結束後才動態產生的文字,沒有一個接上語言表。之前修 i18n 的時候,我只顧著處理頁面一載入就存在的固定文字,完全漏掉了這整批「之後才冒出來」的內容。

所以今天的範圍要比原本想的大很多,不只是雷達圖:

把所有還沒接上語言系統的動態文字,一次補齊,順便把日夜模式初始狀態又跑掉的老問題,這次真正修乾淨。


核心觀念:維度名稱本身也要能翻譯,不能只是查表填空

Day 20 教會我一件事:動態文字要用「查表」,不能用「一次性填表」。但這次又多了一層麻煩。

之前查表的情境,是 key 固定(像 breathIn、breathOut),不同語言去查對應的翻譯就好。但維度名稱不一樣——「日常」這個字本身,同時是資料的 key,也是要顯示給人看的文字。

如果直接把 userScores 的 key 改成英文,整個計分系統、病歷本存檔格式全部都要跟著動,風險太大。所以正確做法是:保留 userScores 用中文當 key 不動,另外準備一張「維度名稱翻譯表」,顯示的時候用中文 key 去查,查出來的才是使用者看到的文字:

const dimensionNames = {
    "日常": { zh: "日常", en: "Daily", ja: "日常", ko: "일상" },
    "效率": { zh: "效率", en: "Efficiency", ja: "効率", ko: "효율" },
    "社交": { zh: "社交", en: "Social", ja: "社交", ko: "사교" },
    "心態": { zh: "心態", en: "Mindset", ja: "メンタル", ko: "멘탈" },
    "放鬆": { zh: "放鬆", en: "Relax", ja: "リラックス", ko: "휴식" },
    "職涯": { zh: "職涯", en: "Career", ja: "キャリア", ko: "커리어" }
};

渲染的時候,用內部的中文 key 去查表拿顯示文字:

const displayName = dimensionNames[dim][currentLang];

這樣資料結構完全不用動,只是多了一層「內部名字」跟「顯示名字」的轉換。

而所有動態產生的區塊,共通的修法都一樣:不要把渲染邏輯跟語言綁死,而是把渲染包成一個函式,語言切換的時候重新呼叫一次。

function applyLanguage(lang) {
    currentLang = lang;
    fillStaticText(lang);      // 固定文字:一次性填表
    renderRadarLegend();       // 動態文字:重新查表渲染
    renderStatus();
    renderHistory();
}

這樣不管使用者是在測驗途中、結果頁、還是病歷本畫面切換語言,畫面都會用當下的語言重新畫一次,不會有任何一塊卡在舊語言裡。


核心觀念之二:日夜模式的初始狀態,要跟系統設定「同時」決定

另一個問題是舊病重犯。之前的寫法大概是這樣:

let isDark = false; // 預設亮色

然後切換按鈕的文字也寫死顯示「亮色」。但如果使用者的系統本身就是暗色模式,瀏覽器會先套用 prefers-color-scheme: dark,頁面背景確實變暗了,可是因為 JavaScript 那邊 isDark 還是寫死的 false,切換按鈕仍然顯示「☀️ 亮色」,兩邊狀態從一開始就是分裂的。

正確做法是:一開始就去問瀏覽器「使用者系統現在是什麼模式」,然後讓 JavaScript 的狀態、畫面的 data-theme、按鈕的文字,三個東西同一時間一起決定,而不是各自寫死。

const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
let isDark = prefersDark;

初始化的時候,用這個 isDark 去同步設定畫面跟按鈕,而不是分開寫。


動手寫 Code:把漏掉的地方一次補齊

今天的示範把測驗結果頁、電池狀態、日夜模式初始化全部接起來,示範「動態文字查表」跟「初始主題同步」這兩件事:

<!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>
    :root {
        --bg: #e3f2fd; --card: #ffffff; --primary: #1e88e5; --text: #263238; --muted: #546e7a;
    }
    [data-theme="dark"] {
        --bg: #0d1117; --card: #161b22; --primary: #4ade80; --text: #e6edf3; --muted: #8b949e;
    }
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: var(--bg); color: var(--text);
        display: flex; justify-content: center; align-items: center;
        min-height: 100vh; margin: 0; padding: 20px;
        transition: background 0.3s, color 0.3s;
    }
    .card {
        background: var(--card); padding: 25px; border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.2); width: 100%; max-width: 480px;
    }
    .top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
    .lang-switch { display: flex; gap: 6px; }
    .lang-btn, .theme-btn {
        background: var(--bg); border: 2px solid transparent; border-radius: 50px;
        padding: 6px 12px; font-size: 0.8rem; cursor: pointer; color: var(--text); transition: 0.2s;
    }
    .lang-btn.active { background: var(--primary); color: white; border-color: var(--primary); }

    h2 { color: var(--primary); margin: 0 0 15px; text-align: center; }
    canvas { display: block; width: 100%; max-width: 300px; height: auto; margin: 0 auto 10px; }

    #legend { list-style: none; padding: 0; margin: 0 0 15px; }
    #legend li {
        display: flex; justify-content: space-between;
        padding: 6px 10px; font-size: 0.88rem; border-bottom: 1px dashed var(--muted);
    }
    #legend b { color: var(--primary); }

    .status-box {
        background: var(--bg); border-radius: 12px; padding: 12px;
        font-size: 0.9rem; font-weight: bold; color: var(--primary); margin-bottom: 10px;
    }

    .btn {
        background: var(--primary); color: white; border: none; border-radius: 50px;
        padding: 10px 24px; font-size: 0.9rem; cursor: pointer; width: 100%; margin-top: 6px;
    }
</style>
</head>
<body>

    <div class="card">
        <div class="top-bar">
            <div class="lang-switch">
                <button class="lang-btn" data-lang="zh">中</button>
                <button class="lang-btn" data-lang="en">EN</button>
                <button class="lang-btn" data-lang="ja">日</button>
                <button class="lang-btn" data-lang="ko">한</button>
            </div>
            <button class="theme-btn" id="themeBtn"></button>
        </div>

        <h2 data-i18n="resultTitle"></h2>

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

        <!-- 今天的重點:這排是測驗結束後才動態產生的,之前完全沒接上語言系統 -->
        <ul id="legend"></ul>
        <div class="status-box" id="statusBox"></div>

        <button class="btn" onclick="reroll()" data-i18n="rerollBtn"></button>
    </div>

    <script>
        // 維度分數照舊用中文當 key,不動它,純粹拿來當內部資料
        let userScores = { "日常": 72, "效率": 45, "社交": 90, "心態": 30, "放鬆": 88, "職涯": 50 };

        // 固定文字
        const i18n = {
            resultTitle: { zh: "🏥 診斷結果", en: "🏥 Diagnosis", ja: "🏥 診断結果", ko: "🏥 진단 결과" },
            rerollBtn:   { zh: "重新產生一組分數", en: "Reroll scores", ja: "スコアを再生成", ko: "점수 다시 뽑기" },
            themeLight:  { zh: "☀️ 亮色", en: "☀️ Light", ja: "☀️ ライト", ko: "☀️ 라이트" },
            themeDark:   { zh: "🌙 暗色", en: "🌙 Dark", ja: "🌙 ダーク", ko: "🌙 다크" }
        };

        // 維度名稱翻譯表:中文 key → 四種語言的顯示文字
        const dimensionNames = {
            "日常": { zh: "日常", en: "Daily",      ja: "日常",      ko: "일상" },
            "效率": { zh: "效率", en: "Efficiency", ja: "効率",      ko: "효율" },
            "社交": { zh: "社交", en: "Social",     ja: "社交",      ko: "사교" },
            "心態": { zh: "心態", en: "Mindset",    ja: "メンタル",   ko: "멘탈" },
            "放鬆": { zh: "放鬆", en: "Relax",      ja: "リラックス", ko: "휴식" },
            "職涯": { zh: "職涯", en: "Career",     ja: "キャリア",   ko: "커리어" }
        };

        let currentLang = "zh";

        function fillStaticText(lang) {
            document.querySelectorAll("[data-i18n]").forEach(el => {
                const entry = i18n[el.dataset.i18n];
                if (entry && entry[lang]) el.innerText = entry[lang];
            });
        }

        function renderLegend() {
            const list = document.getElementById("legend");
            list.innerHTML = Object.entries(userScores).map(([dim, val]) => {
                const name = dimensionNames[dim][currentLang];
                return `<li><span>${name}</span><b>${val}</b></li>`;
            }).join("");
        }

        function getAvgDrain() {
            const values = Object.values(userScores);
            const avg = values.reduce((a, b) => a + b, 0) / values.length;
            return Math.round(100 - avg);
        }

        function renderStatus() {
            const drain = getAvgDrain();
            const level = drain <= 30 ? "statusGood" : drain <= 60 ? "statusMid" : drain <= 85 ? "statusLow" : "statusCritical";
            const statusText = {
                statusGood:     { zh: "🟢 電量充足,今天狀態不錯。", en: "🟢 Battery full, you're doing great.", ja: "🟢 バッテリー十分、今日は調子がいい。", ko: "🟢 배터리 충분, 컨디션 좋아요." },
                statusMid:      { zh: "🟡 省電模式,別再接新任務了。", en: "🟡 Power saving mode, no new tasks please.", ja: "🟡 省電力モード、新しいタスクはNG。", ko: "🟡 절전 모드, 새 일 받지 마세요." },
                statusLow:      { zh: "🟠 低電量警告,快去充電。", en: "🟠 Low battery warning, go recharge.", ja: "🟠 バッテリー低下、充電してください。", ko: "🟠 배터리 부족, 충전하세요." },
                statusCritical: { zh: "🔴 即將關機,放下手邊的事。", en: "🔴 About to shut down, stop everything.", ja: "🔴 シャットダウン寸前、手を止めて。", ko: "🔴 종료 직전, 하던 일 멈추세요." }
            };
            document.getElementById("statusBox").innerText = statusText[level][currentLang];
        }

        function applyLanguage(lang) {
            currentLang = lang;
            fillStaticText(lang);
            renderLegend();
            renderStatus();
            applyThemeLabel();
            document.querySelectorAll(".lang-btn").forEach(b => b.classList.toggle("active", b.dataset.lang === lang));
        }

        document.querySelectorAll(".lang-btn").forEach(btn => {
            btn.addEventListener("click", () => applyLanguage(btn.dataset.lang));
        });

        function reroll() {
            Object.keys(userScores).forEach(dim => userScores[dim] = Math.floor(Math.random() * 101));
            renderLegend();
            renderStatus();
            drawRadar();
        }

        const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        let isDark = prefersDark;

        function applyTheme() {
            if (isDark) document.documentElement.setAttribute("data-theme", "dark");
            else document.documentElement.removeAttribute("data-theme");
        }

        function applyThemeLabel() {
            document.getElementById("themeBtn").innerText =
                isDark ? i18n.themeDark[currentLang] : i18n.themeLight[currentLang];
        }

        document.getElementById("themeBtn").addEventListener("click", () => {
            isDark = !isDark;
            applyTheme();
            applyThemeLabel();
            drawRadar();
        });

        function drawRadar() {
            const canvas = document.getElementById("radarChart");
            const ctx = canvas.getContext("2d");
            const dims = Object.keys(userScores);
            const values = Object.values(userScores);
            const cx = canvas.width / 2, cy = canvas.height / 2, radius = 100, total = dims.length;

            ctx.clearRect(0, 0, canvas.width, canvas.height);
            const lineColor = isDark ? "#30363d" : "#cfd8dc";
            const fillColor = isDark ? "#4ade80" : "#1e88e5";

            ctx.strokeStyle = lineColor;
            for (let level = 1; level <= 4; level++) {
                ctx.beginPath();
                for (let i = 0; i < total; i++) {
                    const a = -Math.PI / 2 + (Math.PI * 2 * i) / total;
                    const r = radius * level / 4;
                    const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
                    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
                }
                ctx.closePath();
                ctx.stroke();
            }

            ctx.beginPath();
            values.forEach((v, i) => {
                const a = -Math.PI / 2 + (Math.PI * 2 * i) / total;
                const r = radius * v / 100;
                const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
                i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
            });
            ctx.closePath();
            ctx.fillStyle = fillColor + "33";
            ctx.fill();
            ctx.strokeStyle = fillColor;
            ctx.lineWidth = 2;
            ctx.stroke();
        }

        applyTheme();
        applyLanguage("zh");
        drawRadar();
    </script>
</body>
</html>

再存成 .html 打開,可以先看一下主題按鈕有沒有跟你電腦系統目前的明暗模式對上,再切幾次語言,雷達圖下面那排維度名稱跟電池狀態說明會立刻跟著換,不會再停在中文。


幾個我踩過的小細節

  • 維度名稱另外做翻譯表,不直接改 userScores 的 key
    userScores 的 key 從 Day 11 就被計分、歸一化、localStorage 存檔一路沿用到現在,改 key 連動範圍太大,而且舊存檔的 key 還是中文,新舊資料會對不起來。讓資料層不動,只在顯示那一層多包一層翻譯最安全。

  • renderLegend()、renderStatus() 要在 applyLanguage() 裡重新呼叫
    這其實就是 Day 20「查表」邏輯的延伸。語言一變,直接重新跑一次渲染,不用額外記什麼特殊狀態,比呼吸過場那個案例單純多了。

  • prefersDark 只在載入時抓一次,使用者中途改系統設定不會自動跟著變
    今天只處理「進站當下對不上」這個最明顯的問題。如果要做到「使用中途系統切成暗色、網頁自動跟著變」,還要加 change 事件監聽,先留到之後有需要再補。

  • 主題按鈕的文字也要走 i18n
    原本「☀️ 亮色」「🌙 暗色」是寫死中文,跟雷達圖標籤是同一種病。所以 applyThemeLabel() 也接進語言表,確保不管哪個語言、哪個主題,按鈕上的字都是對的。


開發心得與設計考量

  • 「做完」跟「做完整」是兩回事
    首頁文字切語言很順利,我就以為 i18n 已經收尾了,其實只處理了頁面一載入就存在的文字,完全沒想到後面陸續加的功能——電池狀態、雷達圖圖例、病歷本列表——每一個都是測驗過程中動態冒出來的,寫的當下竟然沒想到要接回語言系統,實在太粗心大意了T.T

  • 之後的習慣:動態塞文字進畫面,第一件事就先想語言表
    只要是會動態產生 DOM 的地方,很容易悄悄漏接 i18n。之後加新功能,我大概會養成「先想這段文字怎麼接語言表,再動手寫」的順序,而不是等到最後才回頭補。

  • 日夜模式的 bug 也是同一種教訓
    第一次修的時候只處理了「點擊按鈕之後」的狀態切換,沒處理「頁面剛載入那一瞬間」該是什麼狀態,等於只修了一半。這次把初始化也接上 prefers-color-scheme,系統設定、畫面主題、按鈕文字三個狀態終於真正綁在一起。


今日結語與明日預告

今天我們把所有漏接語言系統的動態文字一次補齊,包括雷達圖下方的維度圖例、電池狀態說明、日夜模式切換按鈕的文字,還順手把日夜模式初始狀態跟系統設定對不上的老問題也真的修好,讓整間診所不管用哪種語言、哪種明暗模式打開,看起來都是完整的一致狀態,不會再有露出破綻的半成品。

結果頁現在總算乾淨又完整了,但盯著那張診斷報告看久了,還是有東西可以加。

我想讓這個專題再更有趣一點。

真正的診所,看完診不是應該要蓋個章嗎?

既然都叫「賽博紓壓診所」了,病歷上怎麼能沒有醫生那種用力一蓋、有點歪、還帶點生鏽感的戳章?

所以明天 Day 24,我們要用 CSS 3D 變形,做一個惡搞病歷戳章動畫:讓一枚章從畫面上方重重落下,帶點旋轉跟晃動,蓋在診斷結果上,補上那股真實診所的儀式感。

明天,我要讓每一份病歷都蓋上官方認證的爛章!


上一篇
Day 22:第二階段總結——這十四天到底蓋出了什麼!
下一篇
Day 24:惡搞病歷戳章動畫:用 CSS 3D 變形與落下特效模擬真實診所
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言