跳至主要内容
💬 回報這一頁

Charts 圖表

一組零相依的 SVG 圖表:不裝任何圖表庫,直接用 token 的色票畫。 八種圖各自回答一個問題,超出這八個問題的需求,這組元件刻意不做。

八種圖都是真元件(資料是行內宣告的兩鍵示範值)
甲單位:420420甲單位乙單位:250250乙單位丙單位:610610丙單位丁單位:180180丁單位
各單位數量(資料表)
項目數值
甲單位420
乙單位250
丙單位610
丁單位180
本期進度(目標=上限)640 / 目標 80080%)
互動 playground——可切圖表類型與資料點數

先講邊界:這組元件不適合什麼

圖表最容易出事的地方不是畫得好不好看,是被用在它撐不住的場景。 所以邊界寫在最前面。

✅ 這樣做

後台的閱讀型圖表:一眼看懂現況、必要時點一下鑽取到明細清單。 資料點數百位數以內

🚫 不要這樣

不要拿來做分析型互動:縮放、刷選(brush)、十字游標讀值、 圖內鑽取後再鑽取——這些一個都沒做,硬補會變成半套的圖表庫。 真的需要,請直接用成熟圖表庫,不要改造這組。

限制說明
資料量SVG 逐點渲染。上千個節點就會開始卡(每點都是 DOM 元素,hover 還要各自綁事件)。超過約 500 點請改 canvas 方案,或先在資料層彙總
互動只有 hover 提示與 onSelect 回呼。沒有縮放、刷選、拖曳、圖內編輯
只畫必要的基準線與端點值,沒有完整刻度系統、沒有雙 Y 軸(Pareto 的累積線是唯一例外,理由見下)
即時沒有串流/動畫過場。資料換了就重畫

為什麼刻意這麼窄:圖表庫是後台前端最常見的 bundle 肥大來源, 而後台系統九成的圖其實只需要「把數字畫成長度」。 把功能收在這條線以內,就不需要任何相依,換色票、換主題、列印都不會打架。

何時用哪種圖

先問「這張圖要回答什麼問題」,再挑圖種。不要先挑圖種再想要放什麼。

使用者的問題意圖用哪種
誰比較多/比較少?比較BarChart
是不是少數幾項佔了大部分?集中度Pareto
這個總量是由哪些部分組成的?組成StackedBar
隨時間怎麼變?趨勢TrendChart
實際離目標還有多遠?達成率Bullet
這兩個數值有關係嗎?相關性Scatter
兩個維度交叉起來,哪裡集中?分布(二維)Heatmap
前 20% 拿走了多少?累積分布/不均程度LineChart(配對角基準線)
✅ 這樣做

一張卡片一張圖、一個問題。旁邊用一句話寫結論 (Callout 或圖表卡的說明列)。

🚫 不要這樣

不要把兩個問題疊在一張圖上。「順便也把去年放上來」通常會讓兩個問題都看不清楚—— 那是兩張圖。

沒有圓餅圖,這是刻意的

圓餅圖要人比較「角度」,而人比較長度遠比比較角度準。 超過 3 塊就分不出誰大誰小,還必須另外放圖例才知道每塊是什麼。

要看組成 → StackedBar(一列就是一個總量,長度直接可比)。 要看集中度 → Pareto(順便告訴你「前幾項就佔了八成」)。

八種圖

以下每一種都列出:用途 · 何時不要用 · 資料形狀 · 可調參數。 資料形狀用共同型別:

type BarDatum = { label: string; value: number; id?: string };
type Segment = { label: string; value: number; color: string };
type Point = { x: number; y: number; label?: string; id?: string };

BarChart 長條圖

比較同一個量在不同類別間的大小。

✅ 這樣做

類別 3–12 個、標籤短、只有一個量要比。

🚫 不要這樣

類別是時間(月份、週)→ 用 TrendChart。 長條圖不表達「順序連續」,讀者不會自然看出趨勢。

<BarChart data={[{ label: "北區", value: 1_200_000 },]} showValues maxItems={12} />
參數作用
dataBarDatum[]
showValues在長條頂端標數值。列印與觸控裝置一定要開(沒有 hover 就讀不到值)
maxItems類別上限,超過時彙總成「其他(N 項)」
valueFmt數值格式器
onSelect / selectedIndex點擊鑽取(由宿主渲染明細清單)與選取態
color / height單一序列色、高度

Pareto 柏拉圖

長條由大到小 + 累積百分比折線。 回答「前幾項就佔掉多少」。

✅ 這樣做

要做取捨排序時用:先處理哪幾項最划算(ABC 分析、退回原因、呆滯項目)。

🚫 不要這樣

類別之間本來就差不多時不要用。柏拉圖的價值來自不均, 平坦的柏拉圖只會讓人以為自己看漏了什麼。

累積線是本組唯一的「雙軸」,因為它是柏拉圖的定義,不是為了省一張圖而疊上去的。 即使如此也不畫第二條 Y 軸刻度——累積值只在提示裡以百分比呈現。

參數作用
dataBarDatum[](元件內部自行由大到小排序,宿主不必先排)
maxItems同上,超過彙總成「其他」
onSelect點擊鑽取

StackedBar 水平堆疊長條

看一個總量由哪些部分構成,同時比較多個總量。

✅ 這樣做

分段 2–6 段,且各段語意固定(每一列的第 1 段永遠是同一件事)。

🚫 不要這樣

不要放 8 段以上。最窄的那幾段會細到看不見,也點不到—— 細碎項目請先在資料層合併成「其他」。

每一列右側固定顯示總量數字。這不是裝飾:堆疊圖最常被問的問題就是「所以總共多少」, 而人沒辦法用眼睛把幾個色塊加起來。

參數作用
rows每列 labelsegments: Segment[](顏色由宿主指定,理由見〈色票〉)
onSelectRow / selectedRow整列點擊鑽取與選取態
height單列高度
每列右側固定顯示總量;圖例配 Legend
  • 甲類
  • 乙類
  • 丙類
甲單位240
乙單位240
各單位分類組成(各列分段明細)
項目甲類乙類丙類合計
甲單位1208040240
乙單位6015030240

TrendChart 趨勢折線

同一個量隨時間怎麼變。

✅ 這樣做

x 軸是等距的時間(每月、每週)。至少 3 個點才看得出趨勢。

🚫 不要這樣

x 軸不是時間、或間距不等(例如只有幾個不連續的日期)→ 用 BarChart。 折線會讓人以為中間的值是連續變化的,那是無中生有。

zeroBased 預設 true(Y 軸從 0 起算)。

✅ 這樣做

金額、數量這類「絕對量」一律 zeroBased。從 0 起算才不會把 2% 的波動 畫成懸崖。

🚫 不要這樣

只有在看「相對變化」且已明確標示起訖值時才關掉。關掉時務必讓兩端數值可見, 否則就是在誤導。

參數作用
dataBarDatum[]label 當 x 軸刻度)
zeroBasedY 軸是否從 0 起算(預設 true)
valueFmt端點值與提示的格式器
onSelect / selectedIndex點擊某期鑽取該期明細

Bullet 子彈圖

實際 vs 目標,一條就講完。

✅ 這樣做

單一指標的達成率(預算執行、SLA、配額)。放在卡片頂端當摘要。

🚫 不要這樣

不要一次排十條當主要視覺。十條子彈圖等於一張沒有排序的長條圖, 那時該用 BarChart 並排序。

這是唯一使用狀態色的圖:超出目標走 --danger、未超出走 --success, 不用分類色票。理由見〈色票〉。

「超出」是好是壞由宿主決定語意——本元件的預設假設是「目標=上限」(例如預算)。 若你的指標是「目標=下限」(例如營收),請改由文案講清楚, 不要讓讀者自己猜紅色代表什麼。

參數作用
value / target實際值與目標值
label指標名稱

Scatter 散布圖

兩個數值之間有沒有關係。

✅ 這樣做

兩軸都是連續數值、點數 20–300、你真的想看相關性。

🚫 不要這樣

點數少於 10 不要用——三五個點看不出任何關係,卻很容易讓人腦補出一條線。 點數上千也不要用(見〈邊界〉)。

軸範圍取自資料的 min/max(不從 0 起算),因為散布圖看的是分散形狀不是絕對大小。 必須標 xLabel / yLabel,否則兩軸各是什麼完全無從得知。

參數作用
pointsPoint[]label 用於提示
xLabel / yLabel軸說明(建議必填
onSelect點擊某點鑽取該筆

Heatmap 熱圖

兩個維度交叉之後,值集中在哪裡。

✅ 這樣做

列 × 欄都是類別(區域 × 分類、月份 × 項目),格子數在 100 以內。

🚫 不要這樣

維度值很多(50 × 50)不要用。人沒辦法在 2500 格裡找出模式, 那需要排序後的清單或 Pareto

null 代表無資料,不是 0。 元件會把 null 畫成灰底並顯示 , 圖例也會註明「灰=無資料」。把無資料當成 0 是熱圖最常見的謊言: 沒有發生和發生了但金額為零,是兩件完全不同的事。

參數作用
rowLabels / colLabels兩軸類別
cells(number | null)[][],維度為列 × 欄
domain色階值域。不給就依當期資料自動取 min/max
fmt格內數值格式器
legend是否顯示色階圖例(預設開)
onSelect點格鑽取
✅ 這樣做

跨期比較時務必手動指定 domain

🚫 不要這樣

不要在多張並排的熱圖上各自用自動值域。每張圖的「最紅」代表不同的數字, 並排比較就是錯的——而且錯得完全看不出來。

null 畫成灰底+「—」,跟 0 分得開
單位 × 分類數量
甲類乙類丙類丁類
甲單位120458
乙單位6015012
丙單位30950

0 → 深 150;灰=無資料

LineChart 累積分布折線

累積比例曲線,配對角基準線看「離平均分布差多遠」。

✅ 這樣做

看不均程度:前 20% 的往來對象貢獻了多少金額、前 20% 的項目佔多少結存值。

🚫 不要這樣

不要拿來畫時間序列——那是 TrendChart。 這支的兩軸都是 0–1 的比例,語意不同。

參數作用
points{ x, y }兩者都必須先正規化到 0–1
diagonal是否畫對角基準線(完全平均分布)

對角線是這張圖的全部意義所在:曲線離對角線越遠=越集中。沒有基準線,這條曲線讀不出任何東西。

Legend 圖例

色塊 + 文字標籤的清單。色塊永遠不會單獨出現。

<Legend items={[{ label: "通路 A", color: PALETTE[0] }, { label: "通路 B", color: PALETTE[1] }]} />

只有多序列的圖(StackedBar)需要圖例。單序列的圖不要放圖例—— 一個顏色配一行說明只是佔位子,標題已經說完了。

配色策略:先問資料形態,再問維度語意

畫任何一張圖之前,顏色的來源由這棵判斷樹決定。目的只有一個: 顏色永遠回答同一個問題——使用者在徽章上學到的「綠=完成」, 到了圖表裡不能變成別的意思;長期使用後,看到顏色就知道系統在說什麼。

資料是連續數值嗎?

├─ 是,單向(量的大小)──▶ Sequential:單色相染色量
│ Heatmap 就是這層(PALETTE[0] 染色量 8–78%)。跨期比較鎖 domain。

├─ 是,雙向(好壞偏差)──▶ Diverging:語意色相淡階,中性中點
│ danger-subtle ←── muted ──→ success-subtle
│ 兩極亮度對稱;值域對稱(-max, +max)讓中點對齊零。
│ 這是 Delta 的紅綠邏輯在連續資料上的延伸。

└─ 否(離散類別)──▶ 三層,依序判斷:
① 語意:維度值已有系統語意(狀態、好壞、變更)→ STATUS_SERIES
② 身分:維度會跨圖表、跨期別反覆出現 → colorByKey
③ 區辨:一次性比較 → PALETTE 照序

序列色一律從 token 取(--chart-1--chart-8,見色彩語意; 選色程序見怎麼選一組分類色票)。 狀態色語意的正本在提醒色辭典

第 1 層【語意】:維度本身就是狀態時,必須沿用狀態色

「分類色不表達狀態」有一個容易漏掉的反面:維度值本身就是系統語意時 (狀態組成、好壞分佈、變更與否),照序取 PALETTE 反而是錯的—— 「已完成」在徽章上是綠的、在堆疊圖裡變成藍的,使用者剛建立的語意記憶 被同一個畫面拆掉。

import { STATUS_SERIES } from "@/components/dooping/base";

// 狀態組成堆疊:done=success、confirmed=info、void=danger
segments: [{ label: "已完成", value: 12, color: STATUS_SERIES.success },]
✅ 這樣做

語意堆疊用 STATUS_SERIES淡底)。圖表分段是大面積色, 而提醒視窗與圖表重點色共用同一份高飽和面積預算——淡底才不會把警報的 視覺份量吃掉。實色版 STATUS_SERIES_STRONG 只給線、點這類小面積。 這與 Badge「預設淡底、實色給例外」是同一條強度語法。

🚫 不要這樣

不要把狀態維度餵給 PALETTE,也不要反過來拿 --success 當一般分類色。兩套語意分家是雙向的禁令。

第 2 層【身分】:反覆出現的維度,顏色跟實體走

同一個類別在所有圖表、所有期別必須是同一個顏色——顏色一旦與實體綁定, 它就成了使用者認得的身分。

import { colorByKey } from "@/components/dooping/base";

// 鍵清單是「維度的定義」:宿主宣告一次、所有圖表共用,與當期資料無關
const UNITS = ["甲單位", "乙單位", "丙單位", "丁單位"] as const;
<BarChart color={colorByKey("乙單位", UNITS)}/>
✅ 這樣做

固定的類別鍵清單決定色索引。清單寫在宿主的常數裡, 超過 8 個或找不到的鍵自動退 muted(該進「其他」的實體 不該搶到會撞色的新顏色)。

🚫 不要這樣

不要依「當期數值排序後的名次」配色——最自然的寫法 PALETTE[i](照當期陣列順序)恰好就是這個錯。上月排第一的類別 這月排第三,顏色就換了,讀者會以為在看不同的東西。

同一實體的子狀態(預估 vs 實際、本期 vs 上期)用同色相降階 (透明度或淡階),不佔第二個色票位——不同色相會被讀成另一個獨立分類。

第 3 層【區辨】:一次性比較才照序取用

維度沒有語意、也不會反覆出現時,PALETTE 照序取用即可—— 順序即安全順序,前 k 色永遠是近似最佳的 k 色子集。

超過 8 類不加色、要強調不換色:

// 超過封頂:取前 N-1 名,其餘彙總成「其他(N 項)」
const shown = capItems(data, 8);
  • 不要循環取色——第 9 類拿到跟第 1 類一樣的顏色,圖例會出現兩個 同色不同名的項目。BarChartPareto 內建 maxItems(預設 12), 其他圖種在資料層先彙總
  • 要強調某一類:highlight + mute——選取後其餘淡化(selectedIndex 已內建:其餘降到 35% 不透明度),重點自然浮出。不要為了強調把某類換成 更鮮豔的色相——那會被讀成不同種類,而不是同一種類的重點
  • Pareto 是刻意的例外:整張圖的語意就是排序,單一顏色、不做分類配色

空資料、單點、全零

這三種是「圖表看起來壞掉了」的三大來源,各有各的處理。

情境元件行為宿主要補什麼
空資料data.length === 0顯示一行「無資料」,不畫空白座標軸EmptyState 說清楚是「還沒有資料」還是「篩選後沒有結果」——元件分不出這兩者
單一資料點畫出該點並置中,不畫線一個點沒有趨勢可言。若圖表標題寫著「趨勢」,此時應改顯示單一數值
全部為零座標軸正常,所有長度為 0(內部以 Math.max(1, …) 保底避免除以零)明說「本期皆為 0」。否則使用者會以為資料沒載入
值全部相同(熱圖)色階值域自動撐開,避免整張同色若這在業務上有意義,用文字說明
✅ 這樣做

「沒有資料」和「資料是 0」要能分辨。前者用空狀態,後者畫出來並註明。

🚫 不要這樣

不要讓兩者都變成空白區塊。使用者無法判斷是系統壞了、篩選錯了, 還是這期真的是零。

無障礙:顏色不能是唯一線索

這是圖表最難做到、也最常被跳過的一條。

做法為什麼
直接標示優先於圖例StackedBar 每列右側標總量、BarChartshowValues。眼睛不必在圖例與圖形之間來回對照
圖例一定有文字只有色塊的圖例,灰階列印後全部一樣
相鄰序列取色票兩端色票已把色覺障礙下最難分辨的一段拆到陣列兩端,善用它
分段數壓在 6 以內超過之後,任何色票在色覺障礙下都會有兩段撞在一起
提示內容不是唯一出口hover 提示對鍵盤與觸控使用者不可靠
✅ 這樣做

每一張圖旁邊都要能拿到數字本身——同一份資料的 DataTable 或 CSV 匯出。

🚫 不要這樣

不要讓某個數字只存在於圖裡。那等於對鍵盤使用者、螢幕報讀器使用者、 以及把報表印出來覆核的人說「這份資料不給你」。

文字等價:role="img" 的義務

規則本身是通則,正本在無障礙四原則role="img" 會讓子樹對輔助技術隱藏,掛了就必須用文字補回(WCAG 1.1.1,Level A); 文字出口用 sr-only 表格、不做 SVG 焦點管理;數值已在鄰近可見文字時圖形改標 aria-hidden。 這裡不重講——同一條規則有兩份說明,必然會分歧。

本頁只留各圖種對應到哪一種做法

圖種做法
BarChartParetoTrendChartLineChartScatteraria-label 摘要(由資料自動生成)+ 一份 sr-only<table><caption>scope),把原本只有 hover 拿得到的精確數值補成文字
StackedBarBullet數值已在鄰近可見文字(列標籤+合計/實際+目標+差額)→ 圖形標 aria-hidden,避免同一份資料唸兩次;StackedBar 的分段明細改由整體表格提供
Heatmap本來就是真表格(數值是可見文字、顏色只是輔助)→ 不加額外表格,改補正表格語意(captionscope/列標頭用 <th scope="row">

鍵盤等價:資料表兼任操作介面

hover 提示與點擊鑽取對鍵盤使用者不可達(WCAG 2.1.1,Level A)。 解法不是 SVG 焦點管理(理由同上節),而是讓文字等價的那張資料表兼任鍵盤介面

  • onSelect 的圖,資料表的每一列是一顆真按鈕,Enter 觸發同一個鑽取回呼
  • 資料表平常 sr-only鍵盤焦點進入時現形(同 skip-link 慣例)—— 明眼的鍵盤使用者不會把焦點丟進看不見的地方
  • 來源專案的實作沒有做這一層,收錄時已補上——這是收錄的前提,不是後續優化

用 Tab 走到下面這個活範例就看得到:

Tab 進圖表,資料表會現形(列是可按的)
甲類:34甲類乙類:51乙類丙類:22丙類
各分類批次數(資料表)
項目數值
34
51
22

列印

圖表用 currentColor 與 token 變數上色,列印樣式下會跟著主題走。 兩個注意事項:

  • 部分印表機預設不印背景色。長條與色塊會消失——這是 showValues 與「直接標示」 在列印情境下不只是加分項的原因。
  • 圖表卡片套 print-blockbreak-inside: avoid),避免一張圖被切成兩頁。 見列印與匯出

互動示範

上面的活範例都是靜態資料。要玩鑽取、選取態、資料表現形,用 Storybook 的版本:

長條與柏拉圖:點擊鑽取在 Storybook 開啟

取用

npx shadcn@latest add https://kielchang.github.io/dooping-design-book/r/charts.json

一個 item 帶走整組:八種圖+Legend+共同底座(型別、PALETTEcapItems、 文字/鍵盤等價表)。相依只有 @dooping/tokens(色票與軸線變數)與 utilscn)—— 沒有圖表庫、沒有 d3、沒有 canvas polyfill。

抄進宿主後的匯入長這樣:

import { BarChart } from "@/components/dooping/bar-chart";
import { PALETTE, capItems } from "@/components/dooping/base";

色彩驗收不需要新增守衛:npm run verify:color 已經在驗這 8 色的分類色票 (彼此的色覺障礙 ΔE00、對四種底色的對比、以及不得靠近六個狀態色)。