{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pie-chart","title":"Pie Chart","description":"Show how a total divides into shares as a donut or pie chart. Search terms: ring, distribution.","dependencies":[],"registryDependencies":["@siteplane/base","@siteplane/motion","@siteplane/chart"],"files":[{"path":"packages/ui/src/components/pie-chart/pie-chart.tsx","content":"\"use client\";\nimport * as React from \"react\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { ChartEmptyState } from \"../chart/chart-cartesian\";\nimport {\n  CHART_DEFAULT_LOCALE,\n  chartFigureFormatter,\n  chartLabels,\n  chartNumberFormatter,\n} from \"../chart/chart-format\";\nimport { useChartInteraction } from \"../chart/chart-interaction\";\nimport { ChartLegend, useChartSeriesFocus } from \"../chart/chart-legend\";\nimport { chartEmptyColor, chartRingColors } from \"../chart/chart-palette\";\nimport { ChartTooltip, chartTooltipText } from \"../chart/chart-tooltip\";\nimport type { ChartLabels, ChartPalette } from \"../chart/chart-types\";\nimport {\n  MotionNumber,\n  followed,\n  useFollowPoint,\n  useTweenedRows,\n} from \"../motion\";\nimport {\n  DONUT_INNER_RADIUS,\n  PIE_BOX,\n  pieCenterFit,\n  popDistance,\n  popOffset,\n  ringOf,\n  ringPath,\n  shareAnchor,\n  shareAtPoint,\n  wedgePath,\n  type PieArc,\n  type PieCenterStep,\n} from \"./pie-geometry\";\n// A gap in the ring smaller than this, in percent of the circle, is the\n// rounding of shares that fill it: no track is drawn for it.\nconst trackMinimum = 0.001;\n\nexport type PieChartDatum = {\n  id: string;\n  label: string;\n  value: number;\n  color?: string;\n  /**\n   * An unfilled share, such as unused capacity. It takes the neutral empty\n   * colour instead of a series colour.\n   */\n  empty?: boolean;\n};\nexport type PieChartProps = {\n  data: readonly PieChartDatum[];\n  label: string;\n  /**\n   * The id of an element that describes the chart, read after what the chart\n   * gives assistive technology itself.\n   */\n  \"aria-describedby\"?: string;\n  /**\n   * `brand` (the default) shades the brand colour from strong to light, spread\n   * over the number of shares (up to eight), for parts of one whole.\n   * `categorical` gives each share its own chart colour, `--chart-1` to\n   * `--chart-8` in order. Both start over from the ninth share. A share that\n   * names its `color` keeps it, and an `empty` share is always neutral. The\n   * last share never takes the color of the first, where the ring closes.\n   */\n  palette?: ChartPalette;\n  size?: number;\n  className?: string;\n  /** Writes a value in the tooltip and in what screen readers read for each share. A number in the format of `locale` by default. */\n  formatValue?: (value: number) => string;\n  /** The locale of the numbers. */\n  locale?: string;\n  emptyLabel?: string;\n  /**\n   * Replaces words that the chart gives screen readers, merged with the\n   * English defaults: its role description and the keyboard hint.\n   */\n  labels?: Partial<ChartLabels> | undefined;\n  /**\n   * `donut` draws the shares as a ring. `pie` fills the whole disc with a wedge\n   * per share, so it has no hole.\n   */\n  variant?: \"donut\" | \"pie\";\n  /**\n   * Content of your own in the hole of the donut, in place of `centerValue`\n   * and `centerLabel`. It is laid out in the square around the hole and cut\n   * off at the hole's edge, so it never covers the ring: keep it short, or pass\n   * the figure as `centerValue`, which the chart fits. A pie has no hole and\n   * shows none, and a chart with nothing to draw shows `emptyLabel` in its\n   * place. It lies over the ring without taking the pointer and is not part of\n   * the chart's accessible name.\n   */\n  center?: React.ReactNode;\n  /**\n   * A figure in the hole of the donut, such as the total of the shares, which\n   * the chart fits into the hole: in the largest text step it fits in, from 30\n   * px down, and in compact notation, such as 1.2M, when it does not fit in\n   * full at 20 px. Screen readers read it in full. It rolls to a new value.\n   */\n  centerValue?: number;\n  /** A caption under `centerValue`, such as Tickets: one line of 12 px muted text that ends in an ellipsis when the hole is too narrow for it. */\n  centerLabel?: string;\n  /** How `centerValue` is written, as Intl.NumberFormat options in `locale`, such as a currency. The chart adds compact notation when the figure needs it. */\n  centerFormat?: Intl.NumberFormatOptions;\n  /**\n   * Lists the shares beside the ring, each with its colour. Without it screen\n   * readers still read each share and its value, as the description of the\n   * chart.\n   */\n  legend?: boolean;\n  /**\n   * Lets the highlighted share step out of the ring, 10 px along its middle\n   * angle at any size, and lets a mouse over an entry of the legend highlight\n   * its share as well. Off by default: no share ever moves, and the legend does\n   * not react to the mouse. No share fades either way. A share that steps out\n   * reaches up to 10 px beyond the box of the chart.\n   */\n  popOut?: boolean;\n};\n/**\n * One share of the ring, or one wedge of the pie. While it is the highlighted\n * share it steps out along its middle angle, and it goes back in when it is not;\n * a chart that does not pop out gives it no distance to step, so it stays. It\n * moves with the follow motion, which owns the glide, its token and reduced\n * motion (rulebook M5): the translate is a pair of motion values bound to the\n * path, so only the path moves, and the chart does not render again. No share\n * fades: the shares are shades of one hue, and a faded share took the colour of\n * another one.\n */\nfunction PieSegment({\n  arc,\n  color,\n  container,\n  highlighted,\n  pie,\n  popUnits,\n  ring,\n}: {\n  arc: PieArc;\n  color: string;\n  /** The element whose motion tokens set the glide: the chart. */\n  container: React.RefObject<Element | null>;\n  /** This share is the highlighted one. */\n  highlighted: boolean;\n  /** A wedge of a pie, not a share of the ring. */\n  pie: boolean;\n  /** How far the highlighted share steps out, in the units of the box. */\n  popUnits: number;\n  ring: { radius: number; width: number };\n}): React.ReactElement | null {\n  const out = useFollowPoint(\n    highlighted ? popOffset(arc.start, arc.value, popUnits) : { x: 0, y: 0 },\n    { container },\n  );\n  if (pie)\n    // A wedge is a path of its own, so that neighbours meet along exact edges;\n    // dashes of a stroke as wide as the disc would meet a little off.\n    return arc.value > 0 ? (\n      <followed.path\n        d={wedgePath(arc.start, arc.value)}\n        fill={color}\n        data-active={highlighted ? \"\" : undefined}\n        style={{ x: out.x, y: out.y }}\n      />\n    ) : null;\n  // The share is a dash along the open path of the ring.\n  return (\n    <followed.path\n      d={ringPath(ring.radius)}\n      fill=\"none\"\n      stroke={color}\n      strokeWidth={ring.width}\n      pathLength=\"100\"\n      strokeDasharray={`${arc.value} ${100 - arc.value}`}\n      strokeDashoffset={-arc.start}\n      data-active={highlighted ? \"\" : undefined}\n      style={{ x: out.x, y: out.y }}\n    />\n  );\n}\n\n// The text classes of the steps of a figure in the hole, written whole for\n// Tailwind: the kit's 30 px figure (the Metric Card's), then the smaller steps.\nconst figureStepClassName: Record<PieCenterStep, string> = {\n  12: \"text-xs\",\n  14: \"text-sm\",\n  16: \"text-base\",\n  20: \"text-xl\",\n  24: \"text-2xl\",\n  30: \"text-3xl\",\n};\n\n/**\n * A figure in the hole of the donut, with an optional caption under it: in the\n * largest step it fits in, in full or in compact notation (`pieCenterFit`),\n * which the chart picks from its size and the text alone, so that the server\n * and the client agree. A compact figure is read in full. The caption stays on\n * one line and ends in an ellipsis where the hole narrows (rulebook K5).\n */\nfunction PieCenterFigure({\n  caption,\n  format,\n  locale,\n  size,\n  value,\n}: {\n  caption: string | undefined;\n  format: Intl.NumberFormatOptions | undefined;\n  locale: string;\n  size: number;\n  value: number;\n}): React.ReactElement {\n  const full = chartFigureFormatter(locale, format)(value);\n  const short = chartFigureFormatter(locale, format, true)(value);\n  const fit = pieCenterFit({ caption, chartSize: size, compact: short, full });\n  const text = fit.compact ? short : full;\n  return (\n    <>\n      <span\n        className={cn(\n          slotClass(\"pie-chart-center-value\"),\n          \"max-w-full font-(--sp-rw-font-weight-strong) tabular-nums\",\n          figureStepClassName[fit.size],\n          // After the step, which sets a line height of its own.\n          \"leading-none\",\n        )}\n        data-compact={fit.compact ? \"\" : undefined}\n        data-size={fit.size}\n        data-slot=\"pie-chart-center-value\"\n      >\n        {text === full ? (\n          <MotionNumber value={text} />\n        ) : (\n          <>\n            <span className=\"sr-only\">{full}</span>\n            <MotionNumber aria-hidden=\"true\" value={text} />\n          </>\n        )}\n      </span>\n      {caption ? (\n        <span\n          className={cn(\n            slotClass(\"pie-chart-center-label\"),\n            \"mt-1 truncate text-xs leading-none text-muted-foreground\",\n          )}\n          data-slot=\"pie-chart-center-label\"\n          style={{ maxWidth: `${fit.captionWidth}%` }}\n          title={caption}\n        >\n          {caption}\n        </span>\n      ) : null}\n    </>\n  );\n}\n\nexport function PieChart({\n  data,\n  label,\n  \"aria-describedby\": describedBy,\n  palette = \"brand\",\n  size = 200,\n  className,\n  formatValue: givenFormat,\n  locale = CHART_DEFAULT_LOCALE,\n  emptyLabel = \"No data\",\n  labels,\n  variant = \"donut\",\n  center,\n  centerValue,\n  centerLabel,\n  centerFormat,\n  legend = true,\n  popOut = false,\n}: PieChartProps): React.ReactElement {\n  const chart = React.useRef<SVGSVGElement>(null);\n  const words = chartLabels(labels);\n  // Numbers as the other charts write them: in the format of the locale.\n  const formatValue = givenFormat ?? chartNumberFormatter(locale);\n  const pie = variant === \"pie\";\n  // A pie is a ring with no hole: it reaches in to the centre.\n  const innerRadius = pie ? 0 : DONUT_INNER_RADIUS;\n  const ring = ringOf(innerRadius);\n  // The palette is spread over the shares that are filled: an empty share is\n  // neutral and takes no place in it. The last share and the first meet where\n  // the ring closes, so they never share a colour.\n  const colors = chartRingColors(\n    palette,\n    data.filter((item) => !item.empty).length,\n  );\n  let series = 0;\n  const rows = data.map((item) => {\n    const index = item.empty ? -1 : series++;\n    return {\n      ...item,\n      value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0,\n      color: item.color ?? (item.empty ? chartEmptyColor : colors[index]!),\n    };\n  });\n  const total = rows.reduce((sum, row) => sum + row.value, 0);\n  // The ring as it is drawn now, in percent of the circle: when the data\n  // changes, each share glides to its new size and place, a new share grows\n  // from nothing and a share that is gone shrinks away. At rest this is the\n  // data itself.\n  const arcs = useTweenedRows(\n    rows,\n    (row) => (total > 0 ? (row.value / total) * 100 : 0),\n    { container: chart },\n  );\n  let offset = 0;\n  const drawn = arcs.map((arc) => {\n    const start = offset;\n    offset += arc.value;\n    return { ...arc, start };\n  });\n  // The part of the circle that no share fills, which the track shows: all of\n  // it in a chart with nothing in it, the gap while shares grow from nothing or\n  // shrink to it, and none at rest, where the shares fill the circle.\n  const filled = Math.min(100, Math.max(0, offset));\n  const unfilled = 100 - filled;\n  // The share under a point, from its angle within the ring or the disc. One\n  // lookup for the whole chart keeps the active share steady while the pointer\n  // crosses from one share to the next.\n  const segmentAt = (clientX: number, clientY: number): string | null => {\n    const box = chart.current?.getBoundingClientRect();\n    if (!box || !box.width || total <= 0) return null;\n    const scale = PIE_BOX / box.width;\n    return shareAtPoint(\n      drawn,\n      (clientX - box.x - box.width / 2) * scale,\n      (clientY - box.y - box.height / 2) * scale,\n      innerRadius,\n    );\n  };\n  // The chart core keeps the active share, the pointer, the tap that pins, the\n  // arrow keys and the tooltip; the ring only says which share is under a point.\n  // A ring with nothing in it has no share to show: no tooltip, no focus stop.\n  const interaction = useChartInteraction({\n    keys: total > 0 ? rows.map((row) => row.id) : [],\n    itemAt: segmentAt,\n    wrap: true,\n  });\n  const selected =\n    interaction.activeKey === null\n      ? undefined\n      : rows.find((row) => row.id === interaction.activeKey);\n  // The share whose entry the mouse is over in the legend. The chart owns it,\n  // as it owns the active share: one state, handed to the legend and the ring.\n  // The legend sets it only in a chart that pops out, where it has something to\n  // show.\n  const focus = useChartSeriesFocus(rows.map((row) => row.id));\n  // One share is highlighted, which steps out with `popOut`: the share under\n  // the pointer, the one a tap or the keyboard has made active, or, in a chart\n  // that pops out, the one whose entry the mouse is over in the legend. The\n  // mouse on the legend wins, and the tooltip stays with the plot's own share.\n  // Nothing fades in a Pie Chart.\n  const highlightedKey =\n    (popOut ? focus.focusedKey : null) ?? interaction.activeKey;\n  // How far the highlighted share steps out, in the units of the box: none\n  // unless the chart pops out, so nothing moves, whatever highlights a share.\n  const popUnits = popOut ? popDistance(size) : 0;\n  // The card sits beside the middle of the share, where it stands: stepped out\n  // while it is the highlighted one and the chart pops out.\n  const anchorFor = (key: string) => {\n    const arc = drawn.find((item) => item.key === key);\n    const box = chart.current?.getBoundingClientRect();\n    if (!arc || total <= 0 || !box) return null;\n    const point = shareAnchor(\n      arc.start,\n      arc.value,\n      innerRadius,\n      key === highlightedKey\n        ? popOffset(arc.start, arc.value, popUnits)\n        : undefined,\n    );\n    return {\n      x: box.x + box.width * point.x,\n      y: box.y + box.height * point.y,\n    };\n  };\n  // What the chart says about its shares to assistive technology when it takes\n  // focus: its empty label when there is nothing in the ring, and without a\n  // legend each share and its value, which the legend reads out otherwise.\n  const description =\n    total <= 0\n      ? emptyLabel\n      : legend\n        ? undefined\n        : // A full stop ends it, so that the keyboard hint after it is heard\n          // as a sentence of its own.\n          `${rows.map((row) => `${row.label}: ${formatValue(row.value)}`).join(\"; \")}.`;\n  return (\n    // The figure holds the ring and the legend. The focus stop inside is the\n    // chart for assistive technology, named by the label alone.\n    <figure\n      data-slot=\"pie-chart\"\n      className={cn(\n        // A legend without rows keeps no gap beside the ring (rulebook S11).\n        // It stays mounted, so its rows still enter and leave with motion.\n        \"sp-pie-chart\",\n        \"flex max-w-full flex-wrap items-start justify-center gap-5 [&:not(:has([data-slot=chart-legend]_li))]:gap-0\",\n        className,\n      )}\n    >\n      <ChartTooltip\n        // The ring fills its box, so the card has room beside a share only\n        // outside the chart: it stays inside the window instead.\n        align=\"center\"\n        boundary=\"viewport\"\n        interaction={interaction}\n        label={label}\n        anchorFor={anchorFor}\n        content={\n          selected && (\n            <>\n              <strong>\n                <MotionNumber value={formatValue(selected.value)} />\n              </strong>\n              <div>{selected.label}</div>\n            </>\n          )\n        }\n        announcement={\n          selected\n            ? chartTooltipText(undefined, [\n                { label: selected.label, value: formatValue(selected.value) },\n              ])\n            : undefined\n        }\n        description={description}\n        describedBy={describedBy}\n        keyboardHint={words.keyboardHint}\n        roleDescription={words.roleDescription}\n        className=\"relative max-w-full rounded-full\"\n        style={{ width: size, height: size }}\n      >\n        {/* The ring keeps its size at rest and the box is as tight as it was:\n            a share that steps out is drawn outside it, so a chart that pops\n            out must not have its SVG clip it. It is a picture that the focus\n            stop describes, so it is hidden from assistive technology. */}\n        <svg\n          ref={chart}\n          viewBox={`0 0 ${PIE_BOX} ${PIE_BOX}`}\n          className={cn(\"size-full\", popOut && \"overflow-visible\")}\n          aria-hidden=\"true\"\n        >\n          {/* No SVG titles: the browser would show its own tooltip next to\n              the chart's. The label names the chart; the legend and the\n              tooltip carry the values. The track is the part of the ring\n              that no share fills, in the neutral of an empty share. It lies\n              beside the shares, never under them, so that their edges and a\n              share that steps out show the surface behind the chart. */}\n          {unfilled > trackMinimum &&\n            (pie ? (\n              <path\n                d={wedgePath(filled, unfilled)}\n                className=\"sp-pie-chart-track\"\n                data-slot=\"pie-chart-track\"\n                fill={chartEmptyColor}\n              />\n            ) : (\n              <path\n                d={ringPath(ring.radius)}\n                className=\"sp-pie-chart-track\"\n                data-slot=\"pie-chart-track\"\n                fill=\"none\"\n                pathLength=\"100\"\n                stroke={chartEmptyColor}\n                strokeDasharray={`${unfilled} ${100 - unfilled}`}\n                strokeDashoffset={-filled}\n                strokeWidth={ring.width}\n              />\n            ))}\n          {drawn.some((arc) => arc.value > 0) &&\n            drawn.map((arc) => (\n              <PieSegment\n                arc={arc}\n                color={arc.row.color}\n                container={chart}\n                highlighted={highlightedKey === arc.key}\n                key={arc.key}\n                pie={pie}\n                popUnits={popUnits}\n                ring={ring}\n              />\n            ))}\n        </svg>\n        {/* The empty label is the charts' own empty state, in HTML over the\n            ring, so that it keeps its 14 px at any size of the chart. */}\n        {total <= 0 && <ChartEmptyState label={emptyLabel} />}\n        {/* The centre is HTML over the plot, in the hole, which is half the\n            width of the chart: the square around the hole, cut off at its\n            round edge, so that nothing in it covers the ring. It sits inside\n            the focus stop, whose name is the label alone, and never takes the\n            pointer, so the lookup of the ring sees every point of the hole. A\n            chart with nothing to draw shows its empty label there instead. */}\n        {!pie &&\n          total > 0 &&\n          (center != null ||\n            (centerValue !== undefined && Number.isFinite(centerValue))) && (\n            <div\n              data-slot=\"pie-chart-center\"\n              className=\"sp-pie-chart-center pointer-events-none absolute inset-1/4 flex flex-col items-center justify-center overflow-hidden rounded-full text-center\"\n            >\n              {center ?? (\n                <PieCenterFigure\n                  caption={centerLabel}\n                  format={centerFormat}\n                  locale={locale}\n                  size={size}\n                  value={centerValue!}\n                />\n              )}\n            </div>\n          )}\n      </ChartTooltip>\n      {legend && (\n        <ChartLegend\n          fade={false}\n          {...(popOut ? { focus } : {})}\n          items={rows.map((row) => ({\n            key: row.id,\n            label: row.label,\n            color: row.color,\n            value: formatValue(row.value),\n          }))}\n        />\n      )}\n    </figure>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/pie-chart/pie-chart.tsx"},{"path":"packages/ui/src/components/pie-chart/pie-geometry.ts","content":"/*\n * The geometry of the Pie Chart. It is drawn in a 200 x 200 box around its\n * centre. A share is a number of percent of the circle, counted from 12 o'clock\n * clockwise, so the ring, the wedges, the pointer and the tooltip all agree on\n * where a share is.\n *\n * The way the active share steps out, along its middle angle by a fixed\n * distance, is adapted from bklit UI's pie-slice.tsx (`getSliceOffset`) and\n * pie-chart.tsx (`DEFAULT_HOVER_OFFSET`), MIT. Everything else is Siteplane's\n * own.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport { estimateLabelWidth } from \"../chart/chart-scales\";\n\n/** The side of the box the chart is drawn in: the view box of its SVG. */\nexport const PIE_BOX = 200;\nconst CENTER = PIE_BOX / 2;\n/** The radius of the rim. The ring and the pie both reach the edge of the box. */\nexport const PIE_OUTER_RADIUS = 100;\n/** The radius of the hole of a donut. A pie has none. */\nexport const DONUT_INNER_RADIUS = 50;\n\n/**\n * The centre line and the width of the stroke that draws a ring from the inner\n * radius out to the rim. With no inner radius the stroke fills the disc.\n */\nexport function ringOf(innerRadius: number): { radius: number; width: number } {\n  return {\n    radius: (PIE_OUTER_RADIUS + innerRadius) / 2,\n    width: PIE_OUTER_RADIUS - innerRadius,\n  };\n}\n\n/**\n * The path that a share of the ring is dashed along: the circle of `radius`,\n * from 12 o'clock clockwise and back, left open. A closed circle would let the\n * dasher join the last dash to the first at the closing point, and a share that\n * ends exactly there then showed a stray wedge for some data.\n */\nexport function ringPath(radius: number): string {\n  const top = CENTER - radius;\n  const bottom = CENTER + radius;\n  return `M ${CENTER} ${top} A ${radius} ${radius} 0 1 1 ${CENTER} ${bottom} A ${radius} ${radius} 0 1 1 ${CENTER} ${top}`;\n}\n\n/** A coordinate as short as it needs to be: three decimals, and none that are 0. */\nfunction coordinate(value: number): number {\n  return Math.round(value * 1000) / 1000;\n}\n\n/** The point on the rim `percent` of the way round, as the two numbers of a path. */\nfunction rimPoint(percent: number): string {\n  const angle = (percent / 100) * 2 * Math.PI;\n  const x = CENTER + PIE_OUTER_RADIUS * Math.sin(angle);\n  const y = CENTER - PIE_OUTER_RADIUS * Math.cos(angle);\n  return `${coordinate(x)} ${coordinate(y)}`;\n}\n\n// A share this close to the whole is drawn as the disc. A wedge that ends where\n// it began has an arc of no length, which a browser leaves out: the share would\n// vanish instead of filling the circle.\nconst WHOLE = 99.999;\n\n/**\n * The path of the wedge that fills the disc from `start` to `start + value`\n * percent of the circle: from the centre out to the rim, along the rim, and\n * back. An empty string when the share has nothing in it.\n */\nexport function wedgePath(start: number, value: number): string {\n  if (!(value > 0) || !Number.isFinite(start)) return \"\";\n  const radius = PIE_OUTER_RADIUS;\n  if (value >= WHOLE) {\n    return `M ${CENTER} 0 A ${radius} ${radius} 0 1 1 ${CENTER} ${PIE_BOX} A ${radius} ${radius} 0 1 1 ${CENTER} 0 Z`;\n  }\n  const longWay = value > 50 ? 1 : 0;\n  return `M ${CENTER} ${CENTER} L ${rimPoint(start)} A ${radius} ${radius} 0 ${longWay} 1 ${rimPoint(start + value)} Z`;\n}\n\n/** A share as it is drawn now: where it starts and how much of the circle it holds. */\nexport type PieArc = {\n  key: string;\n  start: number;\n  value: number;\n  /** A share that has left the data and is shrinking away. */\n  leaving: boolean;\n};\n\n/**\n * The share under a point, found by the angle of the point within the ring, or\n * the disc when `innerRadius` is 0. The point is an offset from the centre in\n * the box, with y growing downwards. Null outside the rim and in a hole. One\n * lookup for the whole chart keeps the active share steady while the pointer\n * crosses from one share to the next; it reads the shares as they are drawn, so\n * the share under the pointer is the one seen.\n */\nexport function shareAtPoint(\n  arcs: readonly PieArc[],\n  dx: number,\n  dy: number,\n  innerRadius: number,\n): string | null {\n  const distance = Math.hypot(dx, dy);\n  if (distance < innerRadius || distance > PIE_OUTER_RADIUS) return null;\n  const turn = ((Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1) * 100;\n  for (const arc of arcs) {\n    if (\n      !arc.leaving &&\n      arc.value > 0 &&\n      turn >= arc.start &&\n      turn < arc.start + arc.value\n    )\n      return arc.key;\n  }\n  return null;\n}\n\n/**\n * The direction a share points in: the angle of the middle of its arc, in\n * radians from 12 o'clock, clockwise.\n */\nexport function middleAngle(start: number, value: number): number {\n  return ((start + value / 2) / 100) * 2 * Math.PI;\n}\n\n/**\n * Where the tooltip sits for a share from the keyboard or a tap, as fractions\n * of the box: the middle of its arc, halfway between the inner radius and the\n * rim. `out` is how far the share has stepped out (`popOffset`, in the units of\n * the box): the tooltip sits beside the share where it stands.\n */\nexport function shareAnchor(\n  start: number,\n  value: number,\n  innerRadius: number,\n  out: { x: number; y: number } = { x: 0, y: 0 },\n): { x: number; y: number } {\n  // From 12 o'clock, as `middleAngle` counts, to the 3 o'clock of the circle's own maths.\n  const angle = middleAngle(start, value) - Math.PI / 2;\n  const reach = (PIE_OUTER_RADIUS + innerRadius) / 2 / PIE_BOX;\n  return {\n    x: 0.5 + reach * Math.cos(angle) + out.x / PIE_BOX,\n    y: 0.5 + reach * Math.sin(angle) + out.y / PIE_BOX,\n  };\n}\n\n/**\n * The text steps of a figure in the hole of a donut, in px, from the largest:\n * the 30 px of a Metric Card's figure down to the 12 px of a caption.\n */\nexport const PIE_CENTER_STEPS = [30, 24, 20, 16, 14, 12] as const;\nexport type PieCenterStep = (typeof PIE_CENTER_STEPS)[number];\n// The steps in which a figure keeps all its digits; below them it is written\n// compact first, so a large total reads as 1.2M in a large step rather than\n// in full in a small one.\nconst fullSteps = 3;\n// The caption under a figure: 12 px on a line of its height, 4 px below it.\nconst captionSize = 12;\nconst captionGap = 4;\n\n/** Where a figure and its caption sit in the hole: the step of the figure, whether it is compact, and how wide the caption may be. */\nexport type PieCenterFit = {\n  compact: boolean;\n  size: PieCenterStep;\n  /** The widest the caption may be, in percent of the hole's width: the chord of the hole at the caption's lower edge. */\n  captionWidth: number;\n};\n\n/**\n * The step a figure in the hole of a donut `chartSize` px across takes, and\n * whether it is written compact: the first that fits, in the order of\n * `PIE_CENTER_STEPS`, the full figure in the three largest steps and then the\n * compact one in every step. A line fits when its corners stay inside the hole,\n * less a margin of 12 % of its radius from the ring (at least 4 px), with the\n * figure and the caption centred in the hole as one block. The widths are the\n * kit's estimate (`estimateLabelWidth`), so that the server and the client\n * pick the same step; it errs on the wide side. A figure that fits in no step\n * takes the smallest, compact.\n */\nexport function pieCenterFit({\n  caption,\n  chartSize,\n  compact,\n  full,\n}: {\n  caption?: string | undefined;\n  chartSize: number;\n  /** The figure in compact notation. */\n  compact: string;\n  /** The figure in full. */\n  full: string;\n}): PieCenterFit {\n  const hole = (Math.max(0, chartSize) * DONUT_INNER_RADIUS) / PIE_BOX;\n  const room = Math.max(0, hole - Math.max(4, hole * 0.12));\n  // The width of a line whose corners sit `edge` px above or below the centre.\n  const chord = (edge: number) =>\n    edge >= room ? 0 : 2 * Math.sqrt(room * room - edge * edge);\n  const blockHeight = (size: number) =>\n    size + (caption ? captionGap + captionSize : 0);\n  const width = (text: string, size: number) =>\n    (estimateLabelWidth(text) * size) / 12;\n  const candidates = [\n    ...PIE_CENTER_STEPS.slice(0, fullSteps).map((size) => ({\n      compact: false,\n      size,\n    })),\n    ...PIE_CENTER_STEPS.map((size) => ({ compact: true, size })),\n  ];\n  const chosen =\n    candidates.find(\n      ({ compact: short, size }) =>\n        width(short ? compact : full, size) <= chord(blockHeight(size) / 2),\n    ) ?? candidates[candidates.length - 1]!;\n  const captionWidth =\n    hole > 0 ? (chord(blockHeight(chosen.size) / 2) / (2 * hole)) * 100 : 0;\n  return {\n    captionWidth: Math.round(captionWidth * 100) / 100,\n    compact: chosen.compact,\n    size: chosen.size,\n  };\n}\n\n/** How far the active share steps out of the ring, in px: bklit UI's `hoverOffset`. */\nexport const PIE_POP_OUT = 10;\n\n/**\n * How far that is in the units of the box, for a chart drawn `size` px across:\n * the box is scaled to the size, so the step stays `PIE_POP_OUT` px whatever\n * the size of the chart is.\n */\nexport function popDistance(size: number): number {\n  return (PIE_POP_OUT * PIE_BOX) / Math.max(1, size);\n}\n\n/**\n * Where the active share moves to: `distance` along its middle angle, away from\n * the centre, as a translate in the units of `distance`, with y growing\n * downwards (bklit's `getSliceOffset`: x is the sine of the angle and y minus\n * its cosine, from 12 o'clock). A share that is the whole has nowhere outside\n * itself to go and stays.\n */\nexport function popOffset(\n  start: number,\n  value: number,\n  distance: number,\n): { x: number; y: number } {\n  if (!(value > 0) || value >= WHOLE) return { x: 0, y: 0 };\n  const angle = middleAngle(start, value);\n  // `|| 0` keeps a minus sign off a zero that the rounding leaves.\n  return {\n    x: coordinate(Math.sin(angle) * distance) || 0,\n    y: coordinate(-Math.cos(angle) * distance) || 0,\n  };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/pie-chart/pie-geometry.ts"},{"path":"packages/ui/src/components/pie-chart/index.ts","content":"export * from \"./pie-chart\";\n","type":"registry:ui","target":"~/src/siteplane/components/pie-chart/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/pie-chart.md","categories":["Charts"],"meta":{"avoidWhen":["Avoid when readers must compare values precisely or follow change over time; use a Table or a chart with axes.","Avoid for more than five or six shares: small shares become hard to see, and the colors repeat after the eighth."],"description":"Show how a total divides into shares as a donut or pie chart.","knownLimitations":["The ring is one focus stop and arrow keys move between segments; segments have no click or selection props.","variant=\"pie\" fills the disc with a wedge per share and has no hole, so center, centerValue and centerLabel show only in the donut; they lie over the ring and never take the pointer.","centerValue is fitted into the hole from the chart's size and the text alone, the same on the server and the client: the full figure at 30, 24 or 20 px, otherwise compact (1.2M) at the largest step from 30 down to 12 px that fits; screen readers read it in full. The estimate errs on the wide side, so a typeface much wider than the kit's may need a smaller size. centerLabel stays on one line and ends in an ellipsis.","Content of your own in center is laid out in the square around the hole and cut off at the hole's round edge, so it never covers the ring but can be cut: keep it short, or pass the figure as centerValue.","Without the legend the shares and their values are read out as the description of the chart; the legend sits beside the ring and has no position option.","When the data changes, the shares glide to their new size and a share is told by its id: give each share a stable id, never its position.","The brand palette shades the brand color from strong to light and spreads the shades over the filled shares (up to eight); the categorical palette has the eight chart colors. Both repeat from the ninth share, except that the last share never takes the color of the first, where the ring closes; a color on a share always wins.","No share fades while another is active. popOut is off by default: no share moves, and the tooltip alone marks the active share. With popOut the highlighted share steps out 10 px at any size, so it reaches up to 10 px beyond the box of the chart: an ancestor that clips its overflow close to the chart cuts it. A share that is the whole has nowhere to go and stays.","The legend does not toggle shares, and its entries are no focus stops. Only with popOut does a mouse over an entry pop its share out (a touch does nothing); without popOut the legend does not react."],"providerNotes":["The tooltip comes from the installed Tooltip. The chart opens and closes it itself, so it needs no TooltipProvider.","The glide uses the installed Motion component and its tokens. SiteplaneMotionProvider preset=\"reduced\" and the system setting show new data at once."],"releaseStatus":"stable","searchTerms":["pie-chart","pie","donut","ring","chart","share","distribution"],"useWhen":["Use when a few shares of one total should be shown as a donut or a pie with a legend and a tooltip.","Use variant=\"pie\" when the shares should fill a whole disc, or centerValue and centerLabel when the hole of the ring should carry a figure such as a total.","Use popOut when the share under the pointer, or the share of the legend entry under the mouse, should step out of the chart."]}}