{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-chart",
  "title": "Donut Chart",
  "description": "A donut, pie, or half-donut with a center label, tooltip, and legend, on shadcn's chart primitives.",
  "dependencies": [
    "recharts"
  ],
  "registryDependencies": [
    "chart"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/ui/donut-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Label, Pie, PieChart } from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  ChartContainer,\n  ChartLegend,\n  ChartLegendContent,\n  ChartTooltip,\n  ChartTooltipContent,\n  type ChartConfig,\n} from \"@/components/ui/chart\"\n\nexport interface DonutSlice {\n  name: string\n  value: number\n  /** Any CSS color. Defaults to chart-1 through chart-5 in order. */\n  color?: string\n}\n\nexport interface DonutChartProps\n  extends Omit<React.ComponentProps<typeof ChartContainer>, \"config\" | \"children\"> {\n  data: DonutSlice[]\n  /** Inner radius as a fraction of the outer radius. 0 draws a pie. */\n  innerRadius?: number\n  /** Label shown in the middle, under the value. */\n  centerLabel?: React.ReactNode\n  /** Value shown in the middle. Defaults to the total. */\n  centerValue?: React.ReactNode\n  valueFormatter?: (value: number) => string\n  showLegend?: boolean\n  showTooltip?: boolean\n  /** Angle where the first slice starts, in degrees. */\n  startAngle?: number\n  /** Total sweep in degrees. 180 draws a half donut. */\n  sweep?: number\n  paddingAngle?: number\n  /** Animate slices after hydration and when data changes. Respects reduced motion. */\n  animate?: boolean\n  animationDuration?: number\n}\n\nconst reducedMotionQuery = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeToReducedMotion(onChange: () => void) {\n  const query = window.matchMedia(reducedMotionQuery)\n  query.addEventListener(\"change\", onChange)\n  return () => query.removeEventListener(\"change\", onChange)\n}\n\nfunction getReducedMotionSnapshot() {\n  return window.matchMedia(reducedMotionQuery).matches\n}\n\nfunction getReducedMotionServerSnapshot() {\n  return true\n}\n\nfunction DonutChart({\n  data,\n  innerRadius = 0.7,\n  centerLabel,\n  centerValue,\n  valueFormatter = (value) => formatNumber(value, { format: \"compact\" }),\n  showLegend = false,\n  showTooltip = true,\n  startAngle = 90,\n  sweep = 360,\n  paddingAngle = 2,\n  animate = true,\n  animationDuration = 500,\n  className,\n  ...props\n}: DonutChartProps) {\n  const reducedMotion = React.useSyncExternalStore(\n    subscribeToReducedMotion,\n    getReducedMotionSnapshot,\n    getReducedMotionServerSnapshot\n  )\n  const total = data.reduce((sum, slice) => sum + slice.value, 0)\n  const slices = data.map((slice, index) => ({\n    ...slice,\n    key: slice.name.toLowerCase().replace(/[^a-z0-9]+/g, \"-\"),\n    fill: slice.color ?? `var(--chart-${(index % 5) + 1})`,\n  }))\n  const config = Object.fromEntries(\n    slices.map((slice) => [slice.key, { label: slice.name, color: slice.fill }])\n  ) satisfies ChartConfig\n  const center = centerValue ?? valueFormatter(total)\n\n  return (\n    <ChartContainer\n      data-slot=\"donut-chart\"\n      config={config}\n      className={cn(\"mx-auto aspect-square w-full max-h-64\", className)}\n      {...props}\n    >\n      <PieChart>\n        {showTooltip ? (\n          <ChartTooltip\n            cursor={false}\n            content={\n              <ChartTooltipContent\n                hideLabel\n                nameKey=\"key\"\n                formatter={(value, _name, item) => (\n                  <div className=\"flex w-full items-center justify-between gap-4\">\n                    <span className=\"text-muted-foreground\">{item.payload.name}</span>\n                    <span className=\"font-mono font-medium tabular-nums\">\n                      {valueFormatter(Number(value))}\n                    </span>\n                  </div>\n                )}\n              />\n            }\n          />\n        ) : null}\n        <Pie\n          data={slices}\n          dataKey=\"value\"\n          nameKey=\"key\"\n          innerRadius={`${innerRadius * 100}%`}\n          outerRadius=\"100%\"\n          startAngle={startAngle}\n          endAngle={startAngle - sweep}\n          paddingAngle={paddingAngle}\n          strokeWidth={0}\n          cornerRadius={2}\n          isAnimationActive={animate && !reducedMotion}\n          animationDuration={animationDuration}\n          animationEasing=\"ease-out\"\n        >\n          {innerRadius > 0 && (center || centerLabel) ? (\n            <Label\n              content={({ viewBox }) => {\n                if (!viewBox || !(\"cx\" in viewBox) || !(\"cy\" in viewBox)) return null\n                const cx = Number(viewBox.cx)\n                const cy = Number(viewBox.cy) + (sweep < 360 ? 12 : 0)\n                return (\n                  <text x={cx} y={cy} textAnchor=\"middle\" dominantBaseline=\"middle\">\n                    <tspan\n                      x={cx}\n                      y={cy}\n                      className=\"fill-foreground text-2xl font-semibold tabular-nums\"\n                    >\n                      {center}\n                    </tspan>\n                    {centerLabel ? (\n                      <tspan\n                        x={cx}\n                        y={cy + 22}\n                        className=\"fill-muted-foreground text-xs\"\n                      >\n                        {centerLabel}\n                      </tspan>\n                    ) : null}\n                  </text>\n                )\n              }}\n            />\n          ) : null}\n        </Pie>\n        {showLegend ? (\n          <ChartLegend\n            content={\n              <ChartLegendContent nameKey=\"key\" className=\"flex-wrap gap-x-4 gap-y-1\" />\n            }\n          />\n        ) : null}\n      </PieChart>\n    </ChartContainer>\n  )\n}\n\nexport { DonutChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}