import {
MiniChart,
MiniRing,
type MiniChartType,
} from "@/components/ui/mini-chart/mini-chart"
const label = (index: number) => `10-${String(index + 1).padStart(2, "0")}`
const series = (values: number[]) =>
values.map((value, index) => ({ label: label(index), value }))
type Row = {
name: string
hint: string
type: MiniChartType
tone: "blue" | "violet" | "amber" | "green"
data: ReturnType<typeof series>
value: string
trend: number
/** 越低越好的指标:下降显示为好的颜色 */
invert?: boolean
}
const ROWS: Row[] = [
{
name: "API 调用量",
hint: "万次 / 天",
type: "bar",
tone: "blue",
data: series([12, 19, 15, 26, 21, 31, 24, 29, 22, 34, 30, 41]),
value: "41",
trend: 18.2,
},
{
name: "博客访客",
hint: "人 / 天",
type: "area",
tone: "violet",
data: series([28, 27, 35, 29, 40, 57, 50, 38, 48, 47, 42, 51]),
value: "4810",
trend: -4.3,
},
{
name: "构建时长",
hint: "秒 / 次",
type: "line",
tone: "amber",
data: series([48, 52, 47, 51, 44, 46, 43, 45, 41, 42, 40, 38]),
value: "38",
trend: -20.8,
invert: true,
},
{
name: "每日部署",
hint: "次 / 天",
type: "step",
tone: "green",
data: series([2, 2, 3, 3, 3, 5, 5, 4, 4, 6, 6, 7]),
value: "7",
trend: 16.7,
},
]
// 迷你图嵌在一行文字里:名称、走势、当前值、涨跌各占一列
export default function MiniChartDemo() {
return (
<div className="w-full max-w-xl divide-y divide-edge rounded-lg border border-edge bg-card px-4 text-sm">
{ROWS.map((row) => (
<div
key={row.name}
className="grid grid-cols-[minmax(4.5rem,1fr)_minmax(3rem,10rem)_auto_auto] items-center gap-3 py-3 sm:gap-4"
>
<div className="min-w-0">
<p className="truncate font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">{row.hint}</p>
</div>
<MiniChart
type={row.type}
tone={row.tone}
data={row.data}
highlight={row.type === "bar" ? "last" : undefined}
seriesLabel={row.name}
label={`近 12 天${row.name}走势`}
className="h-9"
/>
<span className="text-right font-semibold tabular-nums">
{row.value}
</span>
<span
className={
row.trend >= 0 !== Boolean(row.invert)
? "text-right text-xs text-(--flow-green) tabular-nums"
: "text-right text-xs text-(--flow-rose) tabular-nums"
}
>
{row.trend >= 0 ? "+" : ""}
{row.trend}%
</span>
</div>
))}
<div className="flex items-center gap-4 py-3">
<MiniRing
value={86}
tone="cyan"
size={40}
thickness={4}
label="缓存命中率 86%"
/>
<div className="min-w-0 flex-1">
<p className="font-medium">缓存命中率</p>
<p className="text-xs text-muted-foreground">
命中 1.2 万 · 回源 1,950
</p>
</div>
<span className="font-semibold tabular-nums">86%</span>
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/mini-chart.json条目依赖 shadcn 的 chart(recharts 的封装),安装时会一并添加。示例里的卡片外壳用了 统计卡片,它不是必需的依赖。
用法
MiniChart 一个组件画四种图,用 type 切换;data 可以是纯数字,也可以是带标签的 { label, value }。高度用 className 控制,默认 h-16,宽度撑满父级。
import { MiniChart } from "@/components/ui/mini-chart/mini-chart"
export function Spark() {
return (
<MiniChart
type="area"
data={[28, 27, 35, 29, 40, 57, 50]}
seriesLabel="访客"
label="近 7 天博客访客走势"
/>
)
}label 是读屏名称,说清楚这张图表示什么。进度环 MiniRing 和环形图 MiniDonut 从同一个入口导入。
示例
指标面板
六块指标拼成一条小仪表盘,每块用不同图形与色调,底部是数值与涨跌。
import {
MiniChart,
MiniDonut,
MiniRing,
} from "@/components/ui/mini-chart/mini-chart"
import {
StatCard,
StatLabel,
StatTrend,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const label = (index: number) => `10-${String(index + 1).padStart(2, "0")}`
const series = (values: number[]) =>
values.map((value, index) => ({ label: label(index), value }))
const REQUESTS = series([12, 19, 15, 26, 21, 31, 24, 29, 22, 34, 30, 41])
const VISITS = series([28, 27, 35, 29, 40, 57, 50, 38, 48, 47, 42, 51])
const BUILD = series([48, 52, 47, 51, 44, 46, 43, 45, 41, 42, 40, 38])
const DEPLOYS = series([2, 2, 3, 3, 3, 5, 5, 4, 4, 6, 6, 7])
// 六块指标像一条小仪表盘:用 1px 间隙露出底色做分格线(窄屏两列、宽屏三列),每块用不同图形与 `--flow-*` 色调
const CELL = "gap-2 rounded-none border-0 p-3 sm:p-4"
/** 卡片底部一行:左边数值,右边涨跌幅;两者都不换行,窄卡片里数字也不会断开 */
function Reading({
value,
unit,
trend,
invert,
}: {
value: number | string
unit: string
trend: number
invert?: boolean
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-x-2 gap-y-1">
<StatValue
value={value}
unit={unit}
size="sm"
className="shrink-0 whitespace-nowrap"
/>
<StatTrend
value={trend}
invert={invert}
className="shrink-0 whitespace-nowrap"
/>
</div>
)
}
export default function MiniChartDashboardDemo() {
return (
<div className="grid w-full max-w-3xl grid-cols-2 gap-px overflow-hidden rounded-lg border border-edge bg-edge sm:grid-cols-3">
<StatCard className={CELL}>
<StatLabel>API 调用量</StatLabel>
<MiniChart
type="bar"
data={REQUESTS}
tone="blue"
highlight="last"
track
className="h-14"
tickFormatter={(text) => text.slice(3)}
seriesLabel="万次"
label="近 12 天 API 调用量柱图"
/>
<Reading value={41} unit="万次" trend={18.2} />
</StatCard>
<StatCard className={CELL}>
<StatLabel>博客访客</StatLabel>
<MiniChart
type="area"
data={VISITS}
tone="violet"
className="h-14"
seriesLabel="访客"
label="近 12 天博客访客面积图"
/>
<Reading value={4810} unit="人" trend={-4.3} />
</StatCard>
<StatCard className={CELL}>
<StatLabel>构建时长</StatLabel>
<MiniChart
type="line"
data={BUILD}
tone="amber"
strokeWidth={2.5}
className="h-14"
seriesLabel="秒"
label="近 12 次构建时长折线图"
/>
<Reading value="38" unit="秒" trend={-20.8} invert />
</StatCard>
<StatCard className={CELL}>
<StatLabel>每日部署</StatLabel>
<MiniChart
type="step"
data={DEPLOYS}
tone="green"
strokeWidth={2.5}
className="h-14"
seriesLabel="次"
label="近 12 天部署次数阶梯线"
/>
<Reading value="7" unit="次" trend={16.7} />
</StatCard>
<StatCard className={CELL}>
<StatLabel>缓存命中率</StatLabel>
<div className="flex h-14 items-center">
<MiniRing
value={86}
arc={270}
size={56}
thickness={12}
tone="cyan"
label="缓存命中率 86%"
>
<span className="text-xs font-semibold">86%</span>
</MiniRing>
<p className="ml-3 text-xs leading-5 text-muted-foreground max-sm:hidden">
命中 1.2 万
<br />
回源 1,950
</p>
</div>
<Reading value="86" unit="%" trend={3.4} />
</StatCard>
<StatCard className={CELL}>
<StatLabel>流量来源</StatLabel>
<div className="flex h-14 items-center">
<MiniDonut
size={56}
thickness={34}
segments={[
{ label: "搜索引擎", value: 148, tone: "blue" },
{ label: "直接访问", value: 93, tone: "violet" },
{ label: "社交媒体", value: 64, tone: "amber" },
{ label: "外链", value: 37, tone: "rose" },
]}
label="流量来源占比"
/>
<p className="ml-3 text-xs leading-5 text-muted-foreground max-sm:hidden">
搜索 43%
<br />
直接 27%
</p>
</div>
<Reading value={34200} unit="次" trend={9.6} />
</StatCard>
</div>
)
}
四种图形
bar 柱、line 折线、area 面积(带极淡的渐变)、step 阶梯线。折线和面积的纵轴范围贴着数据的最小到最大值,起伏更明显;柱图从 0 开始。
import {
MiniChart,
type MiniChartType,
} from "@/components/ui/mini-chart/mini-chart"
const DATA = [12, 18, 15, 24, 21, 30, 26, 34, 29, 38, 35, 42]
const TYPES: { type: MiniChartType; name: string }[] = [
{ type: "bar", name: "柱 bar" },
{ type: "line", name: "折线 line" },
{ type: "area", name: "面积 area" },
{ type: "step", name: "阶梯 step" },
]
export default function MiniChartTypesDemo() {
return (
<div className="grid w-full max-w-3xl grid-cols-2 gap-3 sm:grid-cols-4">
{TYPES.map(({ type, name }) => (
<div
key={type}
className="flex flex-col gap-2 rounded-lg border border-edge p-3"
>
<span className="font-mono text-xs text-muted-foreground">
{name}
</span>
<MiniChart type={type} data={DATA} label={`${name}图`} />
</div>
))}
</div>
)
}
横轴与强调
showAxis 打开极简横轴:11px 刻度,没有轴线和刻度线,只保留首尾和间隔均匀的标签。tickFormatter 把标签变成想要的样子,星期轴用单字「一…日」。柱图的 highlight 强调一根(下标、"last" 或 "max"),其余变淡;track 给每根柱加上底槽。
import { MiniChart } from "@/components/ui/mini-chart/mini-chart"
import { formatDay } from "@/lib/data-display"
// 2026-02-02 是周一,一周七天刚好「一…日」
const READS = [150, 250, 190, 290, 220, 350, 250].map((value, index) => ({
label: `2026-02-0${index + 2}`,
value,
}))
const BUILDS = [42, 38, 55, 47, 40, 36, 33].map((value, index) => ({
label: `2026-02-0${index + 2}`,
value,
}))
export default function MiniChartAxisDemo() {
return (
<div className="grid w-full max-w-2xl gap-3 sm:grid-cols-2">
<figure className="flex flex-col gap-3 rounded-lg border border-edge bg-card p-4">
<figcaption className="text-sm text-muted-foreground">
本周博客阅读量 · 强调最高的一天
</figcaption>
<MiniChart
type="bar"
className="h-28"
data={READS}
showAxis
highlight="max"
tickFormatter={(label) => formatDay(label, "weekday")}
seriesLabel="阅读量"
label="本周每天博客阅读量"
/>
</figure>
<figure className="flex flex-col gap-3 rounded-lg border border-edge bg-card p-4">
<figcaption className="text-sm text-muted-foreground">
近 7 天构建时长 · 底槽与最近一天
</figcaption>
<MiniChart
type="bar"
className="h-28"
data={BUILDS}
tone="amber"
showAxis
track
highlight="last"
tickFormatter={(label) => formatDay(label, "short")}
seriesLabel="秒"
label="近 7 天构建时长"
/>
</figure>
</div>
)
}
涨跌配色
tone="trend" 按首尾两点自动选涨色或跌色。颜色来自 --stat-up / --stat-down,由 convention 切换绿涨红跌与红涨绿跌,用法见 统计卡片。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { MiniChart } from "@/components/ui/mini-chart/mini-chart"
import {
StatCard,
StatGroup,
StatLabel,
StatTrend,
StatValue,
type StatGroupProps,
} from "@/components/ui/stat-card/stat-card"
const UP = [20, 22, 21, 26, 25, 30, 29, 34, 38, 41]
const DOWN = [41, 38, 39, 33, 34, 30, 31, 26, 24, 22]
export default function MiniChartTrendDemo() {
const [convention, setConvention] =
React.useState<NonNullable<StatGroupProps["convention"]>>("cn")
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<div
className="flex items-center gap-2"
role="group"
aria-label="涨跌配色约定"
>
<Button
size="sm"
variant={convention === "intl" ? "default" : "outline"}
aria-pressed={convention === "intl"}
onClick={() => setConvention("intl")}
>
绿涨红跌
</Button>
<Button
size="sm"
variant={convention === "cn" ? "default" : "outline"}
aria-pressed={convention === "cn"}
onClick={() => setConvention("cn")}
>
红涨绿跌
</Button>
</div>
<StatGroup convention={convention} className="grid gap-3 sm:grid-cols-2">
<StatCard>
<StatLabel>订阅用户</StatLabel>
<MiniChart
type="area"
tone="trend"
data={UP}
seriesLabel="订阅"
label="订阅用户走势"
/>
<div className="flex items-end justify-between">
<StatValue value={4120} size="sm" />
<StatTrend value={9.6} />
</div>
</StatCard>
<StatCard>
<StatLabel>RSS 打开率</StatLabel>
<MiniChart
type="area"
tone="trend"
data={DOWN}
seriesLabel="打开率"
label="RSS 打开率走势"
/>
<div className="flex items-end justify-between">
<StatValue value="22%" size="sm" />
<StatTrend value={-8.4} />
</div>
</StatCard>
</StatGroup>
</div>
)
}
进度环
MiniRing 是纯 SVG,没有用图表库:arc 控制圆弧总角度(360 是整圆,270 是下方留口的仪表),thickness 控制粗细,children 替换中央的默认百分比。
import { MiniRing } from "@/components/ui/mini-chart/mini-chart"
export default function MiniChartRingDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<MiniRing value={72} label="构建配额已用 72%" />
<span className="text-xs text-muted-foreground">整圆</span>
</div>
<div className="flex flex-col items-center gap-2">
<MiniRing
value={86}
arc={270}
tone="green"
size={88}
label="可用率 86%"
>
<span className="text-base font-semibold">99.9</span>
<span className="text-[11px] text-muted-foreground">可用率 %</span>
</MiniRing>
<span className="text-xs text-muted-foreground">270° 仪表</span>
</div>
<div className="flex flex-col items-center gap-2">
<MiniRing
value={4}
max={5}
tone="amber"
thickness={14}
label="目标完成 4/5"
>
<span className="text-sm font-semibold">4/5</span>
</MiniRing>
<span className="text-xs text-muted-foreground">粗环</span>
</div>
<div className="flex flex-col items-center gap-2">
<MiniRing
value={32}
tone="rose"
size={56}
thickness={12}
label="磁盘 32%"
/>
<span className="text-xs text-muted-foreground">小尺寸</span>
</div>
</div>
)
}
环形图
MiniDonut 画几个分类的占比,分段之间留 2° 的缝,悬停有提示框,中央放合计。图例由你自己写,颜色按顺序对应默认色板。
import { toneColor } from "@/lib/data-display"
import { MiniDonut } from "@/components/ui/mini-chart/mini-chart"
const SEGMENTS = [
{ label: "文档", value: 5320, tone: "blue" },
{ label: "博客", value: 3180, tone: "amber" },
{ label: "组件库", value: 2140, tone: "green" },
] as const
const TOTAL = SEGMENTS.reduce((sum, item) => sum + item.value, 0)
export default function MiniChartDonutDemo() {
return (
<div className="flex items-center gap-6">
<MiniDonut size={112} segments={[...SEGMENTS]} label="各栏目访问量占比">
<span className="text-lg font-semibold">1.1万</span>
<span className="text-[11px] text-muted-foreground">总访问</span>
</MiniDonut>
<ul className="flex flex-col gap-2 text-sm">
{SEGMENTS.map((segment) => (
<li key={segment.label} className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: toneColor(segment.tone) }}
/>
<span className="text-muted-foreground">{segment.label}</span>
<span className="ml-2 font-medium tabular-nums">
{Math.round((segment.value / TOTAL) * 100)}%
</span>
</li>
))}
</ul>
</div>
)
}
API
MiniChart
数据点,按顺序排列
类型MiniChartDatum[]图形:bar 柱、line 折线、area 面积、step 阶梯线
MiniChartType | undefined"area"色调。"trend" 按首尾两点的涨跌自动取涨色或跌色(随 convention 变化)
"trend" | DataTone | undefined"primary"涨跌配色约定,仅 tone="trend" 时有用;不设置时沿用外层
DataConvention | undefined显示横轴(11px 的极简刻度,只保留首尾与间隔均匀的标签)
类型boolean | undefinedfalse横轴刻度文字的格式化,参数是数据点的 label
((label: string, index: number) => string) | undefined悬停提示框
类型boolean | undefinedtrue提示框里数值的格式
类型StatFormatOptions | undefined提示框里的系列名
类型string | undefined"数值"柱图里强调的那一根:下标,"last" 最后一根,"max" 最大值;其余柱变淡
number | "last" | "max" | undefined柱图每根柱的底槽(淡色背景),像进度槽一样表示满值
类型boolean | undefinedfalse线宽,仅折线、面积与阶梯线
类型number | undefined2读屏名称,描述这张图表示什么
类型string | undefined根元素 data-slot="mini-chart",带 data-type;图形颜色通过 shadcn ChartContainer 注入 --color-value。
MiniRing
当前值
类型number满值
类型number | undefined100进度弧的色调
类型DataTone | undefined"primary"外径(像素)
类型number | undefined72环的粗细,占外径的百分比
类型number | undefined10圆弧的总角度:360 是整圆,270 是下方留口的仪表
类型number | undefined360环中央的内容,默认是取整后的百分比
类型React.ReactNode读屏名称
类型string | undefinedMiniDonut
分段数据
类型MiniDonutSegment[]外径(像素)
类型number | undefined96环的粗细,占半径的百分比
类型number | undefined30悬停提示框里数值的格式
类型StatFormatOptions | undefined环中央的内容,如合计数字
类型React.ReactNode读屏名称
类型string | undefined可访问性
- 图表本身是
role="img",名称来自label;数据通过悬停提示框读取,需要精确数值请同时在文字里写出(如卡片里的大号数字)。 MiniRing是role="meter",带aria-valuenow和aria-valuemax。- 开启「减少动态效果」后,recharts 的入场动画关闭,进度环不播放入场动画;进度环由服务端直接输出最终弧长,没有脚本也完整显示。
- 涨跌趋势不只靠颜色:请在旁边配
StatTrend这样带箭头和文字的元素。
注意事项
- 图表由
ResponsiveContainer撑满父级,服务端渲染时先按 160×64 占位,水合后量出真实尺寸;父级必须有确定宽度。 - 同一页面放很多迷你图时,给不需要提示框的设置
showTooltip={false},可以省掉事件监听。 - 提示框的数值用
format(StatFormatOptions)格式化,默认zh-CN压缩。
源码
"use client"
import * as React from "react"
import { useReducedMotion } from "motion/react"
import {
Area,
AreaChart,
Bar,
BarChart,
Cell,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts"
import { cn } from "@/lib/utils"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
conventionClass,
formatStat,
toneColor,
trendOf,
type DataConvention,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
import {
MiniDonut,
type MiniDonutProps,
type MiniDonutSegment,
} from "./mini-donut"
import { MiniRing, type MiniRingProps } from "./mini-ring"
/** 数据点:纯数字,或带标签的 `{ label, value }`(标签用于横轴与提示框)。 */
type MiniChartDatum = number | { label?: string; value: number }
type MiniChartType = "bar" | "line" | "area" | "step"
type MiniChartProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 数据点,按顺序排列 */
data: MiniChartDatum[]
/**
* 图形:`bar` 柱、`line` 折线、`area` 面积、`step` 阶梯线
* @default "area"
*/
type?: MiniChartType
/**
* 色调。`"trend"` 按首尾两点的涨跌自动取涨色或跌色(随 `convention` 变化)
* @default "primary"
*/
tone?: DataTone | "trend"
/** 涨跌配色约定,仅 `tone="trend"` 时有用;不设置时沿用外层 */
convention?: DataConvention
/**
* 显示横轴(11px 的极简刻度,只保留首尾与间隔均匀的标签)
* @default false
*/
showAxis?: boolean
/** 横轴刻度文字的格式化,参数是数据点的 `label` */
tickFormatter?: (label: string, index: number) => string
/**
* 悬停提示框
* @default true
*/
showTooltip?: boolean
/** 提示框里数值的格式 */
format?: StatFormatOptions
/**
* 提示框里的系列名
* @default "数值"
*/
seriesLabel?: string
/** 柱图里强调的那一根:下标,`"last"` 最后一根,`"max"` 最大值;其余柱变淡 */
highlight?: number | "last" | "max"
/**
* 柱图每根柱的底槽(淡色背景),像进度槽一样表示满值
* @default false
*/
track?: boolean
/**
* 线宽,仅折线、面积与阶梯线
* @default 2
*/
strokeWidth?: number
/** 读屏名称,描述这张图表示什么 */
label?: string
}
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" } as const
/** 极简迷你图:没有网格、没有纵轴,适合放进统计卡片;高度用 `className` 控制(默认 `h-16`)。 */
function MiniChart({
className,
data,
type = "area",
tone = "primary",
convention,
showAxis = false,
tickFormatter,
showTooltip = true,
format,
seriesLabel = "数值",
highlight,
track = false,
strokeWidth = 2,
label,
...props
}: MiniChartProps) {
const ready = React.useSyncExternalStore(
subscribe,
() => true,
() => false,
)
const reduced = useReducedMotion()
const gradientId = React.useId().replaceAll(":", "")
const points = data.map((datum, index) =>
typeof datum === "number"
? { label: String(index + 1), value: datum }
: { label: datum.label ?? String(index + 1), value: datum.value },
)
const first = points[0]?.value ?? 0
const last = points.at(-1)?.value ?? 0
const resolved: DataTone =
tone === "trend"
? ({ up: "up", down: "down", flat: "muted" } as const)[
trendOf(last - first)
]
: tone
const config = {
value: { label: seriesLabel, color: toneColor(resolved) },
} satisfies ChartConfig
const focus =
highlight === "last"
? points.length - 1
: highlight === "max"
? points.findIndex(
(point) => point.value === Math.max(...points.map((p) => p.value)),
)
: highlight
const margin = { top: 4, right: 2, bottom: 0, left: 2 }
const active = !reduced
const axis = showAxis ? (
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={6}
minTickGap={12}
interval="preserveStartEnd"
tick={AXIS_TICK}
tickFormatter={tickFormatter}
/>
) : (
<XAxis dataKey="label" hide />
)
const tooltip = showTooltip ? (
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
className="min-w-28 shadow-sm"
formatter={(value) => (
<>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: toneColor(resolved) }}
/>
<span className="text-muted-foreground">{seriesLabel}</span>
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
{formatStat(Number(value), format)}
</span>
</>
)}
/>
}
/>
) : null
const color = "var(--color-value)"
return (
<div
data-slot="mini-chart"
data-ready={ready}
data-type={type}
role="img"
aria-label={label}
className={cn(
"relative flex h-16 w-full min-w-0 text-xs",
conventionClass(convention),
className,
)}
{...props}
>
<ChartContainer
data-slot="mini-chart-plot"
data-ready={ready}
config={config}
initialDimension={{ width: 160, height: 64 }}
className="aspect-auto size-full justify-start opacity-0 transition-opacity duration-200 data-[ready=true]:opacity-100 motion-reduce:transition-none"
>
{type === "bar" ? (
<BarChart data={points} margin={margin} barCategoryGap="22%">
{axis}
<YAxis hide domain={[0, "dataMax"]} />
{tooltip}
<Bar
dataKey="value"
fill={color}
radius={[3, 3, 0, 0]}
maxBarSize={14}
background={
track ? { fill: "var(--muted)", radius: 3 } : undefined
}
isAnimationActive={active}
animationDuration={600}
>
{points.map((point, index) => (
<Cell
key={index}
fill={color}
fillOpacity={
focus === undefined || focus === index ? 1 : 0.28
}
/>
))}
</Bar>
</BarChart>
) : type === "area" ? (
<AreaChart data={points} margin={margin}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.28} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{axis}
<YAxis hide domain={["dataMin", "dataMax"]} />
{tooltip}
<Area
dataKey="value"
type="monotone"
stroke={color}
strokeWidth={strokeWidth}
fill={`url(#${gradientId})`}
activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={active}
animationDuration={700}
/>
</AreaChart>
) : (
<LineChart data={points} margin={margin}>
{axis}
<YAxis hide domain={["dataMin", "dataMax"]} />
{tooltip}
<Line
dataKey="value"
type={type === "step" ? "stepAfter" : "monotone"}
stroke={color}
strokeWidth={strokeWidth}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={active}
animationDuration={700}
/>
</LineChart>
)}
</ChartContainer>
<div
data-slot="mini-chart-placeholder"
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 z-10 min-h-0 bg-muted/30 transition-opacity duration-200 motion-reduce:transition-none",
ready && "opacity-0",
)}
>
<svg
className="absolute inset-0 size-full text-muted-foreground/20"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<path
d="M0 25H100 M0 50H100 M0 75H100"
fill="none"
stroke="currentColor"
strokeDasharray="3 3"
/>
</svg>
</div>
</div>
)
}
const subscribe = () => () => {}
export { MiniChart, MiniDonut, MiniRing }
export type {
MiniChartDatum,
MiniChartProps,
MiniChartType,
MiniDonutProps,
MiniDonutSegment,
MiniRingProps,
}
import * as React from "react"
import { cn } from "@/lib/utils"
import {
formatStat,
motionStyles,
toneColor,
type DataTone,
} from "@/lib/data-display"
type MiniRingProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 当前值 */
value: number
/**
* 满值
* @default 100
*/
max?: number
/**
* 进度弧的色调
* @default "primary"
*/
tone?: DataTone
/**
* 外径(像素)
* @default 72
*/
size?: number
/**
* 环的粗细,占外径的百分比
* @default 10
*/
thickness?: number
/**
* 圆弧的总角度:360 是整圆,270 是下方留口的仪表
* @default 360
*/
arc?: number
/** 环中央的内容,默认是取整后的百分比 */
children?: React.ReactNode
/** 读屏名称 */
label?: string
}
/**
* 进度环:纯 SVG,比用图表库画一圈更轻也更清晰。
* 服务端直接输出进度弧的最终长度,入场动画是纯 CSS,没有脚本也完整显示;系统开启「减少动态效果」时不播放。
*/
function MiniRing({
className,
value,
max = 100,
tone = "primary",
size = 72,
thickness = 10,
arc = 360,
children,
label,
style,
...props
}: MiniRingProps) {
const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0
const sweep = Math.min(360, Math.max(30, arc)) / 360
// 圆从 3 点钟方向顺时针画:整圆从 12 点开始,开口仪表让缺口落在正下方
const rotation = sweep === 1 ? -90 : 90 + (360 * (1 - sweep)) / 2
const radius = 50 - thickness / 2
const common = {
cx: 50,
cy: 50,
r: radius,
fill: "none",
strokeWidth: thickness,
strokeLinecap: "round",
transform: `rotate(${rotation} 50 50)`,
pathLength: 1,
} as const
return (
<div
data-slot="mini-ring"
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"relative flex shrink-0 items-center justify-center",
className,
)}
style={{ width: size, height: size, ...style }}
{...props}
>
{motionStyles()}
<svg
viewBox="0 0 100 100"
aria-hidden="true"
className="absolute inset-0 size-full"
>
<circle
{...common}
stroke="var(--edge)"
strokeDasharray={`${sweep} 1`}
/>
<circle
{...common}
data-dd-anim="arc"
stroke={toneColor(tone)}
strokeDasharray={`${ratio * sweep} 1`}
/>
</svg>
<div
data-slot="mini-ring-content"
className="relative flex flex-col items-center text-center leading-tight tabular-nums"
>
{children ?? (
<span className="text-sm font-semibold">
{formatStat(ratio * 100, { style: "percent", digits: 0 })}
</span>
)}
</div>
</div>
)
}
export { MiniRing }
export type { MiniRingProps }
"use client"
import * as React from "react"
import { useReducedMotion } from "motion/react"
import { Pie, PieChart } from "recharts"
import { cn } from "@/lib/utils"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
SERIES_TONES,
formatStat,
toneColor,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
type MiniDonutSegment = {
/** 分类名,同时是提示框里的名称 */
label: string
value: number
/** 色调,不设置时按顺序取默认色板 */
tone?: DataTone
}
type MiniDonutProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 分段数据 */
segments: MiniDonutSegment[]
/**
* 外径(像素)
* @default 96
*/
size?: number
/**
* 环的粗细,占半径的百分比
* @default 30
*/
thickness?: number
/** 悬停提示框里数值的格式 */
format?: StatFormatOptions
/** 环中央的内容,如合计数字 */
children?: React.ReactNode
/** 读屏名称 */
label?: string
}
/** 环形图:几个分类的占比,中央可放合计。分段之间留 2° 的缝。 */
function MiniDonut({
className,
segments,
size = 96,
thickness = 30,
format,
children,
label,
style,
...props
}: MiniDonutProps) {
const ready = React.useSyncExternalStore(
subscribe,
() => true,
() => false,
)
const reduced = useReducedMotion()
const config: ChartConfig = {}
const data = segments.map((segment, index) => {
const color = toneColor(
segment.tone ?? SERIES_TONES[index % SERIES_TONES.length],
)
config[segment.label] = { label: segment.label, color }
return { label: segment.label, value: segment.value, fill: color }
})
return (
<div
data-slot="mini-donut"
data-ready={ready}
role="img"
aria-label={label}
className={cn("relative shrink-0", className)}
style={{ width: size, height: size, ...style }}
{...props}
>
<ChartContainer
config={config}
initialDimension={{ width: size, height: size }}
className={cn(
"aspect-auto size-full transition-opacity duration-200 motion-reduce:transition-none",
!ready && "opacity-0",
)}
>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
nameKey="label"
className="min-w-28 shadow-sm"
formatter={(value, name, item) => (
<>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.payload?.fill }}
/>
<span className="text-muted-foreground">{name}</span>
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
{formatStat(Number(value), format)}
</span>
</>
)}
/>
}
/>
<Pie
data={data}
dataKey="value"
nameKey="label"
innerRadius={`${100 - thickness}%`}
outerRadius="100%"
paddingAngle={2}
cornerRadius={3}
strokeWidth={0}
isAnimationActive={!reduced}
animationDuration={700}
/>
</PieChart>
</ChartContainer>
<div
data-slot="mini-donut-placeholder"
style={{ borderWidth: (size * thickness) / 200 }}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-full border-muted/50 transition-opacity duration-200 motion-reduce:transition-none",
ready && "opacity-0",
)}
/>
{children ? (
<div
data-slot="mini-donut-content"
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center leading-tight tabular-nums"
>
{children}
</div>
) : null}
</div>
)
}
const subscribe = () => () => {}
export { MiniDonut }
export type { MiniDonutProps, MiniDonutSegment }
import * as React from "react"
/**
* 数据展示共用工具:涨跌配色约定、色调、数字与日期格式化。
* stat-card、mini-chart、chart-card 三个条目共用这一份,所以涨跌色、数字格式只在这里定义。
*/
/**
* 涨跌配色约定:
* - `intl`(默认):绿涨红跌;
* - `cn`:红涨绿跌,国内行情软件的习惯。
*/
type DataConvention = "intl" | "cn"
// 约定只改写 `--stat-up` / `--stat-down` 两个 CSS 变量。变量沿 DOM 向下继承,
// 所以给整块看板的外层设一次 `convention`,里面所有涨跌色(包括图表里的 SVG)都跟着变,
// 不需要 context,服务端组件也能用。没有设置时回落到默认的绿涨红跌。
const CONVENTION_CLASSES: Record<DataConvention, string> = {
intl: "[--stat-up:var(--flow-green,var(--primary))] [--stat-down:var(--flow-rose,var(--destructive))]",
cn: "[--stat-up:var(--flow-rose,var(--destructive))] [--stat-down:var(--flow-green,var(--primary))]",
}
/** 把 `convention` 换成要加到根元素上的类名;不传返回 `undefined`,沿用外层的约定。 */
function conventionClass(convention?: DataConvention) {
return convention ? CONVENTION_CLASSES[convention] : undefined
}
type Trend = "up" | "down" | "flat"
function trendOf(value: number): Trend {
return value > 0 ? "up" : value < 0 ? "down" : "flat"
}
/** 涨跌文字色(Tailwind 只认完整类名,所以三种写全)。 */
const TREND_TEXT: Record<Trend, string> = {
up: "text-[color:var(--stat-up,var(--flow-green,var(--primary)))]",
down: "text-[color:var(--stat-down,var(--flow-rose,var(--destructive)))]",
flat: "text-muted-foreground",
}
/**
* 内置色调:
* - `primary`、`foreground`、`muted`、`destructive` 直接引用主题变量,跟随颜色主题;
* - `blue` 等多彩色调引用 `--flow-*` 变量,没有安装时回落到主题强调色;
* - `chart-1` … `chart-5` 引用主题的图表色板;
* - `up` / `down` 是涨跌色,随 `convention` 变化。
*/
type DataToneName =
| "primary"
| "foreground"
| "muted"
| "destructive"
| "blue"
| "green"
| "amber"
| "rose"
| "violet"
| "cyan"
| "chart-1"
| "chart-2"
| "chart-3"
| "chart-4"
| "chart-5"
| "up"
| "down"
/** 色调:内置名,或任意 CSS 颜色(`"var(--brand)"`、`"oklch(0.7 0.2 150)"` 都可以)。 */
type DataTone = DataToneName | (string & {})
const TONES: Record<DataToneName, string> = {
primary: "var(--primary)",
foreground: "var(--foreground)",
muted: "var(--muted-foreground)",
destructive: "var(--destructive)",
blue: "var(--flow-blue, var(--primary))",
green: "var(--flow-green, var(--primary))",
amber: "var(--flow-amber, var(--primary))",
rose: "var(--flow-rose, var(--destructive))",
violet: "var(--flow-violet, var(--primary))",
cyan: "var(--flow-cyan, var(--primary))",
"chart-1": "var(--chart-1)",
"chart-2": "var(--chart-2)",
"chart-3": "var(--chart-3)",
"chart-4": "var(--chart-4)",
"chart-5": "var(--chart-5)",
up: "var(--stat-up, var(--flow-green, var(--primary)))",
down: "var(--stat-down, var(--flow-rose, var(--destructive)))",
}
/** 把色调换成可直接用于 `fill` / `stroke` / `color` 的 CSS 颜色。 */
function toneColor(tone: DataTone): string {
return Object.hasOwn(TONES, tone) ? TONES[tone as DataToneName] : tone
}
/**
* 多序列图表默认的色调轮换:六种互不相近的多彩色调。
* 不把主题强调色放进来:强调色会落在蓝、绿、紫、琥珀任意一种附近,
* 切到对应的颜色主题时会与其中一条序列撞色。单条序列想跟随主题,显式写 `tone="primary"`。
*/
const SERIES_TONES: DataTone[] = [
"blue",
"amber",
"green",
"violet",
"rose",
"cyan",
]
type StatFormatOptions = {
/**
* 数字样式:`number` 普通数字,`percent` 百分比(传入的 12.5 表示 12.5%),`currency` 货币
* @default "number"
*/
style?: "number" | "percent" | "currency"
/**
* BCP 47 区域标识;`zh-CN` 压缩成「万 / 亿」,`en` 压缩成 K / M / B
* @default "zh-CN"
*/
locale?: string
/**
* 压缩大数(1.2万、3.4亿);百分比不压缩
* @default true
*/
compact?: boolean
/**
* 货币代码,仅 `style="currency"` 生效;人民币显示 ¥
* @default "CNY"
*/
currency?: string
/**
* 最多保留的小数位
* @default 1(压缩与百分比),2(普通数字与货币)
*/
digits?: number
/**
* 正数前加 `+`(零不加),用于涨跌幅
* @default false
*/
sign?: boolean
}
const formatters = new Map<string, Intl.NumberFormat>()
/**
* 格式化统计数字,默认 `zh-CN` 压缩:`12480` → `1.2万`,`345000000` → `3.5亿`;
* `locale: "en"` 时为 `12K`、`345M`。同参数的 `Intl.NumberFormat` 会被缓存复用。
*/
function formatStat(value: number, options: StatFormatOptions = {}): string {
const {
style = "number",
locale = "zh-CN",
compact = style !== "percent",
currency = "CNY",
sign = false,
} = options
const digits = options.digits ?? (compact || style === "percent" ? 1 : 2)
const key = [style, locale, compact, currency, digits, sign].join("|")
let formatter = formatters.get(key)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: style === "number" ? "decimal" : style,
currency: style === "currency" ? currency : undefined,
currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
notation: compact && style !== "percent" ? "compact" : "standard",
maximumFractionDigits: digits,
signDisplay: sign ? "exceptZero" : "auto",
})
formatters.set(key, formatter)
}
return formatter.format(style === "percent" ? value / 100 : value)
}
type DayStyle = "long" | "short" | "weekday"
const dayFormatters = new Map<string, Intl.DateTimeFormat>()
/**
* 格式化日期轴文字:`long` → `2月14日`,`short` → `02/14`,`weekday` → `一`(单字星期)。
* 日期按 UTC 解析与输出,`"2026-02-14"` 这类字符串在任何时区、任何环境下结果一致,不会引起水合差异。
*/
function formatDay(
input: string | number | Date,
style: DayStyle = "long",
locale = "zh-CN",
): string {
const key = `${style}|${locale}`
let formatter = dayFormatters.get(key)
if (!formatter) {
const options: Intl.DateTimeFormatOptions =
style === "weekday"
? { weekday: "narrow" }
: style === "short"
? { month: "2-digit", day: "2-digit" }
: { month: "long", day: "numeric" }
formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" })
dayFormatters.set(key, formatter)
}
return formatter.format(new Date(input))
}
type AxisFormatOptions = Pick<
StatFormatOptions,
"style" | "locale" | "currency" | "digits"
>
// 压缩单位表:从大到小,命中第一个不超过轴最大值的单位
const AXIS_UNITS = {
zh: [
[1e8, "亿"],
[1e4, "万"],
],
"zh-trad": [
[1e8, "億"],
[1e4, "萬"],
],
en: [
[1e9, "B"],
[1e6, "M"],
[1e3, "K"],
],
} as const
/**
* 按「整条轴」统一单位的刻度格式化:先用轴的最大值选定一个单位,所有刻度都换算成它,
* 避免 `9000` 与 `1.2万` 混在同一根轴上。例如最大值 12000 时刻度是 `0`、`0.3万`、`0.6万`、`0.9万`、`1.2万`;
* 最大值 8000 时全部是整数(`2000`、`4000`…)。`en` 同理换成 K / M / B。
*
* @param max 轴上数据的最大绝对值;留 15% 余量,因为图表会把坐标轴上沿取整到比数据更大的刻度
*/
function createAxisFormatter(
max: number,
options: AxisFormatOptions = {},
): (value: number) => string {
const { style = "number", locale = "zh-CN", currency = "CNY" } = options
if (style === "percent")
return (value) =>
formatStat(value, { style: "percent", locale, digits: options.digits })
const family = /^zh-(TW|HK|MO|Hant)/i.test(locale)
? "zh-trad"
: /^zh/i.test(locale)
? "zh"
: "en"
const reach = Math.abs(max) * 1.15
const unit = AXIS_UNITS[family].find(([size]) => reach >= size)
const divisor = unit?.[0] ?? 1
const suffix = unit?.[1] ?? ""
const formatter = new Intl.NumberFormat(locale, {
style: style === "currency" ? "currency" : "decimal",
currency: style === "currency" ? currency : undefined,
currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
notation: "standard",
maximumFractionDigits: options.digits ?? 2,
})
return (value) =>
value === 0
? formatter.format(0)
: `${formatter.format(value / divisor)}${suffix}`
}
// 进度条、计量条、分段条、进度环、刻度仪表的入场动画:纯 CSS,服务端直接输出最终状态,
// 所以没有脚本时也完整显示;系统开启「减少动态效果」时不播放。
const MOTION_CSS = `@media (prefers-reduced-motion: no-preference) {
@keyframes dd-reveal { from { clip-path: inset(0 100% 0 0 round 9999px) } to { clip-path: inset(0 0 0 0 round 9999px) } }
@keyframes dd-rise { from { opacity: 0; transform: scaleY(0.35) } }
@keyframes dd-fade { from { opacity: 0 } }
@keyframes dd-arc { from { stroke-dasharray: 0 1 } }
[data-dd-anim="reveal"] { animation: dd-reveal 0.8s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="rise"] { animation: dd-rise 0.3s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="fade"] { animation: dd-fade 0.35s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="arc"] { animation: dd-arc 0.9s cubic-bezier(0.22, 1, 0.36, 1) both }
}`
/**
* 渲染上面的动画样式。React 19 会按 `href` 去重并提升到 `<head>`,
* 同一页放多少个组件都只有一份;放在服务端组件里也能用。
*/
function motionStyles() {
return React.createElement(
"style",
{ href: "data-display-motion", precedence: "default" },
MOTION_CSS,
)
}
export {
TREND_TEXT,
SERIES_TONES,
conventionClass,
createAxisFormatter,
formatDay,
formatStat,
motionStyles,
toneColor,
trendOf,
}
export type {
AxisFormatOptions,
DataConvention,
DataTone,
DataToneName,
DayStyle,
StatFormatOptions,
Trend,
}