iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Modern Web

Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延系列 第 21

【Day 21】— 入門 JavaScript 網頁架設:圓餅/趨勢圖(Chart.js)

  • 分享至 

  • xImage
  •  

摘要
延續 Day 20 的長條圖統計,我們今天要繼續延伸 Chart.js 的應用:

  • 圓餅圖:用來顯示各精神狀態的「比例分布」。
  • 折線圖:改成計算「每日有效造訪次數」——同一天不論提交幾次,只算 1 次,觀察使用者最近的回訪頻率。

同一份 MOOD_LOG_KEY 資料,可以從「比例」和「趨勢」兩個角度來看,幫助使用者更清楚認識自己的精神狀態與使用習慣。

為什麼要做比例/趨勢?

  • 比例(Pie):直覺看到哪些精神狀態最常出現,是否失衡。
  • 趨勢(Line):觀察每天有沒有固定回來使用,讓使用者形成穩定的自我觀察習慣。

學習重點

  1. Chart.js 不同圖表類型
  • bar:比較不同分類的數量(Day 20 用過)。
  • pie:比較各分類的比例。
  • line:顯示隨時間變化的趨勢。
  1. 每日有效造訪的定義
    不管同一天做了幾次小測驗,只要有一次,就算 1 次「有效造訪」。

核心流程

  1. 圓餅圖
    繼承 Day 20 的 aggregateMoodCounts(),顯示各精神狀態佔比。
  2. 折線圖
  • 新增 aggregateDailyVisits(),只要同一天有任何快照,就記為 1。
  • 用 Chart.js 畫折線圖,X 軸是日期,Y 軸是每日造訪次數(固定為 0 或 1)。

實作

假設已具備:

  • Day 12

    • showIntro() / hideIntroShowForm() → 頁面切換骨架
    • formSection / historySection → 主區塊切換
  • Day 13

    • onSubmit() → 表單提交流程
    • renderHistory() → 歷史清單渲染
    • readRecords() / writeRecords() → 紀錄存取
  • Day 14

    • MOOD_KEY → 當日精神狀態存放
    • saveMood(period, value) → 儲存單一時段
    • loadMood() → 載入並回填 UI
    • showAggregateFeedback() → 顯示整體狀態回饋
  • Day 15

    • moodSubmitBtn 綁定 → 提交小測驗 → appendMoodSnapshot()
  • Day 20

    • MOOD_LOG_KEY → 精神狀態快照陣列
    • makeSnapshotFromCurrentMood() → 拍一次快照
    • appendMoodSnapshot(snapshot) → 加入快照
    • readMoodLog() → 讀取所有快照
    • aggregateMoodCounts() → 狀態統計
  1. HTML 新增區塊
    在歷史回顧頁的統計區後,新增兩個圖表容器與清單:
<!-- [Day21-NEW] 精神狀態占比(圓餅圖) -->
<div id="moodPieWrap" style="position:relative; height:240px; max-width:560px; margin:.5rem 0;">
  <canvas id="moodPie" hidden aria-label="精神狀態圓餅圖" role="img"></canvas>
</div>

<!-- [Day21-NEW] 每日有效造訪(折線圖) -->
<div id="moodTrendWrap" style="position:relative; height:260px; max-width:680px; margin:.5rem 0;">
  <canvas id="moodTrend" hidden aria-label="每日有效造訪折線圖" role="img"></canvas>
</div>
<ul id="moodTrendList" class="muted" aria-live="polite"></ul>
  1. JS 新增函式
// [Day21-NEW] 載入 Chart.js 與時間軸 adapter(可選)
async function ensureChartJS() {
  // 1) Chart.js
  if (!window.Chart) {
    await new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
      s.onload = resolve; s.onerror = reject;
      document.head.appendChild(s);
    });
  }
  // 2) date-fns adapter(time scale 需要;失敗就走降級)
  if (!window._ChartDateAdapterLoaded) {
    try {
      await new Promise((resolve, reject) => {
        const s = document.createElement('script');
        s.src = 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns';
        s.onload = resolve; s.onerror = reject;
        document.head.appendChild(s);
      });
      window._ChartDateAdapterLoaded = true;
    } catch (e) {
      console.warn('adapter 載入失敗,折線圖將使用「分類軸」降級:', e);
      window._ChartDateAdapterLoaded = false;
    }
  }
}

// [Day21-NEW] 取得 2D context
function getCtx(id) {
  const el = document.getElementById(id);
  if (!el) return null;
  el.hidden = false;
  return el.getContext('2d');
}

// [Day21-NEW] 銷毀舊圖,避免重疊 / memory leak
function destroyChart(canvasId) {
  const el = document.getElementById(canvasId);
  if (el && el._chartInstance) {
    el._chartInstance.destroy();
    el._chartInstance = null;
  }
}

// 產生本地 YYYY-MM-DD key(避免時區影響)
function dateKeyLocal(d) {
  const y = d.getFullYear();
  const m = String(d.getMonth()+1).padStart(2,'0');
  const day = String(d.getDate()).padStart(2,'0');
  return `${y}-${m}-${day}`;
}

// [Day21-CHANGED] 每日有效造訪(補零):預設最近 30 天
function aggregateDailyVisits(days = 30) {
  const log = readMoodLog(); // [{ ts, ... }]
  const visitSet = new Set(
    log.map(s => dateKeyLocal(new Date(s.ts || Date.now())))
  );

  // 從今天往回推 N 天,逐日填入 0/1
  const today = new Date();
  today.setHours(0,0,0,0);
  const rows = [];

  for (let i = days - 1; i >= 0; i--) {
    const d = new Date(today);
    d.setDate(today.getDate() - i);
    const key = dateKeyLocal(d);
    rows.push({ x: new Date(d), dateStr: key, y: visitSet.has(key) ? 1 : 0 });
  }
  return rows; // 長度固定 = days
}

// [Day21-NEW] 繪製圓餅圖(占比)
function renderMoodPie(countMap) {
  const canvasId = 'moodPie';
  destroyChart(canvasId);

  const labels = [...countMap.keys()];
  const data   = [...countMap.values()];
  if (data.length === 0) {
    document.getElementById(canvasId).hidden = true;
    return;
  }

  const ctx = getCtx(canvasId);
  if (!ctx) return;

  const chart = new Chart(ctx, {
    type: 'pie',
    data: { labels, datasets: [{ data }] },
    options: {
      responsive: true,
      plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: ctx => `${ctx.label}: ${ctx.parsed}` } } }
    }
  });
  document.getElementById(canvasId)._chartInstance = chart;
}

// [Day21-NEW] 繪製折線圖(趨勢)— 可用 time scale;失敗則用分類軸
function renderMoodTrend(dailyPoints) {
  const canvasId = 'moodTrend';
  const listEl = document.getElementById('moodTrendList');
  destroyChart(canvasId);

  if (!dailyPoints.length) {
    document.getElementById(canvasId).hidden = true;
    listEl.innerHTML = '<li>尚無有效造訪資料</li>';
    return;
  }
  
  // 清單回退:只列出有造訪(=1)的日期
  const nonZero = dailyPoints.filter(p => p.y > 0);
  listEl.innerHTML = nonZero.length
    ? nonZero.map(p => `<li>${p.dateStr}</li>`).join('')
    : '<li>尚無有效造訪資料</li>';

  // 嘗試用 time scale
  const usingTimeScale = !!window._ChartDateAdapterLoaded; // 若 adapter 成功載入,啟用 time scale
  const labels = dailyPoints.map(p => p.dateStr);
  const data   = dailyPoints.map(p => (usingTimeScale ? { x: p.x, y: p.y } : p.y));

  const ctx = getCtx(canvasId);
  if (!ctx) return;

  const chart = new Chart(ctx, {
    type: 'line',
    data: {
      labels: usingTimeScale ? undefined : labels,
      datasets: [{
        label: '每日有效造訪數',
        data,
        tension: 0.25,
        pointRadius: 3
      }]
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      scales: usingTimeScale
        ? { x: { type: 'time', time: { unit: 'day' } }, y: { beginAtZero: true, ticks: { precision: 0, stepSize: 1 } } }
        : { x: { ticks: { autoSkip: true } }, y: { beginAtZero: true, ticks: { precision: 0, stepSize: 1 } } },
      plugins: { legend: { display: false } }
    }
  });
  document.getElementById(canvasId)._chartInstance = chart;
}
  1. JS 整合呼叫點
// [Day21-NEW] 在歷史頁渲染所有圖(bar + pie + line)
async function renderMoodChartsAll() {
  // 先確保圖表核心與 adapter(可選)就緒
  try { await ensureChartJS(); } catch (e) { console.warn('Chart.js 載入失敗:', e); }

  // bar(既有):沿用 Day 20 的 renderMoodStats()
  await renderMoodStats();

  // pie:占比
  const counts = aggregateMoodCounts();   // Map<label, count>
  renderMoodPie(counts);

  // line:趨勢
  const daily = aggregateDailyVisits(30);   // [{x:Date, dateStr:'YYYY-MM-DD', y:number}]
  renderMoodTrend(daily);
}

// === showPage('history') ===
if (page === 'history') {
  historySection.hidden = false;
  renderMoodChartsAll().finally(() => {
    if (typeof renderHistory === 'function') renderHistory();
  });
  focusOrFallback(historySection, 'history-title');
}

驗證

  1. 不同天提交:每一天只算 1 次,折線圖會畫出一條水平線。
  2. 同一天多次提交:折線圖仍顯示「1 次」。
  3. 無資料:清單顯示「尚無有效造訪資料」。

常見錯誤 & 排查

  1. 清單顯示正常,但圖表不出現
    檢查 CDN 是否被阻擋。
  2. 同一天仍顯示多次
    確認 aggregateDailyVisits() 是否正確用 Map.set(key,1),避免累加。
  3. Y 軸不是 0/1
    檢查 Chart.js options,確保有 ticks.stepSize = 1。

上一篇
【Day 20】— 入門 JavaScript 網頁架設:長條圖統計
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言