Charts 圖表
一組零相依的 SVG 圖表:不裝任何圖表庫,直接用 token 的色票畫。 八種圖各自回答一個問題,超出這八個問題的需求,這組元件刻意不做。
| 項目 | 數值 |
|---|---|
| 甲單位 | 420 |
| 乙單位 | 250 |
| 丙單位 | 610 |
| 丁單位 | 180 |
先講邊界:這組元件不適合什麼
圖表最容易出事的地方不是畫得好不好看,是被用在它撐不住的場景。 所以邊界寫在最前面。
後台的閱讀型圖表:一眼看懂現況、必要時點一下鑽取到明細清單。 資料點數百位數以內。
不要拿來做分析型互動:縮放、刷選(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} />
| 參數 | 作用 |
|---|---|
data | BarDatum[] |
showValues | 在長條頂端標數值。列印與觸控裝置一定要開(沒有 hover 就讀不到值) |
maxItems | 類別上限,超過時彙總成「其他(N 項)」 |
valueFmt | 數值格式器 |
onSelect / selectedIndex | 點擊鑽取(由宿主渲染明細清單)與選取態 |
color / height | 單一序列色、高度 |
Pareto 柏拉圖
長條由大到小 + 累積百分比折線。 回答「前幾項就佔掉多少」。
要做取捨排序時用:先處理哪幾項最划算(ABC 分析、退回原因、呆滯項目)。
類別之間本來就差不多時不要用。柏拉圖的價值來自不均, 平坦的柏拉圖只會讓人以為自己看漏了什麼。
累積線是本組唯一的「雙軸」,因為它是柏拉圖的定義,不是為了省一張圖而疊上去的。 即使如此也不畫第二條 Y 軸刻度——累積值只在提示裡以百分比呈現。
| 參數 | 作用 |
|---|---|
data | BarDatum[](元件內部自行由大到小排序,宿主不必先排) |
maxItems | 同上,超過彙總成「其他」 |
onSelect | 點擊鑽取 |
StackedBar 水平堆疊長條
看一個總量由哪些部分構成,同時比較多個總量。
分段 2–6 段,且各段語意固定(每一列的第 1 段永遠是同一件事)。
不要放 8 段以上。最窄的那幾段會細到看不見,也點不到—— 細碎項目請先在資料層合併成「其他」。
每一列右側固定顯示總量數字。這不是裝飾:堆疊圖最常被問的問題就是「所以總共多少」, 而人沒辦法用眼睛把幾個色塊加起來。
| 參數 | 作用 |
|---|---|
rows | 每列 label + segments: Segment[](顏色由宿主指定,理由見〈色票〉) |
onSelectRow / selectedRow | 整列點擊鑽取與選取態 |
height | 單列高度 |
- 甲類
- 乙類
- 丙類
| 項目 | 甲類 | 乙類 | 丙類 | 合計 |
|---|---|---|---|---|
| 甲單位 | 120 | 80 | 40 | 240 |
| 乙單位 | 60 | 150 | 30 | 240 |
TrendChart 趨勢折線
同一個量隨時間怎麼變。
x 軸是等距的時間(每月、每週)。至少 3 個點才看得出趨勢。
x 軸不是時間、或間距不等(例如只有幾個不連續的日期)→ 用 BarChart。
折線會讓人以為中間的值是連續變化的,那是無中生有。
zeroBased 預設 true(Y 軸從 0 起算)。
金額、數量這類「絕對量」一律 zeroBased。從 0 起算才不會把 2% 的波動
畫成懸崖。
只有在看「相對變化」且已明確標示起訖值時才關掉。關掉時務必讓兩端數值可見, 否則就是在誤導。
| 參數 | 作用 |
|---|---|
data | BarDatum[](label 當 x 軸刻度) |
zeroBased | Y 軸是否從 0 起算(預設 true) |
valueFmt | 端點值與提示的格式器 |
onSelect / selectedIndex | 點擊某期鑽取該期明細 |
Bullet 子彈圖
實際 vs 目標,一條就講完。
單一指標的達成率(預算執行、SLA、配額)。放在卡片頂端當摘要。
不要一次排十條當主要視覺。十條子彈圖等於一張沒有排序的長條圖,
那時該用 BarChart 並排序。
這是唯一使用狀態色的圖:超出目標走 --danger、未超出走 --success,
不用分類色票。理由見〈色票〉。
「超出」是好是壞由宿主決定語意——本元件的預設假設是「目標=上限」(例如預算)。 若你的指標是「目標=下限」(例如營收),請改由文案講清楚, 不要讓讀者自己猜紅色代表什麼。
| 參數 | 作用 |
|---|---|
value / target | 實際值與目標值 |
label | 指標名稱 |
Scatter 散布圖
兩個數值之間有沒有關係。
兩軸都是連續數值、點數 20–300、你真的想看相關性。
點數少於 10 不要用——三五個點看不出任何關係,卻很容易讓人腦補出一條線。 點數上千也不要用(見〈邊界〉)。
軸範圍取自資料的 min/max(不從 0 起算),因為散布圖看的是分散形狀不是絕對大小。
必須標 xLabel / yLabel,否則兩軸各是什麼完全無從得知。
| 參數 | 作用 |
|---|---|
points | Point[],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。
不要在多張並排的熱圖上各自用自動值域。每張圖的「最紅」代表不同的數字, 並排比較就是錯的——而且錯得完全看不出來。
| 甲類 | 乙類 | 丙類 | 丁類 | |
|---|---|---|---|---|
| 甲單位 | 120 | 45 | — | 8 |
| 乙單位 | 60 | 150 | 12 | — |
| 丙單位 | — | 30 | 95 | 0 |
淺 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 類一樣的顏色,圖例會出現兩個
同色不同名的項目。
BarChart與Pareto內建maxItems(預設 12), 其他圖種在資料層先彙總 - 要強調某一類:highlight + mute——選取後其餘淡化(
selectedIndex已內建:其餘降到 35% 不透明度),重點自然浮出。不要為了強調把某類換成 更鮮豔的色相——那會被讀成不同種類,而不是同一種類的重點 Pareto是刻意的例外:整張圖的語意就是排序,單一顏色、不做分類配色
空資料、單點、全零
這三種是「圖表看起來壞掉了」的三大來源,各有各的處理。
| 情境 | 元件行為 | 宿主要補什麼 |
|---|---|---|
空資料(data.length === 0) | 顯示一行「無資料」,不畫空白座標軸 | 用 EmptyState 說清楚是「還沒有資料」還是「篩選後沒有結果」——元件分不出這兩者 |
| 單一資料點 | 畫出該點並置中,不畫線 | 一個點沒有趨勢可言。若圖表標題寫著「趨勢」,此時應改顯示單一數值 |
| 全部為零 | 座標軸正常,所有長度為 0(內部以 Math.max(1, …) 保底避免除以零) | 明說「本期皆為 0」。否則使用者會以為資料沒載入 |
| 值全部相同(熱圖) | 色階值域自動撐開,避免整張同色 | 若這在業務上有意義,用文字說明 |
「沒有資料」和「資料是 0」要能分辨。前者用空狀態,後者畫出來並註明。
不要讓兩者都變成空白區塊。使用者無法判斷是系統壞了、篩選錯了, 還是這期真的是零。
無障礙:顏色不能是唯一線索
這是圖表最難做到、也最常被跳過的一條。
| 做法 | 為什麼 |
|---|---|
| 直接標示優先於圖例 | StackedBar 每列右側標總量、BarChart 開 showValues。眼睛不必在圖例與圖形之間來回對照 |
| 圖例一定有文字 | 只有色塊的圖例,灰階列印後全部一樣 |
| 相鄰序列取色票兩端 | 色票已把色覺障礙下最難分辨的一段拆到陣列兩端,善用它 |
| 分段數壓在 6 以內 | 超過之後,任何色票在色覺障礙下都會有兩段撞在一起 |
| 提示內容不是唯一出口 | hover 提示對鍵盤與觸控使用者不可靠 |
每一張圖旁邊都要能拿到數字本身——同一份資料的 DataTable 或 CSV 匯出。
不要讓某個數字只存在於圖裡。那等於對鍵盤使用者、螢幕報讀器使用者、 以及把報表印出來覆核的人說「這份資料不給你」。
文字等價:role="img" 的義務
規則本身是通則,正本在無障礙四原則:
role="img" 會讓子樹對輔助技術隱藏,掛了就必須用文字補回(WCAG 1.1.1,Level A);
文字出口用 sr-only 表格、不做 SVG 焦點管理;數值已在鄰近可見文字時圖形改標 aria-hidden。
這裡不重講——同一條規則有兩份說明,必然會分歧。
本頁只留各圖種對應到哪一種做法:
| 圖種 | 做法 |
|---|---|
BarChart/Pareto/TrendChart/LineChart/Scatter | aria-label 摘要(由資料自動生成)+ 一份 sr-only 的 <table>(<caption>+scope),把原本只有 hover 拿得到的精確數值補成文字 |
StackedBar/Bullet | 數值已在鄰近可見文字(列標籤+合計/實際+目標+差額)→ 圖形標 aria-hidden,避免同一份資料唸兩次;StackedBar 的分段明細改由整體表格提供 |
Heatmap | 本來就是真表格(數值是可見文字、顏色只是輔助)→ 不加額外表格,改補正表格語意(caption/scope/列標頭用 <th scope="row">) |
鍵盤等價:資料表兼任操作介面
hover 提示與點擊鑽取對鍵盤使用者不可達(WCAG 2.1.1,Level A)。 解法不是 SVG 焦點管理(理由同上節),而是讓文字等價的那張資料表兼任鍵盤介面:
- 有
onSelect的圖,資料表的每一列是一顆真按鈕,Enter 觸發同一個鑽取回呼 - 資料表平常
sr-only,鍵盤焦點進入時現形(同 skip-link 慣例)—— 明眼的鍵盤使用者不會把焦點丟進看不見的地方 - 來源專案的實作沒有做這一層,收錄時已補上——這是收錄的前提,不是後續優化
用 Tab 走到下面這個活範例就看得到:
| 項目 | 數值 |
|---|---|
| 34 | |
| 51 | |
| 22 |
列印
圖表用 currentColor 與 token 變數上色,列印樣式下會跟著主題走。
兩個注意事項:
- 部分印表機預設不印背景色。長條與色塊會消失——這是
showValues與「直接標示」 在列印情境下不只是加分項的原因。 - 圖表卡片套
print-block(break-inside: avoid),避免一張圖被切成兩頁。 見列印與匯出。
互動示範
上面的活範例都是靜態資料。要玩鑽取、選取態、資料表現形,用 Storybook 的版本:
取用
npx shadcn@latest add https://kielchang.github.io/dooping-design-book/r/charts.json
一個 item 帶走整組:八種圖+Legend+共同底座(型別、PALETTE、capItems、
文字/鍵盤等價表)。相依只有 @dooping/tokens(色票與軸線變數)與 utils(cn)——
沒有圖表庫、沒有 d3、沒有 canvas polyfill。
抄進宿主後的匯入長這樣:
import { BarChart } from "@/components/dooping/bar-chart";
import { PALETTE, capItems } from "@/components/dooping/base";
色彩驗收不需要新增守衛:npm run verify:color 已經在驗這 8 色的分類色票
(彼此的色覺障礙 ΔE00、對四種底色的對比、以及不得靠近六個狀態色)。