跳至正文
组件

Chart Card图表卡片

图表卡片布局(标题、指标行、图例、明细表)加三个图表:刻度式环形仪表、多序列折线 / 柱 / 面积图(支持堆叠与正负对比)、横向排名条。基于 shadcn chart,颜色全部取自主题变量。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/chart-card.json
会连带安装 shadcn 组件 chart。已存在的文件会询问是否覆盖。

条目依赖 shadcn 的 chart(recharts 的封装),安装时会一并添加。

用法

ChartCard 是外壳,分 Header / Content / Footer 三个区域,区域之间用细线隔开;图表放在 Content 里。

app/traffic.tsx
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 替换中央内容。

正在加载…

多序列折线与明细表

ChartSeries 默认画折线;序列多于一条时在上方显示图例。图表下方的 ChartTable 用 columns 和 rows 描述明细,行里的 tone 对应图表里的序列。

正在加载…

堆叠柱与指标行

type="bar" 加 stacked 得到堆叠柱;头部的 ChartMetrics 把几个关键指标并排摆在一起。

正在加载…

正负对比

stacked="diverging" 让正值向上、负值向下堆叠,适合新增与流失、收入与支出这类方向相反的数据:把流失记为负数,两组数据就落在零线两侧,提示框里仍显示各自的数值。

正在加载…

排名条

ChartRanking 用名次作纵轴,条末标出数值,旁边(窄屏在下方)是带名称的图例格。百分比数据传 max={100},其他数据默认取最大值作满值。

正在加载…

配色与坐标轴

序列没有指定 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

ChartMetric

ChartTable

ChartGauge

ChartSeries

ChartRanking

ChartLegendList

ChartLegendGrid

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。

源码

components/ui/chart-card/chart-card.tsx
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,
}
components/ui/chart-card/chart-gauge.tsx
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 }
components/ui/chart-card/chart-series.tsx
"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 }
components/ui/chart-card/chart-ranking.tsx
"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 }
components/ui/chart-card/chart-legend.tsx
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 }
components/ui/chart-card/chart-parts.tsx
"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 }
lib/data-display.ts
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,
}