{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"donut-chart","type":"registry:ui","title":"Donut Chart","description":"Parts of a whole as a ring, with a legend of every value and share. Plain SVG, no chart library.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-count-to.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/donut-chart.tsx","type":"registry:file","target":"components/dashral/donut-chart.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { CountTo } from \"./lib/count-to\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\nexport type DonutChartDatum = {\n  label: string\n  value: number\n  /** Any CSS color; by default the theme's chart colors in order (--chart-1 … --chart-5). */\n  color?: string\n}\n\ntype DonutChartProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  data: DonutChartDatum[]\n  /** Diameter in pixels. */\n  size?: number\n  /** Ring width in pixels. */\n  thickness?: number\n  /** In the middle, e.g. the total. Hovering a slice or its legend row shows that slice there instead. */\n  children?: React.ReactNode\n  /** A list of slices with their values and shares beside the ring. */\n  showLegend?: boolean\n  /** BCP 47 locale for values and percentages. */\n  locale?: string\n  /** How values are written, e.g. `{ style: \"currency\", currency: \"USD\" }`. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** What the chart shows, e.g. \"Sales by channel\". */\n  \"aria-label\"?: string\n}\n\nconst percentOptions: Intl.NumberFormatOptions = { style: \"percent\", maximumFractionDigits: 1 }\nconst colorAt = (i: number) => `var(--chart-${(i % 5) + 1})`\n\n/**\n * Parts of a whole as a ring — best with two to five slices (more read better as a bar chart). The legend lists every\n * value, so nothing depends on telling colors apart. Slices sweep in when it first shows.\n */\nfunction DonutChart({\n  data,\n  size = 160,\n  thickness = 20,\n  children,\n  showLegend = true,\n  locale: localeProp,\n  formatOptions,\n  className,\n  \"aria-label\": ariaLabelProp,\n  ...props\n}: DonutChartProps) {\n  const { t, locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const ariaLabel = ariaLabelProp ?? t(\"donutChart\")\n  const [drawn, setDrawn] = React.useState(false)\n  const [active, setActive] = React.useState<number | null>(null)\n  React.useEffect(() => setDrawn(true), [])\n\n  const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0)\n  const format = new Intl.NumberFormat(locale, formatOptions)\n  const percent = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 })\n  const radius = (size - thickness) / 2\n  const circumference = 2 * Math.PI * radius\n  // A small gap between slices, unless there's only one.\n  const gap = data.filter((d) => d.value > 0).length > 1 ? Math.min(3, circumference / 100) : 0\n\n  let offset = 0\n  const slices = data.map((d, i) => {\n    const length = total ? (Math.max(0, d.value) / total) * circumference : 0\n    const slice = { ...d, i, color: d.color ?? colorAt(i), start: offset, length }\n    offset += length\n    return slice\n  })\n  const highlighted = active === null ? null : slices[active]\n\n  return (\n    <div data-slot=\"donut-chart\" className={cn(\"flex flex-wrap items-center gap-6\", className)} {...props}>\n      {/* Centered when the legend wraps under it; beside the legend, the legend takes the room. */}\n      <div className=\"relative mx-auto shrink-0\" style={{ width: size, height: size }}>\n        {/* Hover stays on while the pointer crosses the gaps between slices (and the hole); it ends when it leaves the ring. */}\n        <svg\n          width={size}\n          height={size}\n          viewBox={`0 0 ${size} ${size}`}\n          className=\"-rotate-90\"\n          role=\"img\"\n          aria-label={ariaLabel}\n          onPointerLeave={() => setActive(null)}\n        >\n          <circle cx={size / 2} cy={size / 2} r={radius} fill=\"none\" stroke=\"var(--muted)\" strokeWidth={thickness} />\n          {slices.map((s) =>\n            s.length > 0 ? (\n              <circle\n                key={s.i}\n                cx={size / 2}\n                cy={size / 2}\n                r={radius}\n                fill=\"none\"\n                stroke={s.color}\n                strokeWidth={thickness}\n                strokeDasharray={`${drawn ? Math.max(0, s.length - gap) : 0} ${circumference}`}\n                strokeDashoffset={-s.start}\n                // Length and start both ease, so slices slide to new sizes together when the data changes.\n                className=\"transition-[stroke-dasharray,stroke-dashoffset,opacity] duration-(--duration-considered) ease-enter motion-reduce:transition-none\"\n                style={{ opacity: active === null || active === s.i ? 1 : 0.35 }}\n                onPointerEnter={() => setActive(s.i)}\n              >\n                <title>{`${s.label}: ${format.format(s.value)} (${percent.format(total ? s.value / total : 0)})`}</title>\n              </circle>\n            ) : null\n          )}\n        </svg>\n        <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\" aria-live=\"polite\">\n          {/* The hovered slice's value counts from the total (or the slice before) to its own; it stays mounted, counting\n              back to the total, so the next hover starts from there. Read out once, in full. */}\n          <span aria-hidden className={cn(\"text-lg font-semibold tabular-nums\", !highlighted && \"hidden\")}>\n            <CountTo value={highlighted ? highlighted.value : total} locale={locale} formatOptions={formatOptions} />\n          </span>\n          {highlighted ? (\n            <>\n              <span className=\"sr-only\">{format.format(highlighted.value)}</span>\n              <span className=\"max-w-[70%] truncate text-xs text-muted-foreground\">{highlighted.label}</span>\n            </>\n          ) : (\n            children\n          )}\n        </div>\n      </div>\n      {/* Beside the ring when there's room for it, under it on a narrow screen. */}\n      {/* Rows touch (their padding is the spacing), so moving from one to the next never drops the hover in between. */}\n      <ul className={cn(\"grid min-w-0 grow basis-56 grid-cols-1 text-sm\", !showLegend && \"sr-only\")} onPointerLeave={() => setActive(null)}>\n        {slices.map((s) => (\n          <li\n            key={s.i}\n            className=\"flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-accent/60\"\n            onPointerEnter={() => setActive(s.i)}\n          >\n            <span aria-hidden className=\"size-2.5 shrink-0 rounded-sm\" style={{ background: s.color }} />\n            <span className=\"min-w-0 flex-1 truncate\">{s.label}</span>\n            {/* New data: values and shares count to theirs as the slices ease. */}\n            <span className=\"font-medium tabular-nums\">\n              <CountTo value={s.value} locale={locale} formatOptions={formatOptions} />\n            </span>\n            <span className=\"w-12 text-end text-xs text-muted-foreground tabular-nums\">\n              <CountTo value={total ? s.value / total : 0} locale={locale} formatOptions={percentOptions} />\n            </span>\n          </li>\n        ))}\n      </ul>\n    </div>\n  )\n}\n\nexport { DonutChart }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}