iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 17

Day 17 — 學習儀表板:把 localStorage 變成圖

  • 分享至 

  • xImage
  •  

今天要解的問題

現在的資料都在,但只以最原始的形式呈現:一條頂欄百分比、幾個進度環。我看不到:

  • 我這三個月哪幾天有讀書?(時間分布
  • 我在六個類別上的投入是否平衡?(類別分布
  • 我到底累積投入了多少時間?(總量

而且有個更根本的問題:我的學習紀錄鎖在瀏覽器裡,換裝置就沒了。 今天先解決「使用者資料主權」(匯出/匯入),再把資料畫成圖。

順帶說明:全部圖表手寫 SVG,不裝 Chart.js(180 KB)也不裝 D3(Day 1 的無依賴原則)。三張圖加起來約 150 行。

先做匯出/匯入

這件事比圖表重要,而且應該在做雲端同步(Day 27)之前就有——使用者必須隨時能把自己的資料帶走。

/* js/models/backup.js */
const Backup = {
  SCHEMA: 1,

  export() {
    return {
      schema: this.SCHEMA,
      exportedAt: new Date().toISOString(),
      progress: Progress.load(),
      gamify:   Gamify.load(),
      exams:    Exam.loadAll(),
      theme:    Theme.read(),
    };
  },

  download() {
    const data = JSON.stringify(this.export(), null, 2);
    const blob = new Blob([data], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `learnpath-backup-${new Date().toISOString().slice(0, 10)}.json`;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 1000);
  },

匯入是危險操作,所以要合併而不是覆蓋

  /* mode: "merge"(預設,安全)| "replace"(明確要求才做) */
  import(raw, mode = "merge") {
    let d;
    try { d = JSON.parse(raw); } catch { return { ok: false, err: "不是有效的 JSON" }; }
    if (!d || typeof d !== "object") return { ok: false, err: "格式不符" };
    if (d.schema !== this.SCHEMA)   return { ok: false, err: `版本不符(檔案 v${d.schema},本站 v${this.SCHEMA})` };

    const isFlagMap = o => o && typeof o === "object" && !Array.isArray(o)
      && Object.values(o).every(v => v === true);
    if (!isFlagMap(d.progress)) return { ok: false, err: "progress 欄位格式不符" };

    /* 只採用課綱裡真實存在的 key——過濾掉舊版或偽造的資料 */
    const valid = {};
    let dropped = 0;
    for (const k of Object.keys(d.progress)) {
      if (window.LESSONS && window.LESSONS[k]) valid[k] = true; else dropped++;
    }

    const before = Progress.doneCount();
    const merged = mode === "replace" ? valid : { ...Progress.load(), ...valid };
    Progress.save(merged);

    /* 遊戲化:XP 取較大值,streak 取較大值,徽章與已答題目取聯集 */
    if (d.gamify && typeof d.gamify === "object") {
      const cur = Gamify.load();
      Gamify.save({
        xp:         Math.max(cur.xp, Number(d.gamify.xp) || 0),
        streakDays: Math.max(cur.streakDays, Number(d.gamify.streakDays) || 0),
        lastDay:    cur.lastDay || d.gamify.lastDay || null,
        quizzes: { ...cur.quizzes, ...(d.gamify.quizzes || {}) },
        lessons: { ...cur.lessons, ...(d.gamify.lessons || {}) },
        badges:  { ...cur.badges,  ...(d.gamify.badges  || {}) },
      });
    }

    return { ok: true, added: Progress.doneCount() - before, dropped, mode };
  },
};

四個設計決定:

1. schema 版本號。 跟 Day 5 的 localStorage key 版本號同一個道理。沒有版本號的資料格式是無法演進的。

2. 預設 merge 不 replace。 匯入的最壞情況是「使用者不小心用舊備份蓋掉新進度」。merge 讓最壞情況只是「多了幾課」,而不是「掉了三個月的紀錄」。

3. 過濾不存在的 key。 備份檔可能來自舊版(課程改名過)或被手動編輯。只採用課綱裡真的有的課,並回報丟棄了幾筆——匯入不是信任,是驗證後採用。這也是 Day 9 那條原則的延伸。

4. 遊戲化用「取較大值 + 聯集」而不是覆蓋。 XP 取 max、徽章取聯集。這個合併策略在 Day 27 做雲端同步時會直接複用——先在本地把合併邏輯想清楚,上雲時就不用重新設計。

匯入 UI 用 <input type="file"> 加上明確的二次確認:

input.addEventListener("change", async () => {
  const f = input.files[0]; if (!f) return;
  const text = await f.text();
  const preview = Backup.import(text, "merge");        // 先跑一次驗證(不寫入的 dry-run 版本更好)
  if (!preview.ok) return alert(`匯入失敗:${preview.err}`);
  alert(`匯入完成:新增 ${preview.added} 課${preview.dropped ? `,忽略 ${preview.dropped} 筆無效紀錄` : ""}`);
  location.reload();
});

圖一:streak 熱力圖

GitHub 那種一年 52 週 × 7 天的方格圖。需要「每天完成幾課」——但 Day 5 的 Progress 只存 { "ch01-1": true }沒有時間戳

這是設計失誤的帳單。補救有兩條路:

做法 說明
改格式存時間 要遷移,而且過去的資料補不回來
加一個獨立的每日計數 從今天開始有資料,不動既有格式

選第二個,因為既有資料無論如何都補不回時間:

/* js/models/activity.js — 每日活動計數 */
const Activity = {
  KEY: "learnpath-activity-v1",
  load() {
    try { const v = JSON.parse(localStorage.getItem(this.KEY));
          return (v && typeof v === "object" && !Array.isArray(v)) ? v : {}; }
    catch { return {}; }
  },
  /* { "2026-08-03": { lessons: 3, quizzes: 5, minutes: 42 } } */
  bump(field, amount = 1) {
    const d = new Date().toISOString().slice(0, 10);
    const all = this.load();
    const day = all[d] || { lessons: 0, quizzes: 0, minutes: 0 };
    day[field] = (Number(day[field]) || 0) + amount;
    all[d] = day;
    try { localStorage.setItem(this.KEY, JSON.stringify(all)); } catch {}
  },
};

掛在既有的事件點上:

/* chapter.js 的 onComplete */
Progress.setDone(chId, lessonId, true);
Activity.bump("lessons");
Activity.bump("minutes", lesson.min || 10);        // 用課綱的預估分鐘數累積投入時間
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));

lesson.min 終於有第二個用途了——Day 10 費心算出來的分鐘數,今天變成「累積投入時間」的資料來源。(如果當初留著假的「一律 10 分」,這張圖就會是假的。)

熱力圖 SVG:

heatmapSVG(activity, weeks = 26) {
  const CELL = 12, GAP = 3, ROW = 7;
  const today = new Date();
  /* 對齊到本週日,讓每一欄都是完整的一週 */
  const end = new Date(today); end.setDate(end.getDate() + (6 - end.getDay()));
  const days = weeks * 7;
  const start = new Date(end); start.setDate(start.getDate() - days + 1);

  const counts = Object.fromEntries(
    Object.entries(activity).map(([d, v]) => [d, Number(v.lessons) || 0]));
  const max = Math.max(1, ...Object.values(counts));

  /* 五級色階:0 用軌道色,1–4 用強調色的不同不透明度 */
  const level = n => n === 0 ? 0 : Math.min(4, Math.ceil(n / max * 4));
  const FILL = ["var(--track)", "var(--accent)", "var(--accent)", "var(--accent)", "var(--accent)"];
  const OP   = [1, 0.3, 0.5, 0.75, 1];

  let cells = "", monthLabels = "", lastMonth = -1;
  for (let i = 0; i < days; i++) {
    const d = new Date(start); d.setDate(d.getDate() + i);
    const iso = d.toISOString().slice(0, 10);
    const col = Math.floor(i / 7), row = d.getDay();
    const n = counts[iso] || 0, lv = level(n);
    const x = col * (CELL + GAP), y = row * (CELL + GAP);

    cells += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="2"
      fill="${FILL[lv]}" opacity="${OP[lv]}"
      role="img" aria-label="${iso}:${n} 課"><title>${iso}:${n} 課</title></rect>`;

    if (row === 0 && d.getMonth() !== lastMonth) {
      lastMonth = d.getMonth();
      monthLabels += `<text class="hm-month" x="${x}" y="-6">${d.getMonth() + 1}月</text>`;
    }
  }

  const W = Math.ceil(days / 7) * (CELL + GAP), H = ROW * (CELL + GAP);
  return `<svg class="hm" viewBox="-4 -18 ${W + 8} ${H + 24}" role="img"
               aria-label="近 ${weeks} 週學習活動熱力圖">
    ${monthLabels}${cells}</svg>`;
}

兩個細節:

  • <title> 給原生 tooltip:SVG 的 <title> 子元素會被瀏覽器當成 tooltip,零 JS 就有懸浮提示。
  • viewBox 的負值起點-4 -18):留出上方空間給月份標籤,不需要額外的 group transform。

色階用同一個色相的不同不透明度,而不是五個不同顏色。這樣它自動跟著 Day 11 的深色模式與強調色走,不用維護第二套色票。

圖二:類別完成率雷達圖

六個類別 → 六邊形雷達圖。純三角函數:

radarSVG(stats) {                          // stats: [{ label, pct, color }, …]
  const N = stats.length, R = 90, CX = 120, CY = 110;
  /* 從 12 點鐘方向開始,順時針 */
  const pt = (i, r) => {
    const a = (i / N) * Math.PI * 2 - Math.PI / 2;
    return [CX + Math.cos(a) * r, CY + Math.sin(a) * r];
  };
  const poly = r => Array.from({ length: N }, (_, i) => pt(i, r).map(v => v.toFixed(1)).join(",")).join(" ");

  /* 背景網格:四圈 */
  const grid = [0.25, 0.5, 0.75, 1].map(k =>
    `<polygon class="rd-grid" points="${poly(R * k)}"/>`).join("");
  /* 軸線 */
  const axes = stats.map((_, i) => {
    const [x, y] = pt(i, R);
    return `<line class="rd-axis" x1="${CX}" y1="${CY}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}"/>`;
  }).join("");
  /* 資料多邊形 */
  const dataPts = stats.map((s, i) =>
    pt(i, R * Math.max(0.02, s.pct / 100)).map(v => v.toFixed(1)).join(",")).join(" ");
  /* 標籤:往外推 18px,依角度決定對齊方式 */
  const labels = stats.map((s, i) => {
    const [x, y] = pt(i, R + 18);
    const anchor = Math.abs(x - CX) < 6 ? "middle" : (x > CX ? "start" : "end");
    return `<text class="rd-label" x="${x.toFixed(1)}" y="${y.toFixed(1)}"
              text-anchor="${anchor}" dominant-baseline="middle"
              fill="${s.color}">${s.label} ${s.pct}%</text>`;
  }).join("");

  return `<svg class="rd" viewBox="0 0 240 220" role="img"
               aria-label="各類別完成率:${stats.map(s => `${s.label} ${s.pct}%`).join("、")}">
    ${grid}${axes}
    <polygon class="rd-data" points="${dataPts}"/>
    ${stats.map((s, i) => { const [x, y] = pt(i, R * Math.max(0.02, s.pct / 100));
      return `<circle class="rd-dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${s.color}"/>`;
    }).join("")}
    ${labels}</svg>`;
}

Math.max(0.02, pct/100) 是防退化:完成率 0% 時所有點都在中心,多邊形變成一個點、看不出圖形。給 2% 的最小半徑讓形狀維持可辨。

aria-label 把整張圖的數據講一遍——SVG 圖表對螢幕閱讀器來說是黑盒子,唯一的無障礙手段就是完整的文字描述。(Day 19 會再談。)

圖三:每週投入分鐘數(長條圖)

weeklyBarsSVG(activity, weeks = 12) {
  const buckets = new Array(weeks).fill(0);
  const now = new Date();
  for (const [iso, v] of Object.entries(activity)) {
    const diffDays = Math.floor((now - new Date(iso + "T00:00:00")) / 864e5);
    const w = Math.floor(diffDays / 7);
    if (w >= 0 && w < weeks) buckets[weeks - 1 - w] += Number(v.minutes) || 0;
  }
  const max = Math.max(30, ...buckets);
  const BW = 22, GAP = 8, H = 100;
  const bars = buckets.map((m, i) => {
    const h = (m / max) * H;
    return `<g><rect class="wb-bar" x="${i * (BW + GAP)}" y="${(H - h).toFixed(1)}"
      width="${BW}" height="${h.toFixed(1)}" rx="3"><title>${m} 分鐘</title></rect>
      ${m ? `<text class="wb-val" x="${i * (BW + GAP) + BW / 2}" y="${(H - h - 4).toFixed(1)}"
             text-anchor="middle">${m}</text>` : ""}</g>`;
  }).join("");
  return `<svg class="wb" viewBox="-4 -14 ${weeks * (BW + GAP) + 8} ${H + 24}" role="img"
               aria-label="近 ${weeks} 週每週投入分鐘數">${bars}
    <line class="wb-base" x1="0" y1="${H}" x2="${weeks * (BW + GAP) - GAP}" y2="${H}"/></svg>`;
}

Math.max(30, ...) 給 y 軸一個最小上限——否則只讀了 5 分鐘的那週會變成滿高的長條,視覺上騙人。圖表的縮放預設要誠實。

儀表板頁組裝

const DashController = {
  init() {
    const act = Activity.load();
    const g = Gamify.load();
    const total = totalLessonCount(), done = Progress.doneCount();
    const minutes = Object.values(act).reduce((s, v) => s + (Number(v.minutes) || 0), 0);

    const stats = Object.entries(CAT_META).map(([cat, meta]) => {
      const cs = COURSES.filter(c => c.cat === cat);
      const t = cs.reduce((s, c) => s + courseLessonCount(c), 0);
      const d = cs.reduce((s, c) => s + Progress.courseDone(c), 0);
      return { label: meta.label, pct: t ? Math.round(d / t * 100) : 0, color: catColor(cat) };
    });

    document.getElementById("dash-root").innerHTML = `
      <div class="dash-tiles">
        ${tile("已完成", `${done}`, `/${total} 課`)}
        ${tile("累積投入", `${Math.floor(minutes / 60)}`, `小時 ${minutes % 60} 分`)}
        ${tile("等級", `Lv.${Gamify.levelOf(g.xp)}`, `${g.xp} XP`)}
        ${tile("連續學習", `${g.streakDays}`, "天")}
      </div>
      <section class="dash-card"><h3>學習活動</h3>${DashView.heatmapSVG(act)}</section>
      <section class="dash-2col">
        <div class="dash-card"><h3>類別完成率</h3>${DashView.radarSVG(stats)}</div>
        <div class="dash-card"><h3>每週投入</h3>${DashView.weeklyBarsSVG(act)}</div>
      </section>
      <section class="dash-card"><h3>徽章</h3>${Gamify.badgeShelfHTML()}</section>
      <section class="dash-card"><h3>資料</h3>
        <button class="btn" id="dl-backup">匯出備份(JSON)</button>
        <label class="btn" for="up-backup">匯入備份</label>
        <input id="up-backup" type="file" accept="application/json" hidden>
        <p class="dash-note">匯入預設為<b>合併</b>:只會增加完成紀錄,不會刪除既有進度。</p>
      </section>`;
    /* 綁事件… */
  },
};

Gamify.badgeShelfHTML() 是 Day 9 就寫好但一直沒地方放的函式,今天終於有家了。

踩到的雷

時區。 new Date(iso)"2026-08-03" 會解析成 UTC 午夜,在台灣是早上 8 點。做週分桶時這會讓跨週邊界的日期算錯一格。加上 T00:00:00(不帶 Z)就會用本地時間解析:

new Date("2026-08-03")             // UTC 午夜 → 台灣 08:00
new Date("2026-08-03T00:00:00")    // 本地午夜 ✅

這也提醒我 Day 9 的 streak 是用 toISOString() 算 UTC 日期。日期邊界的一致性是個真實問題:Activity 用本地日期、Gamify 用 UTC 日期,兩者在台灣早上 8 點前會不一致。我把 Gamify 也改成本地日期:

today() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}

這是格式變更,需要遷移嗎? 不需要——最壞情況是某個使用者的 streak 多算或少算一天。但如果這是計費系統,就必須寫遷移。判斷「要不要遷移」的標準是錯誤的後果,不是格式是否改變。

SVG 的文字不會自動換行。 <text> 沒有 wrap。雷達圖標籤如果太長就會超出 viewBox 被裁掉。解法:縮短標籤(用 CAT_META.label 的短名)、或用 <foreignObject> 塞 HTML(但那在某些環境支援不完整)。我選縮短標籤。

URL.createObjectURL 要記得 revoke。 不 revoke 會洩漏記憶體(blob 一直被持有)。setTimeout(..., 1000) 而不是立刻 revoke,因為 a.click() 的下載是非同步開始的。

驗證

node scripts/verify.js
python3 -m http.server 8901
# http://localhost:8901/dashboard.html

先塞測試資料:

/* console:造 60 天的假活動 */
const act = {};
for (let i = 0; i < 60; i++) {
  const d = new Date(Date.now() - i * 864e5);
  const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
  if (Math.random() > 0.4) act[iso] = { lessons: 1 + (i % 4), quizzes: 2, minutes: 15 + (i % 30) };
}
localStorage.setItem("learnpath-activity-v1", JSON.stringify(act));
location.reload();

檢查清單:

  • 熱力圖 26 週 × 7 列、有月份標籤、懸浮顯示日期與課數、深淺分五級。
  • 雷達圖六個頂點、標籤不被裁切、0% 時仍看得到形狀。
  • 長條圖 12 週、數值標在柱上、只讀 5 分鐘那週不會滿高。
  • 四個統計磚的數字與實際資料相符。
  • 匯出 → 下載 JSON → 清空 localStorage → 匯入 → 進度回來。
  • 匯入舊備份不會刪掉新進度(merge 語意)。
  • 匯入一個手動加了假 key("nonexistent-9")的檔案 → 該筆被忽略並回報 dropped: 1
  • 匯入 {"schema": 99} → 明確拒絕並說明版本不符。
  • 深色模式下三張圖的顏色都正常(色階跟著 --accent)。

小結與明天預告

今天的重點:

  1. 匯出/匯入比圖表重要:使用者必須能帶走自己的資料,而匯入要 merge 不 replace、驗證後採用。
  2. 合併策略(XP 取 max、集合取聯集)先在本地想清楚,Day 27 上雲直接複用。
  3. 三張 SVG 圖表約 150 行、零依賴,色階用不透明度而不是第二套色票,自動跟著深色模式。
  4. 圖表的縮放要誠實(y 軸最小上限、0% 的最小半徑)。
  5. 日期邊界要全站一致——UTC 與本地混用會產生對不上的統計。

也記一筆設計失誤的帳單:Progress 當初沒存時間戳,導致熱力圖只能從今天開始有資料。如果你的資料可能會想做時間軸分析,第一天就存時間。

明天做教學動畫:canvas 上跑真的計算。核心觀點是「動畫必須跑真模型,不能是假動畫」——我會離線訓練一個小模型,輸出成純資料檔,前端只做推論。


上一篇
Day 16 — 徹底離線:自架 MathJax + PWA
下一篇
Day 18 — 教學動畫:canvas 上跑真的計算
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言