diff --git a/DESIGN.md b/DESIGN.md
index dc71d40..331e1a5 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -129,8 +129,9 @@ Charts use the same colors, not chart-specific tokens:
| The main series | `primary` |
| A comparison (last year, average) | `muted-foreground` |
| A series with a meaning (income, expense, overdue) | `success`, `destructive`, `warning` |
+| Several series of one kind stacked into one total (usage by model, by key) | `primary`, then `primary-2` to `primary-6` (`primaryShades`), each lighter; the rest merged into one 其他 in `muted-foreground` |
-A chart that needs more than two series to be told apart by color is split into small multiples or shown as a table. No Tailwind palette colors and no color literals.
+A chart that needs more than two series to be told apart by color is split into small multiples or shown as a table, unless the series are parts of one total: then they stack, in shades of `primary` ordered largest first, at most six before 其他. No Tailwind palette colors and no color literals.
One exception: a handwritten signature is black ink on white paper in every theme, because it ends up on printed documents. `signature-pad` keeps both colors inside its canvas and preview; nothing else may use them.
diff --git a/app/_docs/demos/chart.tsx b/app/_docs/demos/chart.tsx
index 84ae01b..fd00360 100644
--- a/app/_docs/demos/chart.tsx
+++ b/app/_docs/demos/chart.tsx
@@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
type ChartConfig,
ChartContainer,
+ primaryShades,
ChartLegend,
ChartLegendContent,
ChartTooltip,
@@ -26,33 +27,88 @@ const config = {
const ntd = (value: number) => `NT$ ${value.toLocaleString("en-US")}`
+// Parts of one total: tokens per day, stacked by model.
+// Keys become CSS variable names, so they cannot hold "." (qwen3.8):
+// key each series by position and keep the name in its label.
+const models = ["qwen3.8", "claude-sonnet", "qwen-image", "mineru"].map(
+ (label, j) => ({ key: `m${j}`, label })
+)
+const usage = ["10/10", "10/11", "10/12", "10/13", "10/14"].map((day, i) => ({
+ day,
+ ...Object.fromEntries(
+ models.map((model, j) => [
+ model.key,
+ ((i * 7 + j * 5) % 9) * 9 + 40 - j * 8,
+ ])
+ ),
+}))
+const usageConfig = Object.fromEntries(
+ models.map((model, j) => [
+ model.key,
+ { label: model.label, color: primaryShades[j] },
+ ])
+) satisfies ChartConfig
+
export function Demo() {
return (
-
-
-
-
- `${value / 1000}k`}
- />
- ntd(value)} />}
- />
- } />
-
-
-
-
+
+
+
+
+
+ `${value / 1000}k`}
+ />
+ ntd(value)} />
+ }
+ />
+ } />
+
+
+
+
+
+
+
+
+ `${value}k`}
+ />
+ `${value}k tokens`}
+ />
+ }
+ />
+ } />
+ {models.map((model, j) => (
+
+ ))}
+
+
+
)
}
diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts
index 73d75ce..4d151d8 100644
--- a/app/_docs/docs.ts
+++ b/app/_docs/docs.ts
@@ -334,7 +334,7 @@ export const docs: Doc[] = [
slug: "chart",
title: "圖表",
description:
- "主要數列用主色,對照用灰色,有意義的數列(收入、支出)用狀態色。",
+ "主要數列用主色,對照用灰色,有意義的數列(收入、支出)用狀態色,同一個總量的組成用主色深淺。",
item: "chart",
},
{
diff --git a/registry/winlab/ui/chart.tsx b/registry/winlab/ui/chart.tsx
index d4e3808..bd466aa 100644
--- a/registry/winlab/ui/chart.tsx
+++ b/registry/winlab/ui/chart.tsx
@@ -6,12 +6,44 @@ import * as RechartsPrimitive from "recharts"
import { cn } from "@/registry/winlab/lib/utils"
/** The only colors a series may take (DESIGN.md, Color): the main series,
- * a comparison, or a series whose meaning is a state. */
+ * a comparison, a series whose meaning is a state, or one of up to six
+ * series of the same kind stacked into one total (primary, then primary-2
+ * to primary-6, each lighter). */
export type ChartColor =
- "primary" | "muted-foreground" | "success" | "destructive" | "warning"
+ | "primary"
+ | "muted-foreground"
+ | "success"
+ | "destructive"
+ | "warning"
+ | PrimaryShade
+
+type PrimaryShade = `primary-${2 | 3 | 4 | 5 | 6}`
+
+// How much primary each shade keeps, mixed toward the page background so
+// the steps hold in both themes.
+const shadeMix = { 2: 80, 3: 64, 4: 50, 5: 38, 6: 28 } as const
+
+/** Colors for n stacked series of the same kind, darkest first. Past six,
+ * merge the rest into one series (其他) in muted-foreground. */
+export const primaryShades: ChartColor[] = [
+ "primary",
+ "primary-2",
+ "primary-3",
+ "primary-4",
+ "primary-5",
+ "primary-6",
+]
+
+function colorValue(color: ChartColor) {
+ const shade = /^primary-(\d)$/.exec(color)
+ if (!shade) return `var(--${color})`
+ const mix = shadeMix[Number(shade[1]) as keyof typeof shadeMix]
+ return `color-mix(in oklch, var(--primary) ${mix}%, var(--background))`
+}
/** One entry per series, keyed by its dataKey: what to call it and its
- * color. The key becomes --color- inside the chart. */
+ * color. The key becomes --color- inside the chart, so it must be a
+ * CSS name: key series by position (s0, s1) when names hold "." or spaces. */
export type ChartConfig = Record
const ChartContext = React.createContext(null)
@@ -38,7 +70,7 @@ function ChartContainer({
}) {
const id = React.useId().replace(/:/g, "")
const variables = Object.entries(config)
- .map(([key, series]) => `--color-${key}: var(--${series.color});`)
+ .map(([key, series]) => `--color-${key}: ${colorValue(series.color)};`)
.join(" ")
return (