iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

30 天手把手學會 Chart.js v4:從圖表基礎到互動式資料視覺化實戰系列 第 29

Day 29 - 30 天手把手學會 Chart.js|專案實作

  • 分享至 

  • xImage
  •  

Day 28 我們把「個人財務儀表板」從零想清楚:寫了 User Story、用 MoSCoW 劃定範疇、畫了 Wireframe、設計了 Transaction/Category/Account/Budget 四個 Entity、整理出「依目的挑圖表」的對照表,也規劃好了 server/ + public/ 的資料夾骨架與技術棧。今天要把這份藍圖真正變成一個「打得開、可以互動」的網頁:整合折線圖、環狀圖、長條圖、極座標圖四種圖表於同一個儀表板,加入時間區間切換、類別篩選器、關鍵字搜尋、CSV 資料匯出等互動功能,並用 CSS Grid/Flexbox 完成響應式版面配置。本篇的完整可執行專案放在 examples/example01/,建議一邊閱讀此內容、一邊對照原始碼動手跑起來。

本日範例程式碼:examples

一、專案總覽:把 Day 28 的藍圖變成資料夾

Day 28 規劃出來的資料夾結構,今天就依樣把檔案生出來(examples/example01/ 內可以看到完整內容):

examples/example01/
├── server/                       # Mock API 後端
│   ├── package.json
│   ├── server.js
│   └── data/
│       ├── categories.json       # 8 個收支類別(含配色)
│       ├── accounts.json         # 4 個帳戶(含 1 個信用卡負債帳戶)
│       ├── transactions.json     # 近 6 個月、共 70 筆交易明細
│       ├── budgets.json          # 近 3 個月、5 個支出類別的預算上限
│       └── generate-mock-data.js # 產生上面四份 JSON 的小工具(非必要,方便日後調整資料量)
└── public/                       # 前端頁面
    ├── index.html
    ├── css/
    │   └── style.css             # Grid/Flexbox 響應式版面
    └── js/
        ├── main.js               # 進入點:載入資料、初始化圖表、綁定互動事件
        ├── api.js                # 封裝 fetch,統一取得四份資料
        ├── aggregate.js          # 篩選與彙整邏輯(groupBy/reduce)
        ├── chartSetup.js         # 統一註冊 Chart.js、設定全域預設值
        ├── exportCsv.js          # CSV 匯出(PapaParse.unparse + Blob 下載)
        └── charts/
            ├── trendChart.js     # 收支趨勢折線圖
            ├── categoryChart.js  # 支出分類環狀圖
            ├── budgetChart.js    # 預算 vs 實際長條圖
            └── allocationChart.js # 資產配置極座標圖

1.1 啟動專案

cd examples/example01/server
npm install
npm start

看到終端機印出 Day29 個人財務儀表板 API 已啟動:http://localhost:3000 之後,直接用瀏覽器打開 http://localhost:3000 即可——server.js 裡用 express.staticpublic/ 資料夾也一起served 出來,不需要另外啟動第二個伺服器或安裝任何前端建置工具。

1.2 為什麼前端不需要 Vite/Webpack?

延續 Day 28 的技術棧決定:前端純粹是 HTML/CSS/JavaScript,index.html<script type="module" src="js/main.js"></script> 載入進入點,chartSetup.js 內則直接用 Day 2 教過的 ESM CDN 寫法匯入 Chart.js:

// chartSetup.js(節錄)
import { Chart, registerables } from 'https://cdn.jsdelivr.net/npm/chart.js@4.5.1/+esm';
Chart.register(...registerables);

因為 main.jsaggregate.jscharts/trendChart.js 這些檔案彼此之間都是用相對路徑(例如 import { Chart } from '../chartSetup.js')互相 import,而不是 import 'chart.js' 這種需要打包工具或 import map 才能解析的「裸模組(bare specifier)」寫法,所以瀏覽器原生的 ES Module 機制就能正確載入整條 import 鏈,完全不需要額外安裝 Vite 或 Webpack。這也是 Day 28 說「不需要額外安裝打包工具,就能使用 import 語法做模組化拆分」的實際體現。

二、後端:一支「刻意不做篩選」的 Mock API

2.1 server.js

// server.js(節錄自 examples/example01/server/server.js)
const path = require('path');
const express = require('express');
const cors = require('cors');

const app = express();
const PORT = 3000;

app.use(cors());

const categories = require('./data/categories.json');
const accounts = require('./data/accounts.json');
const transactions = require('./data/transactions.json');
const budgets = require('./data/budgets.json');

app.get('/api/categories', (req, res) => res.json(categories));
app.get('/api/accounts', (req, res) => res.json(accounts));
app.get('/api/transactions', (req, res) => res.json(transactions));
app.get('/api/budgets', (req, res) => res.json(budgets));

// 提供前端靜態檔案(index.html、css、js)
app.use(express.static(path.join(__dirname, '..', 'public')));

app.listen(PORT, () => {
  console.log(`Day29 個人財務儀表板 API 已啟動:http://localhost:${PORT}`);
});

這支後端刻意設計得非常單純:四支 GET API,各自回傳一份完整的 JSON 陣列,沒有任何 ?month=?categoryId= 這種篩選用的 query string(跟 Day 16 教的 RESTful 篩選寫法不同)。這是有意的教學選擇:

  • Day 28 規劃的資料夾結構,把 aggregate.js(彙整邏輯)明確放在 public/js/ 前端這一側,代表「篩選、彙整」本來就該由前端負責,後端只需要老實回傳資料。
  • 今天的教學重點是前端互動與資料彙整,如果連篩選邏輯都搬到後端,反而會分散學習焦點。
  • 實務上這種「後端只給乾淨的原始資料,前端自行決定怎麼篩選、怎麼彙整」的分工,在資料量不大(例如個人記帳 App 一年頂多幾千筆交易)時是完全合理的架構選擇;等資料量成長到後端不篩選就會回傳過多資料時,才需要考慮把篩選邏輯下放到 API 或資料庫層——這也正是 Day 30「效能優化」會延伸討論的方向。

2.2 Mock Data:刻意保留的邊界情況

server/data/generate-mock-data.js 是一支輔助腳本(執行 node generate-mock-data.js 就能重新產生四份 JSON),用來產生近 6 個月、共 70 筆交易的資料。資料裡刻意安排了幾個 Day 28 提醒過的「邊界情況(Edge Case)」,方便驗證圖表與彙整邏輯是否夠健壯:

邊界情況 安排方式 用意
單月收入特別高 12 月除了薪資,還多一筆「年終獎金」45,000 元 測試趨勢折線圖在單一月份出現異常高峰時,Y 軸自動縮放是否合理
單筆大額支出 12 月的娛樂類別安排一筆「跨年小旅行」8,800 元 測試分類佔比環狀圖會不會被單一大額支出「撐爆」某個扇形
某類別特定月份無資料 醫療類別只出現在 11 月、1 月、2 月 測試彙整邏輯在「某月某類別金額為 0」時,圖表該怎麼呈現,而不是直接報錯
類別缺少預算資料 budgets.json 刻意不含「其他」類別的預算 測試預算長條圖遇到「這個類別根本沒設定預算」時的處理方式
負餘額帳戶 信用卡帳戶(acc-credit)餘額為 -8600 測試資產配置圖遇到「負債」而非「資產」時該怎麼處理

💡 如果你想練習不同的資料規模,可以直接修改 generate-mock-data.js 裡的邏輯(例如把 months 陣列延長成 12 個月),重新執行一次腳本即可覆蓋四份 JSON 檔案,不需要手動一筆一筆修改資料。

三、前端骨架:index.html 與響應式版面

3.1 頁面結構

index.html 依照 Day 28 的 Wireframe,由上到下分成四大區塊:

<div class="dashboard">
  <header class="dashboard__header"> ... 標題 + 篩選器工具列 ... </header>
  <p id="statusText">資料載入中...</p>

  <section class="summary-cards"> ... 收入/支出/結餘/較前期 四張卡片 ... </section>

  <section class="charts-grid">
    <div class="chart-box chart-box--wide"><canvas id="trendChart"></canvas></div>
    <div class="chart-box"><canvas id="categoryChart"></canvas></div>
    <div class="chart-box chart-box--wide"><canvas id="budgetChart"></canvas></div>
    <div class="chart-box"><canvas id="allocationChart"></canvas></div>
  </section>

  <section class="transactions"> ... 交易明細表格 ... </section>
</div>

每個 <canvas> 都被包在一個獨立的 .chart-container 裡(完整結構請見 examples/example01/public/index.html),這正是 Day 20 教過的「dedicated container」規則——canvas 不能跟其他元素共用同一個容器,也不能省略這層包裝直接把 canvas 放在版面裡。

3.2 CSS Grid:卡片牆與圖表牆

/* style.css(節錄) */
.summary-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

.charts-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px;
}

.chart-box--wide {
  grid-column: span 2; /* 趨勢圖、預算比較圖跨兩欄,畫面比例比較適合折線圖/長條圖 */
}

.chart-box {
  min-width: 0; /* Day20 提過的陷阱:沒有這行,圖表可能把 Grid 版面撐爆 */
}

.chart-container {
  position: relative; /* dedicated container 規則:必須是 relative,且裡面只能放一個 canvas */
  height: 280px;       /* 明確的高度,搭配 maintainAspectRatio:false 讓圖表確實填滿 */
}
  • .summary-cardsrepeat(auto-fit, minmax(200px, 1fr)):瀏覽器會自動計算「這一行最多能塞幾張卡片」,寬螢幕上四張卡片並排,螢幕變窄時自動換行,不需要手動寫 Media Query 控制卡片數量。
  • .charts-grid 固定兩欄,寬圖表(chart-box--wide)用 grid-column: span 2 跨滿整行,這對應 Day 28 Wireframe 「趨勢圖/預算比較圖比較寬、環狀圖/極座標圖比較窄」的版面安排。
  • .chart-container 明確給高度(而不是用 aspect-ratio 讓圖表自己算),是因為 Dashboard 這種版面通常希望「每張卡片高度整齊劃一」,所以四張圖表全部採用 Day 20 建議的 maintainAspectRatio: false 模式,讓圖表完全貼合容器高度。

3.3 響應式斷點

/* 平板:寬圖表不再跨欄,改成單欄堆疊 */
@media (max-width: 900px) {
  .charts-grid { grid-template-columns: 1fr; }
  .chart-box--wide { grid-column: span 1; }
}

/* 手機:篩選器、卡片全部改成單欄堆疊 */
@media (max-width: 600px) {
  .toolbar { flex-direction: column; align-items: stretch; }
  .summary-cards { grid-template-columns: 1fr 1fr; }
  .chart-container { height: 220px; }
}

交易明細表格則不強制擠壓欄位,而是讓外層 .transactions__table-wrapper 設定 overflow-x: auto,手機瀏覽時改用橫向捲動閱讀表格,避免欄位被壓縮到無法辨識金額與備註內容。

四、資料彙整核心:aggregate.js

這是今天最重要的一支模組,延續 Day 28「Aggregation」章節的設計理念——彙整邏輯完全不 import Chart.js,只單純把「原始交易明細」轉換成「畫圖需要的乾淨資料物件」,方便獨立閱讀、測試,也方便未來替換成別的圖表庫。

4.1 時間區間:從「資料裡最新的月份」往回推算

Mock Data 的日期是固定寫死的(2025-10 ~ 2026-03),如果直接拿系統的 new Date() 當作「現在」,資料會顯示「本月完全沒有交易」而顯得很奇怪。所以 getRangeMonths() 改成從資料本身找出最新出現的月份,再往前推算範圍:

export function getRangeMonths(transactions, range) {
  const allMonths = getSortedMonths(transactions); // 由舊到新排序的月份清單
  const rangeSize = range === 'month' ? 1 : range === '3m' ? 3 : 6;
  const currentMonths = allMonths.slice(-rangeSize);
  const currentStartIndex = Math.max(0, allMonths.length - rangeSize);
  const previousMonths = allMonths.slice(Math.max(0, currentStartIndex - rangeSize), currentStartIndex);
  return { currentMonths, previousMonths, allMonths };
}

這裡同時算出 previousMonths(往前推算「同樣長度的前一個區間」),是為了讓總覽卡片能算出「支出較前期成長了幾 %」——例如選「近 3 個月」時,currentMonths 是最近 3 個月,previousMonths 就是再往前的 3 個月,兩者互相比較。

4.2 兩層篩選:filterByRange vs filterTransactions

/** 只依照時間區間篩選(不篩類別、不篩關鍵字),供圖表/卡片使用 */
export function filterByRange(transactions, range) {
  const { currentMonths } = getRangeMonths(transactions, range);
  const monthSet = new Set(currentMonths);
  return transactions.filter((tx) => monthSet.has(tx.date.slice(0, 7)));
}

/** 完整篩選:時間區間 + 類別 + 關鍵字,供「交易明細表格」與「CSV 匯出」使用 */
export function filterTransactions(transactions, { range, categoryId, keyword }) {
  const byRange = filterByRange(transactions, range);
  const kw = (keyword || '').trim().toLowerCase();
  return byRange.filter((tx) => {
    if (categoryId && categoryId !== 'all' && tx.categoryId !== categoryId) return false;
    if (kw && !tx.note.toLowerCase().includes(kw)) return false;
    return true;
  });
}

刻意拆成兩層是有原因的:「支出分類環狀圖」「預算比較長條圖」只需要反映時間區間,如果使用者在下拉選單選了「只看餐飲類別」,這兩張圖表依然應該顯示「所有類別的整體佔比」,而不是被篩到只剩一種類別(不然環狀圖就失去「佔比比較」的意義了)。真正需要「類別 + 關鍵字」雙重篩選的,只有交易明細表格CSV 匯出這兩個「看明細」的功能。這個設計決策也回應了 Day 28 的提醒:「先想清楚每張圖表要回答什麼問題,再決定資料要怎麼篩選」。

4.3 收支趨勢:為什麼故意「不受」時間區間篩選器影響

/** 收支趨勢折線圖:固定呈現資料中「全部月份」,作為儀表板的整體脈絡 */
export function aggregateMonthlyTrend(transactions) {
  const months = getSortedMonths(transactions);
  const grouped = transactions.reduce((acc, tx) => {
    const month = tx.date.slice(0, 7);
    if (!acc[month]) acc[month] = { income: 0, expense: 0 };
    acc[month][tx.type] += tx.amount;
    return acc;
  }, {});

  return {
    labels: months,
    incomeData: months.map((m) => grouped[m]?.income ?? 0),
    expenseData: months.map((m) => grouped[m]?.expense ?? 0)
  };
}

這張折線圖是唯一一個不隨時間區間篩選器改變的圖表——不管使用者選「本月」還是「近 3 個月」,趨勢圖永遠顯示全部 6 個月的走勢。這是刻意的版面設計:讓使用者無論怎麼篩選其他區塊,都能隨時對照「這段時間在長期趨勢中的位置」,是儀表板設計中常見的「一張全局圖 + 多張細節圖」手法。(如果你希望趨勢圖也跟著篩選器變動,動手練習第 1 題會請你試著修改看看,並體會這樣做在「只選本月」時會遇到什麼 UX 上的取捨。)

4.4 預算比較:刻意保留「查無預算資料」的 null

export function aggregateBudgetComparison(transactions, budgets, categories, months) {
  const monthSet = new Set(months);
  const expenseCategories = categories.filter((c) => c.type === 'expense');

  const actualByCategory = transactions
    .filter((tx) => tx.type === 'expense' && monthSet.has(tx.date.slice(0, 7)))
    .reduce((acc, tx) => { acc[tx.categoryId] = (acc[tx.categoryId] || 0) + tx.amount; return acc; }, {});

  const budgetByCategory = budgets
    .filter((b) => monthSet.has(b.month))
    .reduce((acc, b) => { acc[b.categoryId] = (acc[b.categoryId] || 0) + b.limitAmount; return acc; }, {});

  return {
    labels: expenseCategories.map((c) => c.name),
    categoryIds: expenseCategories.map((c) => c.id),
    budgetData: expenseCategories.map((c) => (c.id in budgetByCategory ? budgetByCategory[c.id] : null)),
    actualData: expenseCategories.map((c) => actualByCategory[c.id] ?? 0),
    hasNoBudget: expenseCategories.map((c) => !(c.id in budgetByCategory))
  };
}

留意 budgetData 用的是 c.id in budgetByCategory ? ... : null,而不是預設成 0——這是有意的區分:「預算是 0 元」跟「根本沒有設定預算」是兩件不一樣的事。「其他」類別因為 Mock Data 沒給預算,會被彙整成 null;Chart.js 的長條圖遇到 null 資料點時,會直接跳過不畫這根長條(形成一個空隙),而不是誤導使用者「這個類別的預算是 0 元、隨便花一點就超支」。

4.5 資產配置:排除負餘額帳戶

export function aggregateAccountAllocation(accounts) {
  const assetAccounts = accounts.filter((a) => a.balance > 0);
  const debtAccounts = accounts.filter((a) => a.balance < 0);

  return {
    labels: assetAccounts.map((a) => a.name),
    data: assetAccounts.map((a) => a.balance),
    totalDebt: debtAccounts.reduce((sum, a) => sum + Math.abs(a.balance), 0),
    debtAccountNames: debtAccounts.map((a) => a.name)
  };
}

信用卡帳戶的餘額是 -8600(代表卡費未繳、屬於負債),如果直接把負數塞進極座標圖,會產生「半徑是負的」這種沒有意義的圖形。這裡選擇把資產(balance > 0)跟負債(balance < 0)分開處理:極座標圖只呈現資產帳戶的相對大小,負債總額則另外用一行提示文字呈現(見 main.jsdebtHint)。這是資料清洗(Data Cleaning)中常見的判斷——不是所有數字都適合直接畫進同一張圖表,负值、比例不同的量值,常常需要先分流處理。

五、四張圖表模組

5.1 收支趨勢折線圖(trendChart.js

延續 Day 5(fill 區域圖、tension 曲線平滑)與 Day 6(ticks.callback 座標軸格式化):

datasets: [
  { label: '收入', data: trend.incomeData, borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.15)', tension: 0.3, fill: true },
  { label: '支出', data: trend.expenseData, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.15)', tension: 0.3, fill: true }
],
options: {
  scales: { y: { ticks: { callback: (value) => `${(value / 1000).toLocaleString()}k` } } }
}

ticks.callback 把 Y 軸的數字從 52000 轉換成更易讀的 52k,這是 Day 6 教過的座標軸格式化技巧在真實專案中的應用。

5.2 支出分類環狀圖(categoryChart.js):Day 19 圖表連動實戰

options: {
  cutout: '60%',
  onClick: (event, activeElements, chart) => {
    if (!activeElements.length) return;
    const { index } = activeElements[0];
    const categoryId = chart.$categoryIds[index];
    onClickCategory(categoryId); // 通知 main.js:使用者點了哪個類別
  }
}

這裡直接重用 Day 19 教過的 onClick(event, activeElements, chart) 三個參數:activeElements[0].index 就是使用者點到的扇形索引,再對照 chart.$categoryIds(建立圖表時掛在實體上的一份對照陣列)反查出對應的 categoryId。拿到 categoryId 之後,透過 onClickCategory 這個由 main.js 傳進來的回呼函式,就能觸發「篩選交易明細表格」的連動效果——這正是 Day 19「圖表連動(點擊 A 圖表更新 B 圖表)」的具體實作。

被選中的扇形還會透過 Chart.js 的 offset 屬性往外凸出,作為「目前篩選中」的視覺提示:

chart.data.datasets[0].offset = breakdown.categoryIds.map((id) =>
  selectedCategoryId !== 'all' && id === selectedCategoryId ? 12 : 0
);

5.3 預算長條圖(budgetChart.js):Day 24 Scriptable Options 實戰

function actualColorScriptable(comparison) {
  return (context) => {
    const { dataIndex } = context;
    const budget = comparison.budgetData[dataIndex];
    const actual = comparison.actualData[dataIndex];
    if (budget == null) return '#94a3b8';       // 沒有預算資料:中性灰色
    return actual > budget ? '#ef4444' : '#3b82f6'; // 超支:紅色;未超支:藍色
  };
}
// ...
datasets: [
  { label: '預算上限', data: comparison.budgetData, backgroundColor: '#cbd5e1' },
  { label: '實際支出', data: comparison.actualData, backgroundColor: actualColorScriptable(comparison) }
]

backgroundColor 這裡傳入的是一個函式而不是固定色碼——這就是 Day 24 教過的 Scriptable Options:Chart.js 每次繪製長條時都會呼叫這個函式,並傳入包含 dataIndexcontext,讓我們可以「依照當下這根長條對應的實際數值,動態決定顏色」。比對 budget == null 的情況(也就是上一節提到的「其他」類別沒有預算資料),顏色會落回中性灰色,而不會誤判成「超支」或「未超支」。

5.4 資產配置極座標圖(allocationChart.js

延續 Day 9 學過的 Polar Area:每個扇形的角度是相等的,只有半徑隨數值變化,因此很適合單純比較「哪個帳戶餘額比較大」,而不強調彼此加總的佔比意義——這也是為什麼上一章要把信用卡負債排除在外:極座標圖不像環狀圖需要「總和等於 100%」,但同樣不適合處理負值。

export function createAllocationChart(canvas, allocation) {
  return new Chart(canvas, {
    type: 'polarArea',
    data: { labels: allocation.labels, datasets: [{ data: allocation.data, backgroundColor: [...] }] },
    options: { responsive: true, maintainAspectRatio: false }
  });
}

四支圖表模組都遵守同一個介面慣例:createXxxChart(canvas, data, ...) 負責第一次建立圖表,updateXxxChart(chart, data, ...) 負責之後的局部更新(只換 data、呼叫 chart.update(),不會 destroy() 重建整張圖),這跟 Day 13 教過的「動態更新圖表資料」原則一致:能用 update() 局部更新,就不要整張圖表重新 new Chart()

六、main.js:狀態管理與事件綁定

6.1 一份「原始資料」+ 一份「篩選條件」

const state = {
  categories: [], accounts: [], transactions: [], budgets: [],
  filters: { range: 'month', categoryId: 'all', keyword: '' }
};
const charts = { trend: null, category: null, budget: null, allocation: null };

state.categories/accounts/transactions/budgets 是從 API 拿到之後就不會再變動的原始資料;state.filters 則是會隨著使用者操作篩選器而改變的「目前篩選條件」。每次篩選條件改變,並不會重新呼叫 API,而是直接拿記憶體裡現成的原始資料,透過 aggregate.js 重新算一次——這也是為什麼 server.js 不需要提供篩選用的 query string:篩選運算全部發生在瀏覽器端,速度快、也不會增加後端負擔

6.2 渲染流程:renderAll() 與局部更新

function renderAll() {
  renderSummaryCards();
  renderTrendChart();
  renderCategoryChart();
  renderBudgetChart();
  renderAllocationChart();
  renderTransactionsTable();
}

每個 renderXxx() 函式都遵守相同的「先建立、後更新」判斷:

function renderCategoryChart() {
  const rangeFiltered = filterByRange(state.transactions, state.filters.range);
  const breakdown = aggregateCategoryBreakdown(rangeFiltered, state.categories);
  if (!charts.category) {
    charts.category = createCategoryChart(canvas, breakdown, handleCategoryChartClick);
  } else {
    updateCategoryChart(charts.category, breakdown, state.filters.categoryId);
  }
}

第一次執行時 charts.categorynull,所以呼叫 createCategoryChart() 建立圖表;之後每次篩選條件改變,charts.category 已經存在,就改呼叫 updateCategoryChart() 只更新資料,避免重複 new Chart() 造成畫面閃爍或記憶體浪費。

6.3 事件綁定:每個篩選器各自負責「該重新渲染哪些區塊」

rangeSelect.addEventListener('change', (event) => {
  state.filters.range = event.target.value;
  // 時間區間會影響:卡片、分類佔比圖、預算比較圖、交易明細(趨勢圖維持全月份不變)
  renderSummaryCards();
  renderCategoryChart();
  renderBudgetChart();
  renderTransactionsTable();
});

categorySelect.addEventListener('change', (event) => {
  state.filters.categoryId = event.target.value;
  updateCategoryChart(charts.category, /* ... */, state.filters.categoryId);
  renderTransactionsTable();
});

值得注意的細節:rangeSelect 改變時不會重新渲染 renderAllocationChart()(資產配置圖是帳戶餘額快照,跟交易的時間區間無關),也不會重新渲染 renderTrendChart()(上一章解釋過的設計決策)。這種「哪個篩選器改變,只重新渲染真正受影響的區塊」的寫法,能幫助之後維護程式碼的人快速看懂「這個篩選器到底會動到畫面上的哪些地方」。

6.4 圖表點擊 → 篩選器連動

function handleCategoryChartClick(categoryId) {
  // 再次點擊同一個類別會取消篩選(切換回「全部類別」),是常見的下拉選單 UX 慣例
  state.filters.categoryId = state.filters.categoryId === categoryId ? 'all' : categoryId;
  categorySelect.value = state.filters.categoryId;
  updateCategoryChart(charts.category, /* ... */, state.filters.categoryId);
  renderTransactionsTable();
}

這個函式把「點擊環狀圖」跟「操作下拉選單」兩種互動方式收斂成同一組狀態——不管使用者是點圖表還是選下拉選單,state.filters.categoryId 永遠是唯一的真相來源(Single Source of Truth),畫面上的下拉選單也會同步更新選中值,兩種操作方式不會互相打架。

七、互動功能細節

7.1 時間區間切換

<select id="rangeSelect"> 提供「本月」「近 3 個月」「近 6 個月」三個選項,對應 aggregate.jsgetRangeMonths()。切換後除了圖表跟著改變,「較前期」卡片的比較基準也會跟著調整(近 3 個月時,會跟「再前面 3 個月」比較,而不是單純跟上個月比較)。

7.2 關鍵字搜尋(含 debounce)

let keywordTimer = null;
keywordInput.addEventListener('input', (event) => {
  clearTimeout(keywordTimer);
  const value = event.target.value;
  keywordTimer = setTimeout(() => {
    state.filters.keyword = value;
    renderTransactionsTable();
  }, 200);
});

如果每打一個字就立刻重新渲染表格,在交易筆數變多之後可能會感覺卡頓。這裡用 setTimeout + clearTimeout 實作一個簡易的 debounce(防抖):使用者停止輸入 200 毫秒後才真正觸發渲染,避免「每敲一個字就整張表格重繪一次」的效能浪費,這個技巧在 Day 20 提到的 resizeDelay(監控 resize 事件的 debounce)也是同樣的原理。

7.3 CSV 資料匯出

// exportCsv.js(節錄)
export function exportTransactionsToCsv(transactions, categories, accounts, fileName) {
  const rows = transactions.map((tx) => ({
    日期: tx.date,
    收支類型: tx.type === 'income' ? '收入' : '支出',
    類別: categories.find((c) => c.id === tx.categoryId)?.name ?? tx.categoryId,
    帳戶: accounts.find((a) => a.id === tx.accountId)?.name ?? tx.accountId,
    金額: tx.amount,
    備註: tx.note
  }));

  const csvText = Papa.unparse(rows); // Day17 學過的 PapaParse,這裡反過來做「JSON -> CSV」
  const blob = new Blob([`\ufeff${csvText}`], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);

  const link = document.createElement('a');
  link.href = url;
  link.download = fileName;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

幾個實務細節:

  • Day 17 用 Papa.parse() 把 CSV 轉成 JSON,這裡反過來用 Papa.unparse() 把 JSON 陣列轉回 CSV 文字,是同一個套件的一體兩面。
  • 匯出前,先把英文欄位(datetype...)轉成中文欄位名稱(日期收支類型...),是為了讓 CSV 開啟後的標題列對一般使用者更友善。
  • CSV 文字前面加上 \ufeff(UTF-8 BOM):這是為了解決 Excel 開啟 UTF-8 編碼的 CSV 檔案時,中文字常常變成亂碼的經典問題。
  • URL.createObjectURL(blob) 產生一個暫時的下載連結,建立一個「看不見的」<a download> 標籤觸發點擊,下載完成後記得呼叫 URL.revokeObjectURL(url) 釋放記憶體。
  • 匯出的內容是**「目前篩選後」**的交易明細(getFilteredTransactions() 的結果),而不是全部 70 筆原始資料——這是資料匯出功能的常見期待:使用者篩出「只看餐飲類別」之後按下匯出,應該只拿到餐飲類別的 CSV,而不是整份原始資料。

八、實際執行與驗收

  1. 依照第二章的指令啟動伺服器後,瀏覽器打開 http://localhost:3000,應該會依序看到:統計卡片(收入/支出/結餘/較前期)→ 四張圖表 → 交易明細表格,且 statusText 顯示「資料載入完成,共 70 筆交易紀錄。」
  2. 切換「時間區間」下拉選單為「近 6 個月」,交易明細筆數應該從本月的 12 筆左右變成 70 筆(也就是全部資料);預算比較長條圖會同時彙整近 6 個月裡「有預算資料的月份」(2026-01~03)。
  3. 在「支出類別」選單選擇「餐飲」,或直接點擊支出分類環狀圖裡「餐飲」對應的扇形,兩種操作應該會得到相同結果:下拉選單自動跳到「餐飲」、扇形往外凸出、交易明細表格只剩餐飲類別的紀錄。
  4. 在「搜尋備註」輸入「超市」,表格應該即時(停頓約 0.2 秒後)篩到只剩備註包含「超市」的交易。
  5. 按下「匯出 CSV」,瀏覽器應該會下載一份檔名類似 transactions_month_1234567890.csv 的檔案,用 Excel 或文字編輯器打開,中文欄位與內容都應該正常顯示,不會出現亂碼。
  6. 把瀏覽器視窗縮到手機寬度(或用開發者工具的裝置模擬),篩選器應該垂直堆疊、卡片變成兩欄、圖表牆變成單欄,交易明細表格則出現橫向捲軸而不是把欄位擠爆。

本篇範例已經用 Puppeteer 進行過自動化驗證:確認四張 <canvas> 都成功建立、三種篩選器(時間區間/類別/關鍵字)都能正確改變交易筆數、以及圖表點擊確實會連動下拉選單與表格,執行過程沒有任何 JavaScript 錯誤(唯一的瀏覽器提示是找不到 favicon.ico,這與網站圖示無關,不影響任何功能)。

九、常見誤區與注意事項

  1. 把所有篩選邏輯都寫進 main.js,沒有拆到 aggregate.js:一開始覺得「反正資料不多,直接在事件監聽器裡面 filterreduce 也很快」,但這樣會讓 main.js 越寫越肥,且彙整邏輯無法被單獨測試。今天的架構把「算資料」跟「畫圖表」「綁事件」明確拆開,是為了 Day 30 的重構鋪路。
  2. 每次篩選都整張圖表 destroy()new Chart():這是新手很容易犯的錯誤,會造成畫面明顯閃爍,也違反 Day 13 教過的「能 update() 就不要重建」原則。今天的四支圖表模組都刻意設計成「建立一次、之後只 update()」。
  3. 忽略「類別篩選不應該影響環狀圖本身」的需求:如果把 categorySelect 的篩選條件也套用到 aggregateCategoryBreakdown(),環狀圖選了「餐飲」之後就會只剩一個扇形,失去「佔比比較」的意義。務必先想清楚每張圖表存在的目的(呼應 Day 28 用 User Story 釐清「為什麼要做」的精神),再決定篩選條件該套用在哪裡。
  4. CSV 匯出忘記加 UTF-8 BOM:少了 \ufeff,用 Windows 版 Excel 直接開啟 CSV 時,中文欄位名稱與內容很容易變成亂碼,這是新手第一次做「JSON 轉 CSV」功能時很容易忽略的小細節。
  5. 長條圖把「沒有預算資料」跟「預算是 0」混為一談:如果 budgetData 一律預設成 0 而不是 null,畫面上會誤導使用者「這個類別不能花任何錢」,而不是「這個類別根本還沒設定預算」,兩者代表的意義完全不同。
  6. 響應式版面忘記 min-width: 0:在 CSS Grid/Flexbox 版面下,容器預設的最小寬度是「內容自身的寬度」,如果圖表容器裡的文字或 canvas 比較寬,忘記設定 min-width: 0 就可能把整個 Grid 版面撐爆、跑版。
  7. 極座標圖/環狀圖直接塞進負數:信用卡負債這種「負值」如果沒有先分流處理,會產生沒有意義的圖形(半徑或角度不可能是負的),資料清洗的步驟不能省略。

明天(Day 30)是這個系列的最後一天:我們會回頭檢視今天做出來的儀表板,討論當交易筆數從 70 筆成長到幾千筆時可能遇到的效能瓶頸,學習 decimation(資料抽稀)外掛的用法,並針對今天略顯集中在 main.js 的渲染邏輯做進一步的程式碼重構與模組化,最後把 Chart.js 這 30 天學過的所有知識點做一次總複習。

參考資源


上一篇
Day 28 - 30 天手把手學會 Chart.js|專案規劃與資料設計
系列文
30 天手把手學會 Chart.js v4:從圖表基礎到互動式資料視覺化實戰29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言