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 (