Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
108 changes: 82 additions & 26 deletions app/_docs/demos/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
type ChartConfig,
ChartContainer,
primaryShades,
ChartLegend,
ChartLegendContent,
ChartTooltip,
Expand All @@ -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 (
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data} barGap={2}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(value: number) => `${value / 1000}k`}
/>
<ChartTooltip
content={<ChartTooltipContent formatValue={(value) => ntd(value)} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="income"
fill="var(--color-income)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="expense"
fill="var(--color-expense)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
<div className="flex w-full flex-col gap-12">
<ChartContainer config={config} className="h-64 w-full">
<BarChart data={data} barGap={2}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(value: number) => `${value / 1000}k`}
/>
<ChartTooltip
content={
<ChartTooltipContent formatValue={(value) => ntd(value)} />
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="income"
fill="var(--color-income)"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="expense"
fill="var(--color-expense)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
<ChartContainer config={usageConfig} className="h-64 w-full">
<BarChart data={usage}>
<CartesianGrid vertical={false} />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<YAxis
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(value: number) => `${value}k`}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatValue={(value) => `${value}k tokens`}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{models.map((model, j) => (
<Bar
key={model.key}
dataKey={model.key}
stackId="tokens"
fill={`var(--color-${model.key})`}
radius={j === models.length - 1 ? [4, 4, 0, 0] : 0}
/>
))}
</BarChart>
</ChartContainer>
</div>
)
}
2 changes: 1 addition & 1 deletion app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export const docs: Doc[] = [
slug: "chart",
title: "圖表",
description:
"主要數列用主色,對照用灰色,有意義的數列(收入、支出)用狀態色。",
"主要數列用主色,對照用灰色,有意義的數列(收入、支出)用狀態色,同一個總量的組成用主色深淺。",
item: "chart",
},
{
Expand Down
40 changes: 36 additions & 4 deletions registry/winlab/ui/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<key> inside the chart. */
* color. The key becomes --color-<key> 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<string, { label: string; color: ChartColor }>

const ChartContext = React.createContext<ChartConfig | null>(null)
Expand All @@ -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 (
<ChartContext.Provider value={config}>
Expand Down
Loading