iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

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

Day 24 - 30 天手把手學會 Chart.js|主題與樣式客製化

  • 分享至 

  • xImage
  •  

昨天(Day 23)我們學會了自訂外掛(Plugin)開發,也搞懂了外掛 options 如何在 plugin.defaults => Chart.defaults.plugins.{id} => chart.options.plugins.{id} 這三層之間合併。今天要把視角從「單一外掛的行為」拉高到「整個專案的視覺風格管理」:學習如何透過 Chart.defaults 一次設定所有圖表的全域樣式、打造深色模式(Dark Mode)與品牌色彩系統,並實作漸層色(Gradient)背景,讓圖表風格能夠有系統地被管理,而不是每張圖表都重複寫一樣的樣式設定。

一、複習:Options 是怎麼被解析出來的?

在動手客製化樣式之前,必須先搞懂一件事:當 Chart.js 要決定「這個長條要用什麼顏色畫」時,它到底參考了哪些設定、以什麼順序參考? 這個機制叫做 Option Resolution(選項解析)

以「Chart 層級的選項」為例,Chart.js 會依照下面的順序,由上而下尋找第一個「有定義值」的設定:

config.options(你在 new Chart() 時傳入的 options)
      ↓ 找不到就往下找
overrides[config.type](該圖表類型的預設覆寫,例如 bar、line 各自的預設)
      ↓ 找不到就往下找
Chart.defaults(全域預設值,優先權最低)

也就是說,Chart.defaults 是所有設定的「最後一道防線」:只要你在建立圖表時沒有明確指定某個選項,Chart.js 最終就會回頭讀取 Chart.defaults 裡的值。這也是為什麼修改 Chart.defaults 可以「一次影響專案內所有還沒有明確覆蓋該選項的圖表」——這正是今天要善用的核心機制。

反過來說,如果某張圖表的 options 裡已經明確寫了顏色,那麼不管 Chart.defaults 怎麼改,這張圖表都不會受影響,因為它的優先權比 Chart.defaults 高。記住這個原則:Chart.defaults 負責「兜底」,個別圖表的 options 負責「例外」。

二、全域預設值設定:Chart.defaults

2.1 最常用的全域設定項目

Chart.defaults 是一個巨大的物件,包含了 Chart.js 內部所有選項的預設值。實務上最常客製化的幾個項目整理如下:

設定路徑 說明 預設值
Chart.defaults.font.family 全域字型 "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"
Chart.defaults.font.size 全域字型大小(px) 12
Chart.defaults.color 全域文字顏色(座標軸文字、圖例文字等) #666
Chart.defaults.backgroundColor 幾何元素(長條、扇形等)未指定時的背景色 rgba(0, 0, 0, 0.1)
Chart.defaults.borderColor 幾何元素未指定時的邊框色,也影響格線顏色 rgba(0, 0, 0, 0.1)
Chart.defaults.plugins.legend.display 是否顯示圖例 true
Chart.defaults.plugins.tooltip.enabled 是否啟用提示框 true
Chart.defaults.elements.line.tension 折線圖預設曲線張力 0(Chart.js 4.x 起)

2.2 實際範例:建立專案的統一視覺基調

假設專案要求所有圖表統一使用「微軟正黑體」字型、文字顏色改為深灰色,可以在應用程式**最一開始(建立任何圖表之前)**寫一次:

// main.js(或任何整個應用程式的進入點)
Chart.defaults.font.family = "'Microsoft JhengHei', 'Segoe UI', sans-serif";
Chart.defaults.font.size = 14;
Chart.defaults.color = '#333333';

// 之後在專案裡任何地方建立的圖表,都會自動套用上面這三項設定
const chart1 = new Chart(ctx1, { type: 'line', data, options: {} });
const chart2 = new Chart(ctx2, { type: 'bar', data, options: {} });

程式碼重點說明:

  1. 設定時機很重要Chart.defaults 的修改必須發生在 new Chart() 之前才有意義。因為 Chart.js 在建立圖表當下就會把 Chart.defaults 的值解析、套用進去;圖表建立完成後才修改 Chart.defaults,並不會讓「已經存在」的圖表自動套用新值(需要呼叫 chart.update() 才會重新解析)。
  2. 巢狀路徑要完整Chart.defaults.font 本身是一個物件,如果直接整個覆蓋(Chart.defaults.font = { size: 14 }),會把 familyweight 等其他欄位一併蓋掉。建議像範例一樣,逐一設定巢狀物件裡的單一欄位,避免不小心清空其他預設值。
  3. 也能針對特定圖表類型設定:如果只想讓折線圖有不同的預設值,可以使用 Chart.overrides.line.xxx = ...,這是專屬於「該圖表類型」的預設層(優先權比 Chart.defaults 高,比個別圖表的 options 低),適合「某一類圖表要有共同風格,但又不想全域套用」的情境。

2.3 全域設定 Plugin 選項

Chart.defaults 不只能設定核心選項,也能設定外掛(Plugin)的全域預設值,這正好呼應昨天學到的三層合併機制:

// 讓所有圖表的圖例文字統一改成 16px、置底顯示
Chart.defaults.plugins.legend.position = 'bottom';
Chart.defaults.plugins.legend.labels.font = { size: 16 };

// 讓昨天寫的 watermark 外掛,全域預設文字改成公司名稱
Chart.defaults.plugins.watermark = { text: 'MyCompany Inc.' };

三、深色模式(Dark Mode)實作

3.1 深色模式需要調整哪些選項?

切換深色模式時,「資料本身的顏色」(例如長條的 backgroundColor)通常不需要變動,真正需要跟著切換的是背景與文字類的選項,整理如下:

選項 淺色模式建議值 深色模式建議值
Chart.defaults.color(文字顏色) #333333 #e0e0e0
Chart.defaults.borderColor(格線顏色) rgba(0, 0, 0, 0.1) rgba(255, 255, 255, 0.15)
畫布背景色(需搭配外掛或 CSS) #ffffff #1e1e2e
plugins.legend.labels.color 通常跟隨 Chart.defaults.color 同上
plugins.tooltip.backgroundColor rgba(0, 0, 0, 0.8)(預設已偏深,通常不需大改) 可視需求微調

3.2 實作:可即時切換的深色模式

最推薦的做法是把「淺色」與「深色」兩套設定整理成物件,切換時統一寫回 Chart.defaults,再對所有現存圖表呼叫 update()

const themes = {
  light: {
    color: '#333333',
    borderColor: 'rgba(0, 0, 0, 0.1)',
    background: '#ffffff'
  },
  dark: {
    color: '#e0e0e0',
    borderColor: 'rgba(255, 255, 255, 0.15)',
    background: '#1e1e2e'
  }
};

// 用來記錄目前所有已建立的圖表,切換主題時才能逐一更新
const allCharts = [];

function applyTheme(themeName) {
  const theme = themes[themeName];

  Chart.defaults.color = theme.color;
  Chart.defaults.borderColor = theme.borderColor;

  // 畫布本身的底色不屬於 Chart.js 選項,改用 CSS 或 body 的 class 控制
  document.body.style.backgroundColor = theme.background;
  document.body.dataset.theme = themeName;

  // 已經存在的圖表不會自動套用新的 Chart.defaults,
  // 必須逐一呼叫 update() 強迫它們重新解析選項
  allCharts.forEach(chart => chart.update());
}

// 建立圖表時記得推進 allCharts 陣列
const chart = new Chart(ctx, { type: 'line', data, options: {} });
allCharts.push(chart);

// 切換按鈕
document.getElementById('themeToggleBtn').addEventListener('click', () => {
  const next = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
  applyTheme(next);
});

applyTheme('light'); // 頁面載入時套用預設主題

重點解說

  1. allCharts 陣列是關鍵:因為修改 Chart.defaults 不會自動讓「已經畫出來」的圖表重繪,一定要手動保存每個圖表的實例,並在切換主題時逐一呼叫 chart.update(),讓 Chart.js 重新跑一次 Option Resolution 流程。
  2. 畫布背景色不是 Chart.js 的選項:Chart.js 預設的 Canvas 背景是透明的,所以「整張圖表看起來的底色」其實來自於外層容器的 CSS 背景色(或是像 Day 23 教的 beforeDraw 外掛手動填色)。因此深色模式的畫布底色,建議直接透過 CSS(background-color)處理,會比每次都用外掛填色更簡單、效能也更好。
  3. 記得同步調整 datasets 顏色的可讀性:如果資料本身用的是很淺的顏色(例如淡黃色的長條),在深色背景下可能會看不清楚邊框。建議品牌色票(見下一節)本身就設計成「在深色背景下也有一定對比度」的顏色,或是針對 borderColor(長條外框)在深色模式下加上一圈稍亮的邊框以增加辨識度。

四、品牌色彩系統(Brand Color System)

4.1 為什麼需要建立調色盤

實務上,公司或產品通常有自己的一套「品牌識別色」(例如主色、輔助色、警示色)。如果每次建立圖表都手動輸入色碼(#FF6384#36A2EB...),不但容易複製貼上打錯字,未來設計團隊更換色票時,也得一個一個檔案去改。更好的做法是把顏色整理成一份共用的調色盤模組,所有圖表統一從這裡取色。

4.2 實作:建立可重複使用的調色盤

// palette.js:集中管理品牌色彩,之後設計團隊換色只需要改這一個檔案
export const BrandColors = {
  primary: '#4c6ef5',
  secondary: '#12b886',
  warning: '#f59f00',
  danger: '#e03131',
  neutral: '#868e96',

  // 提供半透明版本,方便當作 backgroundColor 使用
  primaryAlpha: 'rgba(76, 110, 245, 0.5)',
  secondaryAlpha: 'rgba(18, 184, 134, 0.5)',

  // 依序取用的調色盤陣列,適合多個 dataset 或圓餅圖各區塊使用
  palette: ['#4c6ef5', '#12b886', '#f59f00', '#e03131', '#868e96', '#7048e8']
};

// 提供一個小工具函式:依索引取色,並自動循環(資料筆數超過色票長度時)
export function getBrandColor(index) {
  return BrandColors.palette[index % BrandColors.palette.length];
}

使用時搭配 Chart.js 的 Scriptable Options(可以傳入一個函式,依 context 動態回傳值),就能讓多筆資料自動套用品牌色,不需要手動排列每個顏色:

import { getBrandColor } from './palette.js';

const chart = new Chart(ctx, {
  type: 'bar',
  data: {
    labels: ['台北', '台中', '台南', '高雄'],
    datasets: [{
      label: '各分店營收',
      data: [120, 95, 88, 102],
      backgroundColor: (context) => getBrandColor(context.dataIndex)
    }]
  }
});

重點解說

  1. backgroundColor 傳入函式:這就是 Chart.js 的 Scriptable Options,函式會針對每一筆資料被呼叫一次,並收到一個 context 物件(內含 dataIndexdatasetchart 等資訊),讓顏色可以依據資料索引、數值大小等條件動態決定,而不是寫死一組固定陣列。
  2. getBrandColor 自動循環:使用 index % palette.length 取餘數,確保即使資料筆數超過調色盤顏色數量(例如 10 個分店但只有 6 種品牌色),也不會出現 undefined 顏色,而是從頭開始循環使用。
  3. 集中管理帶來的好處:日後設計團隊更新品牌色,只需要修改 palette.js 這一個檔案,所有引用 BrandColors 的圖表都會自動套用新色票,不需要在每個頁面裡逐一搜尋、取代色碼。

4.3 也可以搭配官方的 Colors 外掛

如果專案沒有嚴格的品牌色票需求,Chart.js 內建的 Colors 外掛可以直接幫每個 dataset 自動套用七種預設色,快速做出「至少每個資料集顏色都不同」的效果:

import { Colors } from 'chart.js';
Chart.register(Colors);

但若有明確的品牌識別色,仍建議採用 5.2 節的自訂調色盤做法,才能確保視覺風格與品牌完全一致。

五、漸層色(Gradient)背景實作

5.1 為什麼漸層不能像純色一樣直接寫死

漸層色在 Canvas 裡是透過 CanvasRenderingContext2D.createLinearGradient() 建立的一個 CanvasGradient 物件,而不是單純的色碼字串。而建立這個物件時,需要用到圖表的實際繪圖區座標(chart.chartArea——但問題是,圖表在第一次渲染之前chartArea 的寬高都還不存在(因為畫布還沒被量測、佈局)。這就是為什麼漸層色沒辦法像 backgroundColor: '#4c6ef5' 這樣直接寫死一個值,而必須透過 Scriptable Options 動態產生。

5.2 完整實作:折線圖的漸層填色

// 快取已經算好的漸層物件,避免每一幀都重新建立造成效能浪費
let cachedGradient;
let cachedWidth, cachedHeight;

function getGradient(ctx, chartArea) {
  const chartWidth = chartArea.right - chartArea.left;
  const chartHeight = chartArea.bottom - chartArea.top;

  // 只有第一次渲染、或圖表尺寸改變(例如視窗縮放)時,才重新建立漸層
  if (!cachedGradient || cachedWidth !== chartWidth || cachedHeight !== chartHeight) {
    cachedWidth = chartWidth;
    cachedHeight = chartHeight;

    cachedGradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
    cachedGradient.addColorStop(0, 'rgba(76, 110, 245, 0.05)');   // 底部:接近透明
    cachedGradient.addColorStop(1, 'rgba(76, 110, 245, 0.6)');    // 頂部:較深的品牌色
  }

  return cachedGradient;
}

const chart = new Chart(ctx, {
  type: 'line',
  data: {
    labels: ['一月', '二月', '三月', '四月', '五月'],
    datasets: [{
      label: '月營收趨勢',
      data: [30, 45, 42, 58, 66],
      borderColor: '#4c6ef5',
      backgroundColor: (context) => {
        const { chart } = context;
        const { ctx, chartArea } = chart;

        if (!chartArea) {
          // 圖表第一次載入、chartArea 尚未計算出來時,先回傳 undefined
          // Chart.js 會在下一輪重繪(chartArea 就緒後)自動再呼叫一次這個函式
          return;
        }
        return getGradient(ctx, chartArea);
      },
      fill: true, // 折線圖預設不會填色,需要設定 fill: true 才會套用 backgroundColor
      tension: 0.3
    }]
  }
});

重點解說

  1. if (!chartArea) return; 是必要的防呆:圖表在初次載入時,Chart.js 會先用「假的資料」跑一次選項解析來決定版面配置,這時候 chartArea 還是 undefined。如果沒有做這個判斷,直接對 undefined 呼叫方法會直接噴錯,導致圖表整個渲染失敗。回傳 undefined 之後,Chart.js 會在 chartArea 準備好的下一輪繪製中自動重新呼叫這個函式。
  2. createLinearGradient(x0, y0, x1, y1) 的方向:這四個參數定義了漸層的「起點」與「終點」座標。範例中 (0, chartArea.bottom, 0, chartArea.top) 表示 x 座標不變(垂直方向漸層),從圖表底部(bottom,色標 0)漸變到圖表頂部(top,色標 1),做出「越往上顏色越深」的效果,很適合搭配折線圖下方填色,營造出資料越高、色彩越飽和的視覺重點。
  3. addColorStop(offset, color)offset 是 0 到 1 之間的數值,代表這個顏色出現在漸層的哪個位置。可以呼叫多次 addColorStop 做出三段以上的多色漸層(例如:藍 → 黃 → 紅,常用來表示數值從低到高的警示程度)。
  4. 快取漸層物件,避免效能浪費getGradient() 函式會在圖表每一次重繪時被呼叫(包含滑鼠移過去觸發的 hover 動畫),如果每次都重新呼叫 createLinearGradient(),會造成不必要的效能負擔。範例中用 cachedGradient 搭配「尺寸是否改變」的判斷式,只有在真正需要時(第一次渲染、或圖表尺寸改變)才重新建立漸層物件,其餘情況直接複用快取結果。
  5. fill: true 別忘記設定:折線圖(Line Chart)預設是「只畫線、不填色」,即使設定了 backgroundColor,如果沒有搭配 fill: true(或 fill: 'origin' 等填色模式),漸層依然不會顯示出來。

5.3 長條圖的漸層填色

長條圖的做法大同小異,差別在於漸層方向通常會設計成「沿著長條的高度」,且因為長條圖沒有 fill 選項的限制,backgroundColor 直接套用即可:

datasets: [{
  data: [65, 59, 80, 81],
  backgroundColor: (context) => {
    const { chart } = context;
    const { ctx, chartArea } = chart;
    if (!chartArea) return;

    const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
    gradient.addColorStop(0, '#4c6ef5');
    gradient.addColorStop(1, '#a5b4fc');
    return gradient;
  }
}]

六、完整範例:整合三種客製化技巧的儀表板

CSS 樣式內容如下:

body { font-family: 'Segoe UI', 'Microsoft JhengHei', sans-serif; max-width: 960px; margin: 40px auto; padding: 0 16px; transition: background-color 0.2s ease; }
h1 { font-size: 1.4rem; }
button { padding: 8px 16px; margin: 12px 8px 12px 0; cursor: pointer; }
.charts { display: flex; gap: 24px; flex-wrap: wrap; }
.chart-box { flex: 1 1 380px; position: relative; height: 380px; }
p.tip { color: #666; font-size: 0.9rem; }
body[data-theme='dark'] p.tip { color: #aaa; }

HTML 版面內容如下:

<button id="themeToggleBtn">切換深色模式</button>
<div class="charts">
  <div class="chart-box"><canvas id="lineChart"></canvas></div>
  <div class="chart-box"><canvas id="barChart"></canvas></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1"></script>

JavaScript 程式碼內容如下:

// 全域預設值設定:在建立任何圖表之前先統一設定字型
Chart.defaults.font.family = "'Microsoft JhengHei', 'Segoe UI', sans-serif";
Chart.defaults.font.size = 13;

// 品牌色彩系統:集中管理色票,並提供依索引循環取色的工具函式
const BrandColors = {
  palette: ['#4c6ef5', '#12b886', '#f59f00', '#e03131']
};
function getBrandColor(index) {
  return BrandColors.palette[index % BrandColors.palette.length];
}

// 深色模式:把淺色/深色兩套設定整理成主題物件
const themes = {
  light: { color: '#333333', borderColor: 'rgba(0, 0, 0, 0.1)', background: '#ffffff' },
  dark: { color: '#e0e0e0', borderColor: 'rgba(255, 255, 255, 0.15)', background: '#1e1e2e' }
};

// 保存所有已建立的圖表,切換主題時才能逐一呼叫 update()
const allCharts = [];

function applyTheme(themeName) {
  const theme = themes[themeName];
  Chart.defaults.color = theme.color;
  Chart.defaults.borderColor = theme.borderColor;

  document.body.style.backgroundColor = theme.background;
  document.body.style.color = theme.color;
  document.body.dataset.theme = themeName;

  // Chart.defaults 修改後,已存在的圖表要手動 update() 才會重新套用
  allCharts.forEach(chart => chart.update());
}

// 漸層色背景:折線圖使用 Scriptable Options 動態產生垂直漸層
let cachedGradient, cachedWidth, cachedHeight;
function getGradient(ctx, chartArea) {
  const chartWidth = chartArea.right - chartArea.left;
  const chartHeight = chartArea.bottom - chartArea.top;

  if (!cachedGradient || cachedWidth !== chartWidth || cachedHeight !== chartHeight) {
    cachedWidth = chartWidth;
    cachedHeight = chartHeight;
    cachedGradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
    cachedGradient.addColorStop(0, 'rgba(76, 110, 245, 0.05)');
    cachedGradient.addColorStop(1, 'rgba(76, 110, 245, 0.6)');
  }
  return cachedGradient;
}

// 圖表 1:折線圖 + 漸層背景
const lineChart = new Chart(document.getElementById('lineChart'), {
  type: 'line',
  data: {
    labels: ['一月', '二月', '三月', '四月', '五月'],
    datasets: [{
      label: '月營收趨勢(萬元)',
      data: [30, 45, 42, 58, 66],
      borderColor: '#4c6ef5',
      backgroundColor: (context) => {
        const { chart } = context;
        const { ctx, chartArea } = chart;
        if (!chartArea) return; // 圖表尚未完成版面配置,先不回傳漸層
        return getGradient(ctx, chartArea);
      },
      fill: true, // 折線圖需要 fill: true 才會套用 backgroundColor
      tension: 0.3
    }]
  },
  options: { responsive: true, maintainAspectRatio: false }
});

// 圖表 2:長條圖 + 品牌色票(依索引循環取色)
const barChart = new Chart(document.getElementById('barChart'), {
  type: 'bar',
  data: {
    labels: ['台北', '台中', '台南', '高雄'],
    datasets: [{
      label: '各分店營收(萬元)',
      data: [120, 95, 88, 102],
      backgroundColor: (context) => getBrandColor(context.dataIndex)
    }]
  },
  options: { responsive: true, maintainAspectRatio: false }
});

allCharts.push(lineChart, barChart);
applyTheme('light'); // 頁面載入時套用預設主題

document.getElementById('themeToggleBtn').addEventListener('click', () => {
  const next = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
  applyTheme(next);
});

https://ithelp.ithome.com.tw/upload/images/20260810/201718295V6STG0Idc.png

重點解說

  1. 全域預設值:頁面載入時透過 Chart.defaults 統一設定字型與文字顏色。
  2. 深色模式切換:一顆按鈕可以在淺色/深色主題間切換,兩張圖表(折線圖、長條圖)與整個頁面背景會同步更新。
  3. 品牌色調色盤 + 漸層背景:折線圖使用 Scriptable Options 產生垂直漸層背景,長條圖則依序套用品牌色票中的顏色。

點擊「切換深色模式」按鈕,觀察畫面背景、格線、文字顏色是否同步切換;同時留意折線圖下方的漸層填色在深色模式下是否依然清晰可辨識。


明天(Day 25)我們會離開純 JavaScript 的世界,開始把 Chart.js 與現代前端框架整合:學習使用 react-chartjs-2 套件,把圖表拆解成可重複使用的 React 元件,並透過 props 傳遞資料、搭配 useState / useEffect 實現資料的動態更新。

參考資源


上一篇
Day 23 - 30 天手把手學會 Chart.js|自訂外掛開發
下一篇
Day 25 - 30 天手把手學會 Chart.js|與 React 整合
系列文
30 天手把手學會 Chart.js v4:從圖表基礎到互動式資料視覺化實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言