跳至正文
组件

Mini Chart迷你图

放进卡片的极简图表:柱、折线、面积、阶梯线、进度环与环形图。无网格无纵轴,可选 11px 横轴,涨跌色随配色约定变化,尊重「减少动态效果」。

正在加载…

安装

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

条目依赖 shadcn 的 chart(recharts 的封装),安装时会一并添加。示例里的卡片外壳用了 统计卡片,它不是必需的依赖。

用法

MiniChart 一个组件画四种图,用 type 切换;data 可以是纯数字,也可以是带标签的 { label, value }。高度用 className 控制,默认 h-16,宽度撑满父级。

app/spark.tsx
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 从同一个入口导入。

示例

指标面板

六块指标拼成一条小仪表盘,每块用不同图形与色调,底部是数值与涨跌。

正在加载…

四种图形

bar 柱、line 折线、area 面积(带极淡的渐变)、step 阶梯线。折线和面积的纵轴范围贴着数据的最小到最大值,起伏更明显;柱图从 0 开始。

正在加载…

横轴与强调

showAxis 打开极简横轴:11px 刻度,没有轴线和刻度线,只保留首尾和间隔均匀的标签。tickFormatter 把标签变成想要的样子,星期轴用单字「一…日」。柱图的 highlight 强调一根(下标、"last" 或 "max"),其余变淡;track 给每根柱加上底槽。

正在加载…

涨跌配色

tone="trend" 按首尾两点自动选涨色或跌色。颜色来自 --stat-up / --stat-down,由 convention 切换绿涨红跌与红涨绿跌,用法见 统计卡片。

正在加载…

进度环

MiniRing 是纯 SVG,没有用图表库:arc 控制圆弧总角度(360 是整圆,270 是下方留口的仪表),thickness 控制粗细,children 替换中央的默认百分比。

正在加载…

环形图

MiniDonut 画几个分类的占比,分段之间留 2° 的缝,悬停有提示框,中央放合计。图例由你自己写,颜色按顺序对应默认色板。

正在加载…

API

MiniChart

根元素 data-slot="mini-chart",带 data-type;图形颜色通过 shadcn ChartContainer 注入 --color-value。

MiniRing

MiniDonut

可访问性

  • 图表本身是 role="img",名称来自 label;数据通过悬停提示框读取,需要精确数值请同时在文字里写出(如卡片里的大号数字)。
  • MiniRing 是 role="meter",带 aria-valuenow 和 aria-valuemax。
  • 开启「减少动态效果」后,recharts 的入场动画关闭,进度环不播放入场动画;进度环由服务端直接输出最终弧长,没有脚本也完整显示。
  • 涨跌趋势不只靠颜色:请在旁边配 StatTrend 这样带箭头和文字的元素。

注意事项

  • 图表由 ResponsiveContainer 撑满父级,服务端渲染时先按 160×64 占位,水合后量出真实尺寸;父级必须有确定宽度。
  • 同一页面放很多迷你图时,给不需要提示框的设置 showTooltip={false},可以省掉事件监听。
  • 提示框的数值用 format(StatFormatOptions)格式化,默认 zh-CN 压缩。

源码

components/ui/mini-chart/mini-chart.tsx
"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,
}
components/ui/mini-chart/mini-ring.tsx
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 }
components/ui/mini-chart/mini-donut.tsx
"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 }
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,
}