import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartGauge,
ChartMetric,
ChartMetrics,
ChartSeries,
} from "@/components/ui/chart-card/chart-card"
import { StatBadge } from "@/components/ui/stat-card/stat-card"
import { formatDay } from "@/lib/data-display"
// 固定公式生成的演示数据:每次构建结果一致
const DATA = Array.from({ length: 21 }, (_, index) => ({
day: `2026-10-${String(index + 1).padStart(2, "0")}`,
installs: Math.round(420 + index * 24 + 90 * Math.sin(index * 0.7)),
reads: Math.round(300 + index * 14 + 120 * Math.cos(index * 0.5 + 1)),
}))
export default function ChartCardTargetDemo() {
return (
<ChartCard className="w-full max-w-3xl">
<ChartCardHeader className="sm:items-center">
<div className="flex flex-col gap-1">
<ChartCardTitle>十月增长</ChartCardTitle>
<ChartCardDescription className="max-sm:hidden">
组件安装与博客阅读 · 距年度目标还差 440 人
</ChartCardDescription>
</div>
<ChartMetrics>
<ChartMetric label="组件安装" value="1.2万" tone="blue" />
<ChartMetric label="博客阅读" value="3.9万" tone="amber" />
</ChartMetrics>
</ChartCardHeader>
<div className="grid lg:grid-cols-5">
<div className="flex flex-col items-center justify-center gap-3 border-b border-edge p-4 max-sm:flex-row max-sm:justify-between lg:col-span-2 lg:border-r lg:border-b-0">
<ChartGauge
value={78}
tone="green"
ticks={64}
caption="年度目标"
className="size-28 sm:size-44"
label="年度订阅目标完成 78%"
/>
<div className="flex items-center gap-2 text-xs text-muted-foreground max-sm:flex-col max-sm:items-start">
<StatBadge status="ok">进度达标</StatBadge>
<span className="whitespace-nowrap tabular-nums">
1,560 / 2,000 人
</span>
</div>
</div>
<ChartCardContent className="lg:col-span-3">
<ChartSeries
type="area"
data={DATA}
xKey="day"
series={[
{ key: "installs", label: "组件安装", tone: "blue" },
{ key: "reads", label: "博客阅读", tone: "amber" },
]}
xTickFormatter={(value) => formatDay(value, "long")}
labelFormatter={(value) => formatDay(value, "long")}
className="h-44 sm:h-56"
label="十月每日组件安装与博客阅读面积图"
/>
</ChartCardContent>
</div>
</ChartCard>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/chart-card.json条目依赖 shadcn 的 chart(recharts 的封装),安装时会一并添加。
用法
ChartCard 是外壳,分 Header / Content / Footer 三个区域,区域之间用细线隔开;图表放在 Content 里。
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartSeries,
} from "@/components/ui/chart-card/chart-card"
const data = [
{ day: "2026-02-01", docs: 6200, blog: 4800 },
{ day: "2026-02-02", docs: 6900, blog: 5100 },
// …
]
export function Traffic() {
return (
<ChartCard>
<ChartCardHeader>
<div>
<ChartCardTitle>站点流量</ChartCardTitle>
<ChartCardDescription>每日页面访问量</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent>
<ChartSeries
data={data}
xKey="day"
series={[
{ key: "docs", label: "文档" },
{ key: "blog", label: "博客" },
]}
/>
</ChartCardContent>
</ChartCard>
)
}| 组件 | 用途 |
|---|---|
ChartCard + Header / Title / Description / Action / Content / Footer | 卡片布局 |
ChartMetrics + ChartMetric | 竖线隔开的指标行 |
ChartTable | 图表下方的明细表,首列带与序列同色的竖条 |
ChartGauge | 刻度式环形仪表 |
ChartSeries | 折线 / 柱 / 面积,支持堆叠与正负对比 |
ChartRanking | 横向排名条 + 图例格 |
ChartLegendList / ChartLegendGrid | 行内图例、图例格 |
页面顶部的预览把几个部件拼在了一张卡片里:左边 ChartGauge 显示目标进度,右边 ChartSeries 画两条面积序列,头部的 ChartMetrics 用同色圆点与序列对应。
示例
刻度式环形仪表
ChartGauge 由一圈细刻度组成,按值依次点亮:点亮的刻度由淡到浓,最后一格最亮最长,像指针停在当前值上。ticks 控制刻度数,arc 控制总角度(默认 270° 下方留口,360 为整圈),fade={false} 关掉渐变,children 替换中央内容。
import { ChartGauge } from "@/components/ui/chart-card/chart-card"
export default function ChartCardGaugeDemo() {
return (
<div className="grid w-full max-w-3xl grid-cols-2 gap-6 sm:grid-cols-4">
<div className="flex flex-col items-center gap-2">
<ChartGauge
value={78}
caption="已完成"
className="size-36"
label="完成 78%"
/>
<span className="text-xs text-muted-foreground">默认 · 270°</span>
</div>
<div className="flex flex-col items-center gap-2">
<ChartGauge
value={92}
arc={360}
tone="green"
caption="可用率"
className="size-36"
label="可用率 92%"
/>
<span className="text-xs text-muted-foreground">整圈 · 绿色</span>
</div>
<div className="flex flex-col items-center gap-2">
<ChartGauge
value={64}
ticks={36}
tone="blue"
fade={false}
className="size-36"
label="配额 64%"
caption="配额"
/>
<span className="text-xs text-muted-foreground">36 刻度 · 不渐变</span>
</div>
<div className="flex flex-col items-center gap-2">
<ChartGauge
value={31}
arc={240}
ticks={48}
tone="rose"
className="size-36"
label="磁盘 31%"
>
<span className="text-2xl font-semibold">31</span>
<span className="text-xs text-muted-foreground">GB 剩余</span>
</ChartGauge>
<span className="text-xs text-muted-foreground">自定义中心</span>
</div>
</div>
)
}
多序列折线与明细表
ChartSeries 默认画折线;序列多于一条时在上方显示图例。图表下方的 ChartTable 用 columns 和 rows 描述明细,行里的 tone 对应图表里的序列。
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
ChartSeries,
ChartTable,
} from "@/components/ui/chart-card/chart-card"
import { formatDay } from "@/lib/data-display"
// 固定公式生成的演示数据:每次构建结果一致
const DATA = Array.from({ length: 28 }, (_, index) => ({
day: `2026-02-${String(index + 1).padStart(2, "0")}`,
docs: Math.round(5200 + index * 100 + 900 * Math.sin(index * 0.8)),
blog: Math.round(4200 + index * 50 + 700 * Math.cos(index * 0.6)),
registry: Math.round(1800 + index * 57 + 400 * Math.sin(index * 1.1 + 1)),
}))
const SERIES = [
{ key: "docs", label: "文档", tone: "blue" },
{ key: "blog", label: "博客", tone: "amber" },
{ key: "registry", label: "组件库", tone: "green" },
] as const
const COLUMNS = [
{ key: "site", header: "栏目" },
{ key: "views", header: "访问量", align: "end" },
{ key: "duration", header: "平均停留", align: "end" },
{ key: "bounce", header: "跳出率", align: "end" },
{ key: "share", header: "占比", align: "end" },
] as const
const ROWS = [
{
tone: "blue",
site: "文档",
views: "18.4万",
duration: "3 分 12 秒",
bounce: "32.1%",
share: "46.9%",
},
{
tone: "amber",
site: "博客",
views: "13.6万",
duration: "4 分 40 秒",
bounce: "41.7%",
share: "34.7%",
},
{
tone: "green",
site: "组件库",
views: "7.2万",
duration: "2 分 05 秒",
bounce: "28.3%",
share: "18.4%",
},
] as const
export default function ChartCardDemo() {
return (
<ChartCard className="w-full max-w-3xl">
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>站点流量</ChartCardTitle>
<ChartCardDescription>
2026 年 2 月 · 每日页面访问量
</ChartCardDescription>
</div>
<ChartMetrics>
<ChartMetric label="总访问量" value="39.2万" />
<ChartMetric label="日均" value="1.4万" />
</ChartMetrics>
</ChartCardHeader>
<ChartCardContent className="flex flex-col gap-6">
<ChartSeries
data={DATA}
xKey="day"
series={[...SERIES]}
xTickFormatter={(value) => formatDay(value, "long")}
labelFormatter={(value) => formatDay(value, "long")}
className="h-48"
label="2 月各栏目每日访问量折线图"
/>
<ChartTable columns={[...COLUMNS]} rows={[...ROWS]} />
</ChartCardContent>
</ChartCard>
)
}
堆叠柱与指标行
type="bar" 加 stacked 得到堆叠柱;头部的 ChartMetrics 把几个关键指标并排摆在一起。
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
ChartSeries,
} from "@/components/ui/chart-card/chart-card"
import { formatDay } from "@/lib/data-display"
const DATA = Array.from({ length: 28 }, (_, index) => ({
day: `2026-02-${String(index + 1).padStart(2, "0")}`,
fast: Math.round(5200 + 1400 * Math.sin(index * 0.5) + index * 40),
slow: Math.round(520 + 380 * Math.abs(Math.sin(index * 1.3))),
}))
export default function ChartCardStackedDemo() {
return (
<ChartCard className="w-full max-w-3xl">
<ChartCardHeader className="sm:items-center">
<div className="flex flex-col gap-1">
<ChartCardTitle>api.yiui.dev</ChartCardTitle>
<ChartCardDescription>实例 ID:edge-hk-01</ChartCardDescription>
</div>
<ChartMetrics>
<ChartMetric label="平均响应" value="42 ms" />
<ChartMetric label="慢请求" value="1,243 次" />
<ChartMetric label="可用率" value="99.97%" />
</ChartMetrics>
</ChartCardHeader>
<ChartCardContent>
<ChartSeries
type="bar"
stacked
data={DATA}
xKey="day"
series={[
{ key: "fast", label: "快请求", tone: "green" },
{ key: "slow", label: "慢请求", tone: "rose" },
]}
xTickFormatter={(value) => formatDay(value, "short")}
labelFormatter={(value) => formatDay(value, "long")}
className="h-60"
label="2 月每天快、慢请求数量堆叠柱图"
/>
</ChartCardContent>
</ChartCard>
)
}
正负对比
stacked="diverging" 让正值向上、负值向下堆叠,适合新增与流失、收入与支出这类方向相反的数据:把流失记为负数,两组数据就落在零线两侧,提示框里仍显示各自的数值。
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
ChartSeries,
} from "@/components/ui/chart-card/chart-card"
// 取消订阅记为负数,stacked="diverging" 会把它画在零线下方
const WEEKS = [
{ week: "08/03", joined: 86, left: -12 },
{ week: "08/10", joined: 92, left: -18 },
{ week: "08/17", joined: 78, left: -9 },
{ week: "08/24", joined: 105, left: -22 },
{ week: "08/31", joined: 118, left: -15 },
{ week: "09/07", joined: 96, left: -26 },
{ week: "09/14", joined: 124, left: -14 },
{ week: "09/21", joined: 131, left: -19 },
{ week: "09/28", joined: 109, left: -31 },
{ week: "10/05", joined: 142, left: -17 },
{ week: "10/12", joined: 138, left: -21 },
{ week: "10/19", joined: 156, left: -13 },
]
export default function ChartCardDivergingDemo() {
return (
<ChartCard className="w-full max-w-3xl">
<ChartCardHeader className="sm:items-center">
<div className="flex flex-col gap-1">
<ChartCardTitle>订阅变化</ChartCardTitle>
<ChartCardDescription>近 12 周 · 每周新增与取消</ChartCardDescription>
</div>
<ChartMetrics>
<ChartMetric label="新增" value="1,375" tone="green" />
<ChartMetric label="取消" value="217" tone="rose" />
<ChartMetric label="净增" value="1,158" />
</ChartMetrics>
</ChartCardHeader>
<ChartCardContent>
<ChartSeries
type="bar"
stacked="diverging"
data={WEEKS}
xKey="week"
series={[
{ key: "joined", label: "新增订阅", tone: "green" },
{ key: "left", label: "取消订阅", tone: "rose" },
]}
yDomain={[-40, 160]}
yTicks={[-40, 0, 40, 80, 120, 160]}
className="h-60"
label="近 12 周每周新增与取消订阅人数对比柱图"
/>
</ChartCardContent>
</ChartCard>
)
}
排名条
ChartRanking 用名次作纵轴,条末标出数值,旁边(窄屏在下方)是带名称的图例格。百分比数据传 max={100},其他数据默认取最大值作满值。
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartRanking,
} from "@/components/ui/chart-card/chart-card"
const PAGES = [
{ label: "组件库 · 线条网格", value: 99 },
{ label: "智能体开发引导", value: 94 },
{ label: "博客 · 静态导出实战", value: 80 },
{ label: "文档 · 快速开始", value: 68 },
{ label: "组件库 · 世界时钟", value: 52 },
{ label: "关于我", value: 45 },
]
export default function ChartCardRankingDemo() {
return (
<ChartCard className="w-full max-w-3xl">
<ChartCardHeader>
<div className="flex flex-col gap-1">
<ChartCardTitle>热门页面</ChartCardTitle>
<ChartCardDescription>按近 30 天阅读完成率排名</ChartCardDescription>
</div>
</ChartCardHeader>
<ChartCardContent>
<ChartRanking items={PAGES} max={100} label="热门页面阅读完成率排名" />
</ChartCardContent>
</ChartCard>
)
}
配色与坐标轴
序列没有指定 tone 时按顺序取默认色板:蓝、琥珀、绿、紫、玫红、青。默认色板里没有主题强调色——强调色会落在其中某一种附近,切到对应的颜色主题时会和那条序列撞色;单条序列想跟随主题时显式写 tone="primary"。颜色全部来自主题变量,14 个颜色主题和亮暗模式下都能正确显示。tone 也可以是 chart-1 … chart-5、up / down(涨跌色,随 convention 变化)或任意 CSS 颜色。
坐标轴字号 11px,网格只有水平虚线,颜色是线条网格的 --edge。日期轴用 formatDay 变成「2月14日」或「02/14」,星期轴是单字「一…日」,纵轴整条轴共用一个单位:按数据最大值选「万 / 亿」(en 为 K / M / B),所有刻度都换算成它,不会出现 9000 与 1.2万 混在一起;数据的最大值不到 1 万时刻度都是普通整数。
API
ChartCard
涨跌配色约定,里面的图表与指标共用;不设置时沿用外层
类型DataConvention | undefinedChartMetric
指标名
类型React.ReactNode指标值
类型React.ReactNode数值前的色标,用来对应图表里的某条序列
类型DataTone | undefinedChartTable
ChartTableColumn[]ChartTableRow[]ChartGauge
当前值
类型number满值
类型number | undefined100刻度数量;刻度越多越像一圈连续的光带
类型number | undefined60刻度环的总角度:360 是整圆,270 是下方留口的仪表
类型number | undefined270点亮刻度的色调
类型DataTone | undefined"primary"点亮的刻度由淡到浓渐变,最后一格最亮最长,像指针停在当前值上
类型boolean | undefinedtrue中央大数字的格式,默认取整后的百分比
类型StatFormatOptions | undefined中央数字下方的小字,如「达成率」
类型React.ReactNode中央内容,传入后替代默认的数字与小字
类型React.ReactNode读屏名称
类型string | undefinedChartSeries
数据行,每行一个横轴位置
类型Record<string, string | number>[]横轴对应的字段名
类型string要画的序列
类型ChartSeriesItem[]图形:折线、柱、面积
类型"area" | "bar" | "line" | undefined"line"堆叠柱 / 面积;"diverging" 让正负值分别向上、向下堆叠,用来对比两组相反方向的数据
"diverging" | boolean | undefinedfalse折线与面积的插值:平滑、折线、阶梯
类型"linear" | "monotone" | "step" | undefined"monotone"横轴刻度文字的格式化,如把 "2026-02-14" 变成 "2月14日"
((value: string, index: number) => string) | undefined提示框标题的格式化,默认同横轴刻度
类型((value: string) => string) | undefined提示框里数值的格式,默认 zh-CN 压缩;纵轴取其中的 style、locale、currency、digits,并按整条轴统一单位(万 / 亿或 K / M / B)
StatFormatOptions | undefined纵轴范围,不设置时自动
类型[number, number] | undefined纵轴刻度位置
类型number[] | undefined显示纵轴
类型boolean | undefinedtrue显示水平虚线网格
类型boolean | undefinedtrue在图表上方显示图例;默认序列多于一条时显示
类型boolean | undefined柱宽上限(像素)
类型number | undefined涨跌配色约定,仅序列使用 up / down 色调时有用;不设置时沿用外层
DataConvention | undefined读屏名称,描述这张图表示什么
类型string | undefinedChartRanking
排名条目,按从高到低排列
类型ChartRankingItem[]横轴满值;百分比数据用 100,其余数据默认取最大值
类型number | undefined数值格式,默认是不压缩的百分比
类型StatFormatOptions | undefined{ style: "percent", digits: 0 }在图表旁显示图例格(名称 + 数值);窄屏落到图表下方
类型boolean | undefinedtrue读屏名称,描述这张图表示什么
类型string | undefinedChartLegendList
ChartLegendItem[]色标形状:dot 圆点(柱、面积),line 短横线(折线)
"dot" | "line" | undefined"dot"ChartLegendGrid
ChartLegendItem[]ChartCardHeader、ChartCardTitle、ChartCardDescription、ChartCardAction、ChartCardContent、ChartCardFooter、ChartMetrics 是带样式的容器,属性同对应的原生元素。
可访问性
- 图表容器是
role="img",名称来自label;悬停提示框读取精确数值。需要让读屏用户拿到数据时,配一张ChartTable或在文字里写出关键数字。 ChartGauge是role="meter",带aria-valuenow和aria-valuemax;中央的数字是真实文本。ChartTable用语义化的<table>,表头scope="col",窄屏横向滚动而不是挤压。- 序列靠图例的名称区分,不只靠颜色。
- 开启「减少动态效果」后,recharts 的入场动画关闭,仪表的刻度不再依次淡入;仪表的刻度由服务端直接输出,淡入是纯 CSS,没有脚本也完整显示。
注意事项
- 图表由
ResponsiveContainer撑满容器,服务端渲染时先按 320×200 占位,水合后量出真实尺寸;ChartSeries的高度用className(默认h-64)控制。 - 纵轴宽度用
width="auto"按刻度文字自适应,需要和别的图表对齐时给相同的yDomain与yTicks。 - 涨跌配色约定见 统计卡片;
ChartCard与ChartSeries也接受convention。
源码
import * as React from "react"
import { cn } from "@/lib/utils"
import {
conventionClass,
toneColor,
type DataConvention,
type DataTone,
} from "@/lib/data-display"
import { ChartGauge, type ChartGaugeProps } from "./chart-gauge"
import {
ChartLegendGrid,
ChartLegendList,
type ChartLegendGridProps,
type ChartLegendItem,
type ChartLegendListProps,
} from "./chart-legend"
import {
ChartRanking,
type ChartRankingItem,
type ChartRankingProps,
} from "./chart-ranking"
import {
ChartSeries,
type ChartSeriesItem,
type ChartSeriesProps,
} from "./chart-series"
type ChartCardProps = React.ComponentProps<"div"> & {
/** 涨跌配色约定,里面的图表与指标共用;不设置时沿用外层 */
convention?: DataConvention
}
/** 图表卡片的外壳:细边框、无阴影;用下面的 Header / Content / Footer 分区,分区之间用细线隔开。 */
function ChartCard({ className, convention, ...props }: ChartCardProps) {
return (
<div
data-slot="chart-card"
data-convention={convention}
className={cn(
"flex min-w-0 flex-col overflow-hidden rounded-lg border border-edge bg-card text-sm text-card-foreground",
conventionClass(convention),
className,
)}
{...props}
/>
)
}
/** 卡片头部:左边标题与说明,右边放 `ChartCardAction`;窄屏上下堆叠。 */
function ChartCardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="chart-card-header"
className={cn(
"flex flex-col gap-3 border-b border-edge p-4 sm:flex-row sm:items-start sm:justify-between",
className,
)}
{...props}
/>
)
}
// 用 div 而不是 h3:标题层级由所在页面决定,需要时自己加 role="heading" 与 aria-level
function ChartCardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="chart-card-title"
className={cn("text-sm leading-5 font-medium", className)}
{...props}
/>
)
}
function ChartCardDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="chart-card-description"
className={cn("text-[13px] leading-5 text-muted-foreground", className)}
{...props}
/>
)
}
/** 头部右侧的操作区:按钮、选择器、图例等。 */
function ChartCardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="chart-card-action"
className={cn("flex shrink-0 items-center gap-2", className)}
{...props}
/>
)
}
function ChartCardContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="chart-card-content"
className={cn("min-w-0 p-4", className)}
{...props}
/>
)
}
function ChartCardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="chart-card-footer"
className={cn(
"flex items-center gap-3 border-t border-edge p-4 text-xs text-muted-foreground",
className,
)}
{...props}
/>
)
}
/** 指标行:几个并排的小指标,之间用竖线隔开,放在头部或图表上方。 */
function ChartMetrics({ className, ...props }: React.ComponentProps<"dl">) {
return (
<dl
data-slot="chart-metrics"
className={cn(
"flex flex-wrap items-center gap-y-2 divide-x divide-edge",
className,
)}
{...props}
/>
)
}
type ChartMetricProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 指标名 */
label: React.ReactNode
/** 指标值 */
value: React.ReactNode
/** 数值前的色标,用来对应图表里的某条序列 */
tone?: DataTone
}
function ChartMetric({
className,
label,
value,
tone,
...props
}: ChartMetricProps) {
return (
<div
data-slot="chart-metric"
className={cn(
"flex flex-col gap-1 px-4 first:pl-0 last:pr-0 max-sm:px-3",
className,
)}
{...props}
>
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
{tone ? (
<span
aria-hidden="true"
className="size-1.5 rounded-full"
style={{ backgroundColor: toneColor(tone) }}
/>
) : null}
{label}
</dt>
<dd className="text-sm leading-none font-medium tabular-nums">{value}</dd>
</div>
)
}
type ChartTableColumn = {
/** 对应行数据里的字段名 */
key: string
/** 表头 */
header: React.ReactNode
/**
* 对齐方式;数字列用 `end`
* @default "start"
*/
align?: "start" | "center" | "end"
}
type ChartTableRow = {
/** 行首的色标,对应图表里的序列 */
tone?: DataTone
/** 各列的内容,字段名与 `columns[].key` 对应 */
[column: string]: React.ReactNode
}
type ChartTableProps = Omit<React.ComponentProps<"table">, "children"> & {
columns: ChartTableColumn[]
rows: ChartTableRow[]
}
const ALIGN = {
start: "text-left",
center: "text-center",
end: "text-right",
} as const
/** 图表下方的明细表:第一列前带一根与序列同色的竖条,数字列右对齐、等宽。 */
function ChartTable({ className, columns, rows, ...props }: ChartTableProps) {
return (
<div data-slot="chart-table" className="w-full overflow-x-auto">
<table
data-slot="chart-table-element"
className={cn("w-full min-w-max border-collapse text-sm", className)}
{...props}
>
<thead>
<tr className="border-b border-edge text-xs text-muted-foreground">
{columns.map((column) => (
<th
key={column.key}
scope="col"
className={cn(
"px-3 py-2 font-normal first:pl-0 last:pr-0",
ALIGN[column.align ?? "start"],
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-edge">
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((column, columnIndex) => (
<td
key={column.key}
className={cn(
"px-3 py-2.5 tabular-nums first:pl-0 last:pr-0",
ALIGN[column.align ?? "start"],
)}
>
{columnIndex === 0 && row.tone ? (
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="h-5 w-1 shrink-0 rounded-full"
style={{ backgroundColor: toneColor(row.tone) }}
/>
{row[column.key]}
</span>
) : (
row[column.key]
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export {
ChartCard,
ChartCardAction,
ChartCardContent,
ChartCardDescription,
ChartCardFooter,
ChartCardHeader,
ChartCardTitle,
ChartGauge,
ChartLegendGrid,
ChartLegendList,
ChartMetric,
ChartMetrics,
ChartRanking,
ChartSeries,
ChartTable,
}
export type {
ChartCardProps,
ChartGaugeProps,
ChartLegendGridProps,
ChartLegendItem,
ChartLegendListProps,
ChartMetricProps,
ChartRankingItem,
ChartRankingProps,
ChartSeriesItem,
ChartSeriesProps,
ChartTableColumn,
ChartTableProps,
ChartTableRow,
}
import * as React from "react"
import { cn } from "@/lib/utils"
import {
formatStat,
motionStyles,
toneColor,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
type ChartGaugeProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 当前值 */
value: number
/**
* 满值
* @default 100
*/
max?: number
/**
* 刻度数量;刻度越多越像一圈连续的光带
* @default 60
*/
ticks?: number
/**
* 刻度环的总角度:360 是整圆,270 是下方留口的仪表
* @default 270
*/
arc?: number
/**
* 点亮刻度的色调
* @default "primary"
*/
tone?: DataTone
/**
* 点亮的刻度由淡到浓渐变,最后一格最亮最长,像指针停在当前值上
* @default true
*/
fade?: boolean
/** 中央大数字的格式,默认取整后的百分比 */
format?: StatFormatOptions
/** 中央数字下方的小字,如「达成率」 */
caption?: React.ReactNode
/** 中央内容,传入后替代默认的数字与小字 */
children?: React.ReactNode
/** 读屏名称 */
label?: string
}
const CENTER = 100
const OUTER = 94
const TICK = 12
/**
* 刻度式环形仪表:一圈细刻度,按值依次点亮。
* 点亮的刻度从起点向当前值逐格淡入;服务端直接输出最终状态,淡入是纯 CSS,没有脚本也完整显示;系统开启「减少动态效果」时不播放。
*/
/** 三角函数在 Node 与浏览器里末位小数可能不同,取 3 位小数让服务端与客户端输出一致,避免水合告警。 */
function round(value: number) {
return Math.round(value * 1000) / 1000
}
function ChartGauge({
className,
value,
max = 100,
ticks = 60,
arc = 270,
tone = "primary",
fade = true,
format,
caption,
children,
label,
...props
}: ChartGaugeProps) {
const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0
const lit = Math.round(ratio * ticks)
const color = toneColor(tone)
const sweep = Math.min(360, Math.max(30, arc))
// 角度从 12 点钟顺时针量;开口仪表让缺口落在正下方,起点在左下
const start = sweep === 360 ? 0 : 180 + (360 - sweep) / 2
const step = sweep === 360 ? 360 / ticks : sweep / Math.max(1, ticks - 1)
const marks = Array.from({ length: ticks }, (_, index) => {
const angle = ((start + index * step) * Math.PI) / 180
const sin = Math.sin(angle)
const cos = Math.cos(angle)
const head = lit > 0 && index === lit - 1
// 最后一格点亮的刻度比其余长,作为「当前值」的标记
const length = head ? TICK + 6 : TICK
return {
index,
head,
on: index < lit,
x1: round(CENTER + (OUTER - length) * sin),
y1: round(CENTER - (OUTER - length) * cos),
x2: round(CENTER + OUTER * sin),
y2: round(CENTER - OUTER * cos),
opacity: head || !fade || lit < 2 ? 1 : 0.3 + 0.7 * (index / (lit - 1)),
}
})
return (
<div
data-slot="chart-gauge"
role="meter"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
className={cn(
"relative aspect-square size-44 max-w-full shrink-0",
className,
)}
{...props}
>
{motionStyles()}
<svg
viewBox="0 0 200 200"
aria-hidden="true"
className="absolute inset-0 size-full"
>
{/* 稳定的淡色底环不依赖入场动画,慢网与无脚本时仍有完整轮廓。 */}
<circle
data-slot="chart-gauge-placeholder"
cx={CENTER}
cy={CENTER}
r={OUTER - TICK / 2}
fill="none"
stroke="var(--muted)"
strokeWidth={TICK}
pathLength={1}
strokeDasharray={`${sweep / 360} 1`}
transform={`rotate(${start - 90} 100 100)`}
/>
{marks.map((mark) =>
mark.on ? (
<line
key={mark.index}
data-dd-anim="fade"
x1={mark.x1}
y1={mark.y1}
x2={mark.x2}
y2={mark.y2}
stroke={color}
strokeWidth={mark.head ? 3.5 : 3}
strokeLinecap="round"
opacity={mark.opacity}
style={{
animationDelay: `${Math.round((mark.index / ticks) * 700)}ms`,
}}
/>
) : (
<line
key={mark.index}
x1={mark.x1}
y1={mark.y1}
x2={mark.x2}
y2={mark.y2}
stroke="var(--edge)"
strokeWidth={3}
strokeLinecap="round"
/>
),
)}
</svg>
<div
data-slot="chart-gauge-content"
className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-center tabular-nums"
>
{children ?? (
<>
<span className="text-3xl leading-none font-semibold tracking-tight">
{formatStat(ratio * 100, {
style: "percent",
digits: 0,
...format,
})}
</span>
{caption ? (
<span className="text-xs text-muted-foreground">{caption}</span>
) : null}
</>
)}
</div>
</div>
)
}
export { ChartGauge }
export type { ChartGaugeProps }
"use client"
import * as React from "react"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts"
import { cn } from "@/lib/utils"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
SERIES_TONES,
conventionClass,
createAxisFormatter,
formatStat,
toneColor,
type DataConvention,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
import { ChartLegendList } from "./chart-legend"
import { CHART_AXIS_TICK, CHART_GRID, useChartAnimation } from "./chart-parts"
type ChartSeriesItem = {
/** 数据里这条序列的字段名 */
key: string
/** 图例与提示框里的名称 */
label: string
/** 色调,不设置时按顺序取默认色板(蓝、琥珀、绿、紫、玫红、青) */
tone?: DataTone
}
type ChartSeriesProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 数据行,每行一个横轴位置 */
data: Record<string, string | number>[]
/** 横轴对应的字段名 */
xKey: string
/** 要画的序列 */
series: ChartSeriesItem[]
/**
* 图形:折线、柱、面积
* @default "line"
*/
type?: "line" | "bar" | "area"
/**
* 堆叠柱 / 面积;`"diverging"` 让正负值分别向上、向下堆叠,用来对比两组相反方向的数据
* @default false
*/
stacked?: boolean | "diverging"
/**
* 折线与面积的插值:平滑、折线、阶梯
* @default "monotone"
*/
curve?: "monotone" | "linear" | "step"
/** 横轴刻度文字的格式化,如把 `"2026-02-14"` 变成 `"2月14日"` */
xTickFormatter?: (value: string, index: number) => string
/** 提示框标题的格式化,默认同横轴刻度 */
labelFormatter?: (value: string) => string
/** 提示框里数值的格式,默认 `zh-CN` 压缩;纵轴取其中的 `style`、`locale`、`currency`、`digits`,并按整条轴统一单位(万 / 亿或 K / M / B) */
format?: StatFormatOptions
/** 纵轴范围,不设置时自动 */
yDomain?: [number, number]
/** 纵轴刻度位置 */
yTicks?: number[]
/**
* 显示纵轴
* @default true
*/
showYAxis?: boolean
/**
* 显示水平虚线网格
* @default true
*/
showGrid?: boolean
/**
* 在图表上方显示图例;默认序列多于一条时显示
*/
showLegend?: boolean
/** 柱宽上限(像素) */
barSize?: number
/** 涨跌配色约定,仅序列使用 `up` / `down` 色调时有用;不设置时沿用外层 */
convention?: DataConvention
/** 读屏名称,描述这张图表示什么 */
label?: string
}
/** 多序列图表:折线、柱、面积三合一,支持堆叠与正负对比。高度用 `className` 控制(默认 `h-64`)。 */
function ChartSeries({
className,
data,
xKey,
series,
type = "line",
stacked = false,
curve = "monotone",
xTickFormatter,
labelFormatter,
format,
yDomain,
yTicks,
showYAxis = true,
showGrid = true,
showLegend = series.length > 1,
barSize,
convention,
label,
...props
}: ChartSeriesProps) {
const ready = React.useSyncExternalStore(
subscribe,
() => true,
() => false,
)
const animation = useChartAnimation()
const gradientBase = React.useId().replaceAll(":", "")
const resolved = series.map((item, index) => ({
...item,
color: toneColor(item.tone ?? SERIES_TONES[index % SERIES_TONES.length]),
}))
const config: ChartConfig = {}
for (const item of resolved)
config[item.key] = { label: item.label, color: item.color }
// 纵轴整条轴共用一个单位:先找出数据(堆叠时是每列的合计)的最大绝对值,再选「万 / 亿」或 K / M
const keys = series.map((item) => item.key)
const peak =
yDomain && yDomain.length === 2
? Math.max(Math.abs(yDomain[0]), Math.abs(yDomain[1]))
: Math.max(
0,
...data.map((row) => {
const values = keys.map((key) => Number(row[key]) || 0)
return stacked
? Math.max(
values.filter((v) => v > 0).reduce((a, b) => a + b, 0),
-values.filter((v) => v < 0).reduce((a, b) => a + b, 0),
)
: Math.max(...values.map(Math.abs))
}),
)
const formatTick = createAxisFormatter(peak, format)
const margin = { top: 8, right: 8, bottom: 0, left: 0 }
const grid = showGrid ? <CartesianGrid {...CHART_GRID} /> : null
const xAxis = (
<XAxis
dataKey={xKey}
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={16}
tick={CHART_AXIS_TICK}
tickFormatter={xTickFormatter}
/>
)
const yAxis = (
<YAxis
hide={!showYAxis}
width="auto"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={CHART_AXIS_TICK}
domain={yDomain}
ticks={yTicks}
tickFormatter={formatTick}
/>
)
const tooltip = (
<ChartTooltip
cursor={type === "bar" ? false : { stroke: "var(--edge)" }}
content={
<ChartTooltipContent
className="min-w-36 shadow-sm"
labelFormatter={
labelFormatter
? (value) => labelFormatter(String(value))
: undefined
}
formatter={(value, name, item) => (
<>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
<span className="text-muted-foreground">{name}</span>
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
{formatStat(Number(value), format)}
</span>
</>
)}
/>
}
/>
)
const stackId = stacked ? "stack" : undefined
return (
<div
data-slot="chart-series"
data-ready={ready}
data-type={type}
className={cn(
"relative flex h-64 min-w-0 flex-col gap-3",
conventionClass(convention),
className,
)}
{...props}
>
{showLegend ? (
<ChartLegendList
marker={type === "line" ? "line" : "dot"}
items={resolved.map((item) => ({
label: item.label,
tone: item.color,
}))}
/>
) : null}
<ChartContainer
data-slot="chart-series-plot"
data-ready={ready}
config={config}
role="img"
aria-label={label}
initialDimension={{ width: 320, height: 200 }}
className="aspect-auto min-h-0 w-full flex-1 justify-start opacity-0 transition-opacity duration-200 data-[ready=true]:opacity-100 motion-reduce:transition-none"
>
{type === "bar" ? (
<BarChart
data={data}
margin={margin}
barCategoryGap="28%"
stackOffset={stacked === "diverging" ? "sign" : undefined}
>
{grid}
{xAxis}
{yAxis}
{tooltip}
{resolved.map((item, index) => (
<Bar
key={item.key}
dataKey={item.key}
name={item.label}
fill={`var(--color-${item.key})`}
stackId={stackId}
maxBarSize={barSize ?? 24}
radius={
stacked &&
stacked !== "diverging" &&
index < resolved.length - 1
? 0
: [3, 3, 0, 0]
}
{...animation}
/>
))}
</BarChart>
) : type === "area" ? (
<AreaChart data={data} margin={margin}>
<defs>
{resolved.map((item) => (
<linearGradient
key={item.key}
id={`${gradientBase}-${item.key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={`var(--color-${item.key})`}
stopOpacity={0.28}
/>
<stop
offset="100%"
stopColor={`var(--color-${item.key})`}
stopOpacity={0}
/>
</linearGradient>
))}
</defs>
{grid}
{xAxis}
{yAxis}
{tooltip}
{resolved.map((item) => (
<Area
key={item.key}
dataKey={item.key}
name={item.label}
type={curve === "step" ? "stepAfter" : curve}
stroke={`var(--color-${item.key})`}
strokeWidth={2}
fill={`url(#${gradientBase}-${item.key})`}
stackId={stackId}
activeDot={{ r: 3, strokeWidth: 0 }}
{...animation}
/>
))}
</AreaChart>
) : (
<LineChart data={data} margin={margin}>
{grid}
{xAxis}
{yAxis}
{tooltip}
{resolved.map((item) => (
<Line
key={item.key}
dataKey={item.key}
name={item.label}
type={curve === "step" ? "stepAfter" : curve}
stroke={`var(--color-${item.key})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
{...animation}
/>
))}
</LineChart>
)}
</ChartContainer>
<div
data-slot="chart-series-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 { ChartSeries }
export type { ChartSeriesItem, ChartSeriesProps }
"use client"
import * as React from "react"
import { Bar, BarChart, Cell, LabelList, XAxis, YAxis } from "recharts"
import { cn } from "@/lib/utils"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
import {
SERIES_TONES,
formatStat,
toneColor,
type DataTone,
type StatFormatOptions,
} from "@/lib/data-display"
import { ChartLegendGrid } from "./chart-legend"
import { CHART_AXIS_TICK, useChartAnimation } from "./chart-parts"
type ChartRankingItem = {
/** 条目名称,图例里显示 */
label: string
/** 数值,单位由 `format` 决定(默认按百分比显示) */
value: number
/** 色调,不设置时按顺序取默认色板 */
tone?: DataTone
}
type ChartRankingProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 排名条目,按从高到低排列 */
items: ChartRankingItem[]
/**
* 横轴满值;百分比数据用 100,其余数据默认取最大值
*/
max?: number
/**
* 数值格式,默认是不压缩的百分比
* @default { style: "percent", digits: 0 }
*/
format?: StatFormatOptions
/**
* 在图表旁显示图例格(名称 + 数值);窄屏落到图表下方
* @default true
*/
showLegend?: boolean
/** 读屏名称,描述这张图表示什么 */
label?: string
}
const ROW_HEIGHT = 36
/**
* 横向排名条:左侧是名次,条末标出数值,右侧(窄屏在下方)是带名称的图例格。
* 名次对应图例的顺序,所以图例同样按名次排列。
*/
function ChartRanking({
className,
items,
max,
format = { style: "percent", digits: 0 },
showLegend = true,
label,
...props
}: ChartRankingProps) {
const ready = React.useSyncExternalStore(
subscribe,
() => true,
() => false,
)
const animation = useChartAnimation()
const data = items.map((item, index) => ({
rank: String(index + 1),
label: item.label,
value: item.value,
fill: toneColor(item.tone ?? SERIES_TONES[index % SERIES_TONES.length]),
}))
const config: ChartConfig = { value: { label: "数值" } }
const ceiling = max ?? Math.max(...items.map((item) => item.value), 1)
return (
<div
data-slot="chart-ranking"
data-ready={ready}
className={cn(
"relative grid min-w-0 items-center gap-6",
showLegend && "lg:grid-cols-2",
className,
)}
{...props}
>
<ChartContainer
data-slot="chart-ranking-plot"
data-ready={ready}
config={config}
role="img"
aria-label={label}
initialDimension={{ width: 320, height: items.length * ROW_HEIGHT }}
className="aspect-auto w-full justify-start opacity-0 transition-opacity duration-200 data-[ready=true]:opacity-100 motion-reduce:transition-none"
style={{ height: items.length * ROW_HEIGHT + 24 }}
>
<BarChart
data={data}
layout="vertical"
barCategoryGap="30%"
margin={{ top: 0, right: 40, bottom: 0, left: 0 }}
>
<XAxis type="number" domain={[0, ceiling]} hide />
<YAxis
dataKey="rank"
type="category"
width={20}
tickLine={false}
axisLine={false}
tick={CHART_AXIS_TICK}
/>
<Bar
dataKey="value"
radius={[0, 4, 4, 0]}
background={{ fill: "var(--muted)", radius: 4 }}
{...animation}
>
{data.map((entry) => (
<Cell key={entry.label} fill={entry.fill} />
))}
<LabelList
dataKey="value"
position="right"
offset={8}
className="fill-foreground text-xs tabular-nums"
formatter={(value: unknown) => formatStat(Number(value), format)}
/>
</Bar>
</BarChart>
</ChartContainer>
{showLegend ? (
<ChartLegendGrid
items={items.map((item, index) => ({
label: `${index + 1}. ${item.label}`,
tone: item.tone ?? SERIES_TONES[index % SERIES_TONES.length],
value: formatStat(item.value, format),
}))}
/>
) : null}
<div
data-slot="chart-ranking-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",
)}
>
<div className="absolute inset-0 flex flex-col justify-around gap-2 p-3">
{items.map((item, index) => (
<div
key={index}
className="h-3 shrink rounded-sm bg-muted-foreground/15"
style={{
width: `${Math.max(12, Math.min(90, (item.value / ceiling) * 90))}%`,
}}
/>
))}
</div>
</div>
</div>
)
}
const subscribe = () => () => {}
export { ChartRanking }
export type { ChartRankingItem, ChartRankingProps }
import * as React from "react"
import { cn } from "@/lib/utils"
import { toneColor, type DataTone } from "@/lib/data-display"
type ChartLegendItem = {
label: string
tone: DataTone
/** 图例项右侧的数值,已格式化的文字 */
value?: React.ReactNode
}
type ChartLegendListProps = Omit<React.ComponentProps<"ul">, "children"> & {
items: ChartLegendItem[]
/**
* 色标形状:`dot` 圆点(柱、面积),`line` 短横线(折线)
* @default "dot"
*/
marker?: "dot" | "line"
}
/** 行内图例:色标 + 名称,放在图表上方,窄屏自动换行。 */
function ChartLegendList({
className,
items,
marker = "dot",
...props
}: ChartLegendListProps) {
return (
<ul
data-slot="chart-legend-list"
className={cn("flex flex-wrap items-center gap-x-4 gap-y-1", className)}
{...props}
>
{items.map((item) => (
<li
key={item.label}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<span
aria-hidden="true"
className={cn(
"shrink-0",
marker === "dot"
? "size-2 rounded-full"
: "h-0.5 w-3 rounded-full",
)}
style={{ backgroundColor: toneColor(item.tone) }}
/>
{item.label}
</li>
))}
</ul>
)
}
type ChartLegendGridProps = Omit<React.ComponentProps<"ul">, "children"> & {
items: ChartLegendItem[]
}
/** 图例格:两列,每项是圆点、名称和下面一行大号数值(通常是百分比)。 */
function ChartLegendGrid({ className, items, ...props }: ChartLegendGridProps) {
return (
<ul
data-slot="chart-legend-grid"
className={cn("grid grid-cols-2 gap-x-6 gap-y-4", className)}
{...props}
>
{items.map((item) => (
<li key={item.label} className="flex min-w-0 items-start gap-2.5">
<span
aria-hidden="true"
className="mt-1.5 size-2 shrink-0 rounded-full"
style={{ backgroundColor: toneColor(item.tone) }}
/>
<span className="flex min-w-0 flex-col gap-1">
<span className="line-clamp-2 text-[13px] text-muted-foreground">
{item.label}
</span>
<span className="text-lg leading-none font-semibold tabular-nums">
{item.value}
</span>
</span>
</li>
))}
</ul>
)
}
export { ChartLegendGrid, ChartLegendList }
export type { ChartLegendGridProps, ChartLegendItem, ChartLegendListProps }
"use client"
import { useReducedMotion } from "motion/react"
/** 坐标轴刻度:11px、次要文字色。 */
const CHART_AXIS_TICK = {
fontSize: 11,
fill: "var(--muted-foreground)",
} as const
/** 网格线:只画水平线,细、淡、虚线,颜色取线条网格的 `--edge`。 */
const CHART_GRID = {
vertical: false,
stroke: "var(--edge)",
strokeDasharray: "3 4",
} as const
/** recharts 的入场动画开关:系统开启「减少动态效果」时关闭。 */
function useChartAnimation() {
const reduced = useReducedMotion()
return { isAnimationActive: !reduced, animationDuration: 700 } as const
}
export { CHART_AXIS_TICK, CHART_GRID, useChartAnimation }
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,
}