{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pie-chart",
  "type": "registry:component",
  "title": "Pie Chart",
  "description": "One total, divided between its parts.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/pie-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/pie-chart/index.tsx",
      "content": "'use client';\n\n/**\n * PieChart: one whole, divided between its parts.\n *\n * ```tsx\n * <PieChart data={spend} innerRadius={0.58}>\n *   <PieChart.Header title=\"This month\" value=\"$18,420\" legend />\n *   <PieChart.Slices />\n *   <PieChart.Center />\n * </PieChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's PieChart, so a chart written\n * for one reads the same in the other. What differs is what the web changes: a\n * colour is named by the theme's role, hovering a slice reads it, a click or a\n * tap holds the reading, and the arrow keys walk the slices.\n *\n * ## One reading\n *\n * Hover, the keyboard, a press and the legend all set one index, and everything\n * that responds to it (the lift, the dimming, the centre, the callback) reads\n * that index. The lift and the dimming are CSS over the drawn slices rather than\n * chart states, so a reading that comes from the legend or from `activeIndex`\n * looks the same as one that comes from the pointer, without rebuilding the chart.\n *\n * ## The unroll\n *\n * One hand sweeps clockwise from the start of the dial and each slice shows only\n * as far as it has got. A slice half drawn is a shorter slice, so this cannot be\n * a fade. It is a conic mask over the finished drawing, with one registered\n * angle animated, and it comes off when the sweep ends. Nothing repaints at rest.\n */\nimport { defineChart, type ChartInteractionController } from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { polar, radialArc } from '@tanstack/charts/polar';\nimport { Chart } from '@tanstack/charts/react/core';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { pieKeyWalk, pieSliceAt, pieSlices, pieSlicesBetween, pieTotal, type PieSliceAngles } from './pie-layout';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type PieChartStatus = 'loading' | 'ready';\n\n/** Written out in full, because the registry finds the tokens a component needs by reading its source. */\nconst COLORS = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\n/** Which of the theme's roles a slice is drawn in. */\nexport type PieChartColor = keyof typeof COLORS;\n\n/** One slice. Its share is worked out from the others, so there is no maximum. */\nexport interface PieDatum {\n  /** Name for the legend, the centre readout and screen readers. */\n  label: string;\n  /** How much of the whole this slice is. Negatives are treated as zero. */\n  value: number;\n  /** Which of the theme's roles to draw it in. Defaults to the chart palette, in order. */\n  color?: PieChartColor;\n}\n\nconst PALETTE = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: PALETTE,\n};\n\nconst pieChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative col-start-1 row-start-2 w-full justify-self-center',\n    chart:\n      'absolute! inset-0 cursor-pointer touch-pan-y rounded-full text-xs [&_svg]:rounded-full [&_svg]:outline-none [&_svg:focus-visible]:ring-2 [&_svg:focus-visible]:ring-ring',\n    skeleton:\n      'pointer-events-none col-start-1 row-start-2 justify-self-center bg-muted transition-opacity duration-500 motion-reduce:transition-none',\n    center:\n      'pointer-events-none relative col-start-1 row-start-2 flex flex-col items-center justify-center self-center justify-self-center text-center',\n    centerLabel: 'max-w-full truncate text-xs text-muted-foreground',\n    centerValue: 'max-w-full truncate font-semibold tabular-nums tracking-tight',\n    centerCaption: 'max-w-full truncate text-xs text-muted-foreground',\n    legend: 'col-start-1 row-start-3 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3 text-xs',\n    legendItem: 'flex items-center gap-1.5',\n    legendButton:\n      'flex max-w-full cursor-pointer items-center gap-1.5 rounded-sm outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    legendName: 'truncate text-muted-foreground',\n    legendValue: 'font-medium tabular-nums',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing:\n      'flex max-w-[55%] shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n  },\n});\n\n/** Room kept outside the pie so a selected slice has somewhere to lift into. */\nconst LIFT = 8;\nconst MORPH_DURATION = 420;\n\n/** Milliseconds for a slice to lift out and settle back. */\nconst SELECT_DURATION = 180;\n\n/** Opacity of a legend entry whose slice is not the one being read. */\nconst LEGEND_DIM = 0.4;\n\nconst SLICES_MARK = 'pie-slices';\n\nconst SWEEP_STYLES =\n  \"@property --panelui-pie-sweep{syntax:'<angle>';inherits:false;initial-value:0deg}\" +\n  '@keyframes panelui-pie-unroll{to{--panelui-pie-sweep:var(--panelui-pie-span)}}' +\n  '@media (prefers-reduced-motion:no-preference){[data-pie-unroll]{' +\n  'mask-image:conic-gradient(from var(--panelui-pie-start),#000 var(--panelui-pie-sweep),transparent 0);' +\n  'animation:panelui-pie-unroll var(--panelui-pie-duration) cubic-bezier(0.33,1,0.68,1) both}}';\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\nconst compactNumber = (value: number) => COMPACT.format(value);\n\n/** A square inside a circle, not across it: the corners of a wider box fall outside the hole. */\nconst holeRoom = (hole: number) => hole * Math.SQRT2;\n\n/** Below this width the hole fits the number alone. */\nconst FULL_CENTER_ROOM = 90;\n\n/** One slice as the arc mark wants it: angles in radians, clockwise from twelve. */\ninterface SliceRow {\n  index: number;\n  startAngle: number;\n  endAngle: number;\n}\n\ninterface SlicesConfig {\n  cornerRadius: number;\n  popOut: number;\n  dimOpacity: number;\n}\n\ninterface PieChartContextValue {\n  data: readonly PieDatum[];\n  total: number;\n  slices: readonly PieSliceAngles[];\n  colors: readonly string[];\n  /** Outer radius in pixels. Zero until the plot has been measured. */\n  radius: number;\n  /** Inner radius in pixels. Zero for a pie. */\n  hole: number;\n  box: number;\n  startAngle: number;\n  endAngle: number;\n  loading: boolean;\n  activeIndex: number;\n  setSlices: (config: SlicesConfig | undefined) => void;\n  /** A reading that goes away by itself, such as hovering a legend entry. */\n  read: (index: number) => void;\n  /** Holds a slice, or lets it go if it is the one held. */\n  togglePin: (index: number) => void;\n}\n\nconst PieChartContext = createContext<PieChartContextValue | null>(null);\n\nfunction useChart(component: string): PieChartContextValue {\n  const context = useContext(PieChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <PieChart>`);\n  }\n  return context;\n}\n\n/** The slice being read and its share, for something rendered inside the chart. */\nexport function usePieChart() {\n  const { data, slices, activeIndex } = useChart('usePieChart');\n  return {\n    activeIndex,\n    activeSlice: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,\n    /** The slice's share of the whole, 0 to 1. */\n    activeFraction: activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0,\n  };\n}\n\nexport interface PieChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** One entry per slice, in the order they are drawn clockwise. */\n  data: readonly PieDatum[];\n  /** Fixed diameter in pixels. Fills the container's width as a square when omitted. */\n  size?: number;\n  /**\n   * The hole, as a share of the radius. `0` is a pie. Anything above it is a\n   * donut, and `0.55` to `0.65` leaves room for a readout in the middle.\n   */\n  innerRadius?: number;\n  /** Where the first slice begins, in degrees clockwise from twelve o'clock. */\n  startAngle?: number;\n  /**\n   * Where the last one ends, on the same clock. A turn between the two closes\n   * the pie, and anything less leaves a gap and reads as a dial.\n   */\n  endAngle?: number;\n  /** Gap between one slice and the next, in degrees. */\n  padAngle?: number;\n  /**\n   * The smallest angle any non-zero slice is drawn at, in degrees. The angle it\n   * borrows comes off the others in proportion, so the turn still closes.\n   */\n  minAngle?: number;\n  /** Milliseconds for the pie to unroll. */\n  animationDuration?: number;\n  /** `loading` holds the slices back and unrolls them when it turns `ready`. */\n  status?: PieChartStatus;\n  /** The slice being read. Leave unset to let the chart track it. */\n  activeIndex?: number;\n  /** Fires with the slice being read, or `-1` when there is none. */\n  onActiveIndexChange?: (index: number) => void;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one slice. Defaults to its name and share. */\n  labelForDatum?: (datum: PieDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the unroll, for a \"replay\" control. */\nexport interface PieChartHandle {\n  replay: () => void;\n}\n\nconst PieChartRoot = forwardRef<PieChartHandle, PieChartProps & RenderProp<'div'>>(function PieChartRoot(\n  {\n    data,\n    size,\n    innerRadius = 0,\n    startAngle = 0,\n    endAngle = 360,\n    padAngle = 0,\n    minAngle = 0,\n    animationDuration = 620,\n    status = 'ready',\n    activeIndex: activeIndexProp,\n    onActiveIndexChange,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = pieChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const interactionRef = useRef<ChartInteractionController<SliceRow, number, number> | null>(null);\n  const scope = useId();\n  const [measured, setMeasured] = useState(0);\n  const [slicesConfig, setSlices] = useState<SlicesConfig | undefined>(undefined);\n  const [internalActive, setInternalActive] = useState(-1);\n  const [revision, setRevision] = useState(0);\n  const [unrolling, setUnrolling] = useState(true);\n\n  /*\n   * The chart mounts once its width is known. Mounted at a guess, the first\n   * measurement is a resize.\n   */\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const read = () => setMeasured(node.clientWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const loading = status === 'loading';\n  const box = size ?? measured;\n  const radius = Math.max(box / 2 - LIFT, 0);\n  const hole = radius * Math.max(0, Math.min(innerRadius, 0.95));\n\n  const values = useMemo(() => data.map((datum) => datum.value), [data]);\n  const total = useMemo(() => pieTotal(values), [values]);\n  const slices = useMemo(\n    () => pieSlices(values, { startAngle, endAngle, padAngle, minAngle }),\n    [values, startAngle, endAngle, padAngle, minAngle]\n  );\n  // What is on screen, which trails `slices` through a data change. The angles\n  // are tweened here because a morph between two drawn arcs interpolates the\n  // large-arc flag as well: a flag of 0.5 is not a valid path, so any slice\n  // crossing half a turn would vanish until the morph ended.\n  const [drawn, setDrawn] = useState<readonly PieSliceAngles[]>(slices);\n  const drawnRef = useRef<readonly PieSliceAngles[]>(slices);\n  useEffect(() => {\n    const from = drawnRef.current;\n    if (from === slices) return;\n    // An inline `data` array is new on every render without anything having moved.\n    if (from.length === slices.length && from.every((slice, i) => slice.from === slices[i]!.from && slice.to === slices[i]!.to)) {\n      drawnRef.current = slices;\n      return;\n    }\n    const show = (next: readonly PieSliceAngles[]) => {\n      drawnRef.current = next;\n      setDrawn(next);\n    };\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n      show(slices);\n      return;\n    }\n    const began = performance.now();\n    let frame = 0;\n    const step = (now: number) => {\n      // A frame's timestamp can be earlier than the clock read that started the tween.\n      const elapsed = Math.max(0, Math.min(1, (now - began) / MORPH_DURATION));\n      show(elapsed < 1 ? pieSlicesBetween(from, slices, 1 - (1 - elapsed) ** 3) : slices);\n      if (elapsed < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [slices]);\n\n  const colors = useMemo(\n    () => data.map((datum, index) => (datum.color ? COLORS[datum.color] : PALETTE[index % PALETTE.length]!)),\n    [data]\n  );\n\n  const wanted = activeIndexProp ?? internalActive;\n  // An index the data no longer has reads nothing.\n  const activeIndex = wanted < data.length ? wanted : -1;\n\n  // A reading is reported when it changes, not per pointer event.\n  const reported = useRef(-1);\n  // Latest in a ref, so an inline callback does not count as a new reading.\n  const onChange = useRef(onActiveIndexChange);\n  useEffect(() => {\n    onChange.current = onActiveIndexChange;\n  });\n  const report = useCallback((index: number) => {\n    if (reported.current === index) return;\n    reported.current = index;\n    setInternalActive(index);\n    onChange.current?.(index);\n  }, []);\n\n  // Held in a ref because the pointer's reports must not move a slice that was pressed.\n  const pinned = useRef(-1);\n  // A controlled value is the reading now, whatever was last reported or pressed.\n  useEffect(() => {\n    if (activeIndexProp === undefined) return;\n    reported.current = activeIndexProp;\n    if (pinned.current !== activeIndexProp) pinned.current = -1;\n  }, [activeIndexProp]);\n  const read = useCallback(\n    (index: number) => {\n      if (pinned.current < 0) report(index);\n    },\n    [report]\n  );\n  const togglePin = useCallback(\n    (index: number) => {\n      pinned.current = pinned.current === index ? -1 : index;\n      report(pinned.current);\n    },\n    [report]\n  );\n  const release = useCallback(() => {\n    pinned.current = -1;\n    report(-1);\n  }, [report]);\n  // A held slice that was removed would otherwise block every other reading.\n  useEffect(() => {\n    if (pinned.current >= data.length || reported.current >= data.length) release();\n  }, [data.length, release]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      replay: () => {\n        setUnrolling(true);\n        setRevision((current) => current + 1);\n      },\n    }),\n    []\n  );\n\n  // Back to loading arms the unroll again, so a refetch does not land fully drawn.\n  useEffect(() => {\n    if (loading) {\n      setUnrolling(true);\n      release();\n    }\n  }, [loading, release]);\n\n  // A remounted chart says nothing about the focus it lost.\n  useEffect(() => {\n    if (revision > 0 && pinned.current < 0) report(-1);\n  }, [revision, report]);\n\n  // The drawing only reads the slices and the geometry. The reading stays out of\n  // here on purpose, so hovering never rebuilds it.\n  const definition = useMemo(() => {\n    const rows: SliceRow[] = drawn.flatMap((slice, index) =>\n      slice.to > slice.from\n        ? [{ index, startAngle: slice.from * Math.PI * 2, endAngle: slice.to * Math.PI * 2 }]\n        : []\n    );\n    return defineChart({\n      theme: THEME,\n      marks: [\n        polar({\n          scales: { angle: null, radius: null },\n          marks: [\n            radialArc(rows, {\n              id: SLICES_MARK,\n              key: 'index',\n              innerRadius: hole,\n              outerRadius: radius,\n              cornerRadius: slicesConfig?.cornerRadius ?? 0,\n              fill: (row) => colors[row.index] ?? PALETTE[0]!,\n            }),\n          ],\n        }),\n      ],\n      scales: { x: null, y: null },\n      margin: 0,\n      guides: false,\n      focusRing: false,\n      keyboard: true,\n      pointer: true,\n      // By the slice under the pointer, and round the dial in data order for the\n      // arrow keys. The presets go by position on the screen, so Home would land\n      // on whichever slice is furthest left.\n      focus: {\n        resolve: (points, { x, y }) => {\n          const hit = pieSliceAt(drawn, x - box / 2, y - box / 2, hole, radius);\n          return points.filter((point) => point.datum.index === hit);\n        },\n        group: (_points, { point }) => [point],\n        navigation: (points) => points,\n      },\n      motion: false,\n    });\n  }, [drawn, colors, box, radius, hole, slicesConfig?.cornerRadius]);\n\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n\n  const summary = label ?? `Pie chart, ${data.length} ${data.length === 1 ? 'slice' : 'slices'}`;\n  const spoken = (datum: PieDatum, index: number) =>\n    labelForDatum?.(datum, index) ?? `${datum.label}, ${Math.round((slices[index]?.fraction ?? 0) * 100)} percent`;\n\n  const context = useMemo<PieChartContextValue>(\n    () => ({\n      data,\n      total,\n      slices,\n      colors,\n      radius,\n      hole,\n      box,\n      startAngle,\n      endAngle,\n      loading,\n      activeIndex,\n      setSlices,\n      read,\n      togglePin,\n    }),\n    [data, total, slices, colors, radius, hole, box, startAngle, endAngle, loading, activeIndex, read, togglePin]\n  );\n\n  // Slices are found by the key the mark gives them, which holds the row's index and nothing the caller wrote.\n  const active = activeIndex >= 0 ? slices[activeIndex] : undefined;\n  const popOut = slicesConfig?.popOut ?? 6;\n  const lift = active ? ((active.mid - 0.25) * Math.PI * 2) : 0;\n  const reading = slicesConfig\n    ? `[data-pie-scope=\"${scope}\"] [data-ts-key^=\"${SLICES_MARK}:\"]{transition:transform ${SELECT_DURATION}ms ease-out,fill-opacity ${SELECT_DURATION}ms ease-out}` +\n      (active\n        ? `[data-pie-scope=\"${scope}\"] [data-ts-key^=\"${SLICES_MARK}:\"]:not([data-ts-key$=\":${activeIndex}\"]){fill-opacity:${slicesConfig.dimOpacity}}` +\n          `[data-pie-scope=\"${scope}\"] [data-ts-key^=\"${SLICES_MARK}:\"][data-ts-key$=\":${activeIndex}\"]{transform:translate(${(Math.cos(lift) * popOut).toFixed(2)}px,${(Math.sin(lift) * popOut).toFixed(2)}px)}`\n        : '')\n    : '';\n\n  const span = Math.min(Math.max(endAngle - startAngle, 0), 360);\n  const sweepVars = {\n    '--panelui-pie-start': `${startAngle}deg`,\n    '--panelui-pie-span': `${span}deg`,\n    '--panelui-pie-duration': `${animationDuration}ms`,\n  } as CSSProperties;\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'pie-chart',\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((datum, index) => (\n                  <li key={index}>{spoken(datum, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div aria-live=\"polite\" className=\"sr-only\">\n              {activeIndex >= 0 && data[activeIndex] ? spoken(data[activeIndex], activeIndex) : ''}\n            </div>\n            <div\n              ref={plotRef}\n              data-slot=\"pie-chart-plot\"\n              data-pie-scope={scope}\n              className={slotClasses.plot()}\n              style={size ? { width: size, height: size } : { aspectRatio: 1 }}\n              onClick={(event) => {\n                const target = interactionRef.current?.resolvePointer(event.clientX, event.clientY);\n                const point = target?.point;\n                if (point) togglePin(point.datum.index);\n                else if (pinned.current >= 0) release();\n              }}\n              // Before the chart moves its focus, so the move is read rather than ignored.\n              // From the pointer rather than the chart's focus: a finger reads by pressing, its drags are the page\n              // scrolling, and the chart stays silent about a slice it still counts as focused after the keys moved on.\n              onPointerMove={(event) => {\n                if (event.pointerType === 'touch') return;\n                const point = interactionRef.current?.resolvePointer(event.clientX, event.clientY)?.point;\n                read(point ? point.datum.index : -1);\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType !== 'touch') read(-1);\n              }}\n              onBlur={() => read(-1)}\n              // Walked from the reading, and kept from the chart, which would walk from its own focus.\n              onKeyDownCapture={(event) => {\n                // The browser's own shortcuts, such as going back.\n                if (event.altKey || event.ctrlKey || event.metaKey) return;\n                const shown = drawn.flatMap((slice, index) => (slice.to > slice.from ? [index] : []));\n                const next = pieKeyWalk(activeIndex, shown, event.key);\n                if (next === undefined) return;\n                event.preventDefault();\n                event.stopPropagation();\n                pinned.current = -1;\n                report(next);\n              }}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') {\n                  interactionRef.current?.setControlledFocus(null);\n                  release();\n                }\n                if ((event.key === 'Enter' || event.key === ' ') && activeIndex >= 0) {\n                  event.preventDefault();\n                  togglePin(activeIndex);\n                }\n              }}\n            >\n              <style>{SWEEP_STYLES + reading}</style>\n              {box > 0 && !loading && slicesConfig ? (\n                <div\n                  key={revision}\n                  data-pie-unroll={unrolling ? '' : undefined}\n                  className=\"absolute inset-0\"\n                  style={sweepVars}\n                  onAnimationEnd={() => setUnrolling(false)}\n                >\n                  <Chart\n                    definition={definition}\n                    renderer={renderer}\n                    ariaLabel={summary}\n                    aspectRatio={1}\n                    initialWidth={box}\n                    className={slotClasses.chart()}\n                    onRender={({ interaction }) => {\n                      interactionRef.current = interaction;\n                    }}\n                  />\n                </div>\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <PieChartContext.Provider value={context}>{element}</PieChartContext.Provider>;\n});\nPieChartRoot.displayName = 'PieChart';\n\nexport interface PieChartSlicesProps {\n  /** Rounds the four turns of each slice, in pixels. */\n  cornerRadius?: number;\n  /** How far a slice lifts out of the pie while it is read, in pixels. */\n  popOut?: number;\n  /** Opacity of the other slices while one is read. */\n  dimOpacity?: number;\n}\n\n/**\n * Every slice, in the order the data lists them. One part rather than one per\n * datum, because slices share a radius, a hole and a dial by definition.\n */\nfunction PieChartSlices({ cornerRadius = 0, popOut = 6, dimOpacity = 0.35 }: PieChartSlicesProps) {\n  const { setSlices } = useChart('PieChart.Slices');\n  useEffect(() => {\n    setSlices({ cornerRadius, popOut, dimOpacity });\n    return () => setSlices(undefined);\n  }, [cornerRadius, popOut, dimOpacity, setSlices]);\n  return null;\n}\nPieChartSlices.displayName = 'PieChart.Slices';\n\nexport interface PieChartSkeletonProps extends ComponentProps<'div'> {\n  /** Which of the theme's roles to draw the band in. Defaults to the muted surface. */\n  color?: PieChartColor;\n}\n\n/**\n * The loading state: the dial as one plain band, with nothing divided up yet,\n * because placeholder slices would be a made-up split. It stays mounted through\n * the unroll and fades under the slices sweeping over it.\n */\nfunction PieChartSkeleton({ color, className, style, render, ...props }: PieChartSkeletonProps & RenderProp<'div'>) {\n  const { radius, hole, box, startAngle, endAngle, loading } = useChart('PieChart.Skeleton');\n  const slots = pieChartVariants();\n  const span = Math.min(Math.max(endAngle - startAngle, 0), 360);\n  const band = `radial-gradient(closest-side,transparent ${(hole / Math.max(radius, 1)) * 100}%,#000 calc(${(hole / Math.max(radius, 1)) * 100}% + 0.5px))`;\n  const dial = `conic-gradient(from ${startAngle}deg,#000 ${span}deg,transparent 0)`;\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'pie-chart-skeleton',\n        'aria-hidden': true,\n        className: slots.skeleton({ className }),\n        style: {\n          width: radius * 2,\n          height: radius * 2,\n          marginTop: LIFT,\n          opacity: loading ? 1 : 0,\n          ...(color ? { background: COLORS[color] } : {}),\n          borderRadius: '50%',\n          maskImage: `${band},${dial}`,\n          maskComposite: 'intersect',\n          ...style,\n        },\n      },\n      props\n    ),\n  });\n\n  return box > 0 ? element : null;\n}\nPieChartSkeleton.displayName = 'PieChart.Skeleton';\n\nexport interface PieChartCenterProps {\n  /** Heading shown when no slice is read. */\n  defaultLabel?: string;\n  /** Format the number under the label. Defaults to a compact number. */\n  formatValue?: (value: number, slice: PieDatum | null) => string;\n  /** Draw the middle yourself. Given the slice being read, or `null` when there is none. */\n  children?: (slice: PieDatum | null) => ReactNode;\n  /** Added to the readout. */\n  className?: string;\n}\n\n/**\n * The hole's readout: the total, and the slice's own figures while one is read.\n * It keeps to the square that fits inside the hole, and draws nothing on a pie\n * with no hole.\n */\nfunction PieChartCenter({ defaultLabel = 'Total', formatValue, children, className }: PieChartCenterProps) {\n  const { data, slices, total, hole, activeIndex, loading } = useChart('PieChart.Center');\n  const slots = pieChartVariants();\n  const room = holeRoom(hole);\n  if (room <= 0 || loading) return null;\n\n  const slice = activeIndex >= 0 ? (data[activeIndex] ?? null) : null;\n  const share = activeIndex >= 0 ? (slices[activeIndex]?.fraction ?? 0) : 0;\n  const format = formatValue ?? ((value: number) => compactNumber(value));\n  const full = room >= FULL_CENTER_ROOM;\n\n  return (\n    <div data-slot=\"pie-chart-center\" className={slots.center({ className })} style={{ maxWidth: room }}>\n      {children ? (\n        children(slice)\n      ) : (\n        <>\n          <div className={slots.centerLabel()}>{slice ? slice.label : defaultLabel}</div>\n          <div className={slots.centerValue({ className: full ? 'text-xl' : 'text-lg' })}>\n            {format(slice ? slice.value : total, slice)}\n          </div>\n          {full && slice ? (\n            <div className={slots.centerCaption()}>{`${Math.round(share * 100)}% of ${format(total, null)}`}</div>\n          ) : null}\n        </>\n      )}\n    </div>\n  );\n}\nPieChartCenter.displayName = 'PieChart.Center';\n\nexport interface PieChartLegendProps extends ComponentProps<'div'> {\n  /** Show each slice's share of the whole beside its name. */\n  showValue?: boolean;\n}\n\n/**\n * A swatch, a name and a share per slice, under the dial. Each entry is a button\n * for the same reading as its slice, and the easier target for a small one.\n */\nfunction PieChartLegend({ showValue = true, className, render, ...props }: PieChartLegendProps & RenderProp<'div'>) {\n  const { data, slices, colors, activeIndex, read, togglePin } = useChart('PieChart.Legend');\n  const slots = pieChartVariants();\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'pie-chart-legend',\n        className: slots.legend({ className }),\n        children: data.map((slice, index) => {\n          const percent = Math.round((slices[index]?.fraction ?? 0) * 100);\n          return (\n            <button\n              key={index}\n              type=\"button\"\n              aria-pressed={activeIndex === index}\n              aria-label={`${slice.label}, ${percent} percent`}\n              className={slots.legendButton()}\n              style={{ opacity: activeIndex >= 0 && activeIndex !== index ? LEGEND_DIM : 1 }}\n              onPointerEnter={(event) => {\n                if (event.pointerType === 'mouse') read(index);\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType === 'mouse') read(-1);\n              }}\n              onClick={() => togglePin(index)}\n            >\n              <span className={slots.legendSwatch()} style={{ background: colors[index] }} />\n              <span className={slots.legendName()}>{slice.label}</span>\n              {showValue ? <span className={slots.legendValue()}>{percent}%</span> : null}\n            </button>\n          );\n        }),\n      },\n      props\n    ),\n  });\n\n  return data.length ? element : null;\n}\nPieChartLegend.displayName = 'PieChart.Legend';\n\nexport interface PieChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a caveat. */\n  caption?: ReactNode;\n  /** Prettier names for the slices, keyed by their `label`. */\n  labels?: Record<string, string>;\n  /**\n   * Draw a swatch and a name per slice along the trailing edge, for two or three\n   * short names. Past that use `PieChart.Legend`, which has the whole width.\n   */\n  legend?: boolean;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the pie: what the chart is of, what it reads and what the\n * colours mean. The value is not derived here, because a total is a count or a\n * currency depending on what was counted, and only the caller knows which.\n */\nfunction PieChartHeader({\n  title,\n  value,\n  caption,\n  labels,\n  legend = false,\n  className,\n  children,\n  render,\n  ...props\n}: PieChartHeaderProps & RenderProp<'div'>) {\n  const { data, colors } = useChart('PieChart.Header');\n  const slots = pieChartVariants();\n  const trailing =\n    children ??\n    (legend && data.length\n      ? data.map((slice, index) => (\n          <span key={index} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: colors[index] }} />\n            {labels?.[slice.label] ?? slice.label}\n          </span>\n        ))\n      : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'pie-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value || value === 0 ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nPieChartHeader.displayName = 'PieChart.Header';\n\nexport const PieChart = Object.assign(PieChartRoot, {\n  Header: PieChartHeader,\n  Slices: PieChartSlices,\n  Center: PieChartCenter,\n  Legend: PieChartLegend,\n  Skeleton: PieChartSkeleton,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { PieChartHeader, PieChartSlices, PieChartCenter, PieChartLegend, PieChartSkeleton };\n"
    },
    {
      "path": "components/panelui/pie-chart/pie-layout.ts",
      "type": "registry:component",
      "target": "@components/panelui/pie-chart/pie-layout.ts",
      "content": "/** A slice's place on the dial, in turns clockwise from twelve o'clock. */\nexport interface PieSliceAngles {\n  from: number;\n  to: number;\n  /** Halfway along it, which is the direction it lifts out in. */\n  mid: number;\n  /** Its share of the total, 0 to 1. */\n  fraction: number;\n}\n\nexport interface PieLayoutOptions {\n  /** Where the first slice begins, in degrees clockwise from twelve o'clock. */\n  startAngle: number;\n  /** Where the last one ends, on the same clock. */\n  endAngle: number;\n  /** Gap between one slice and the next, in degrees. */\n  padAngle: number;\n  /** The smallest angle any non-zero slice is drawn at, in degrees. */\n  minAngle: number;\n}\n\n/** Everything the values add up to. Negative values count as zero. */\nexport function pieTotal(values: readonly number[]): number {\n  return values.reduce((sum, value) => sum + Math.max(0, value), 0);\n}\n\n/**\n * Shares the dial out between the values, one entry per value, in order.\n *\n * The gaps and the floors come out of the turn first and what is left is shared\n * by value. Adding them on instead would push the last slice past the end of the\n * dial, which on a closed pie means over the first one. A span past one turn is\n * clamped for the same reason.\n */\nexport function pieSlices(\n  values: readonly number[],\n  { startAngle, endAngle, padAngle, minAngle }: PieLayoutOptions\n): PieSliceAngles[] {\n  const span = Math.min(Math.max(endAngle - startAngle, 0), 360) / 360;\n  const total = pieTotal(values);\n  const count = values.length;\n  if (!count || total <= 0 || span <= 0) return [];\n\n  const pad = Math.min(padAngle / 360, span / (count * 2));\n  const gaps = count > 1 || span < 1 ? pad * count : 0;\n  const floor = Math.min(minAngle / 360, (span - gaps) / (count * 2));\n  const drawn = values.filter((value) => value > 0).length;\n  const free = Math.max(span - gaps - floor * drawn, 0);\n\n  let cursor = startAngle / 360 + pad / 2;\n  return values.map((raw) => {\n    const value = Math.max(0, raw);\n    const fraction = value / total;\n    const sweep = value > 0 ? floor + free * fraction : 0;\n    const slice = { from: cursor, to: cursor + sweep, mid: cursor + sweep / 2, fraction };\n    cursor += sweep + pad;\n    return slice;\n  });\n}\n\n/**\n * Which slice a point falls in, or `-1`. `x` and `y` are measured from the centre\n * of the dial, with `y` running down the screen.\n */\nexport function pieSliceAt(\n  slices: readonly PieSliceAngles[],\n  x: number,\n  y: number,\n  inner: number,\n  outer: number\n): number {\n  const distance = Math.hypot(x, y);\n  if (distance < inner || distance > outer) return -1;\n  const turn = Math.atan2(x, -y) / (Math.PI * 2);\n  return slices.findIndex(({ from, to }) => {\n    const past = ((turn - from) % 1 + 1) % 1;\n    return to > from && past < to - from;\n  });\n}\n\n/**\n * The dial part way through a data change, `progress` running 0 to 1. A slice\n * with no earlier place grows from its own start, and the result always has the\n * shape of `to`.\n */\nexport function pieSlicesBetween(\n  from: readonly PieSliceAngles[],\n  to: readonly PieSliceAngles[],\n  progress: number\n): PieSliceAngles[] {\n  const mix = (a: number, b: number) => a + (b - a) * progress;\n  return to.map((slice, index) => {\n    const start = from[index] ?? { ...slice, to: slice.from, fraction: 0 };\n    const angles = { from: mix(start.from, slice.from), to: mix(start.to, slice.to) };\n    return { ...angles, mid: (angles.from + angles.to) / 2, fraction: mix(start.fraction, slice.fraction) };\n  });\n}\n\n/**\n * The slice a navigation key moves the reading to, or undefined for any other key.\n * Takes the slices that are drawn, because a slice with no share has nothing to light. Stops at the ends.\n */\nexport function pieKeyWalk(current: number, drawn: readonly number[], key: string): number | undefined {\n  const last = drawn.length - 1;\n  if (last < 0) return undefined;\n  switch (key) {\n    // By position rather than by lookup, because a slice with no share can be read from the legend.\n    case 'ArrowRight':\n    case 'ArrowDown':\n      return drawn.find((index) => index > current) ?? drawn[last];\n    case 'ArrowLeft':\n    case 'ArrowUp':\n      return [...drawn].reverse().find((index) => index < current) ?? drawn[0];\n    case 'Home':\n      return drawn[0];\n    case 'End':\n      return drawn[last];\n    default:\n      return undefined;\n  }\n}\n"
    }
  ]
}
