{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bubble-chart",
  "type": "registry:component",
  "title": "Bubble Chart",
  "description": "One labelled circle per row, on two measured axes, with a third quantity on each circle's area.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/panelui/bubble-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/bubble-chart/index.tsx",
      "content": "'use client';\n\n/**\n * BubbleChart — one labelled circle per row, on two measured axes, with a third\n * quantity on each circle's area.\n *\n * ```tsx\n * <BubbleChart data={products} xDataKey=\"growth\" yDataKey=\"margin\" sizeKey=\"revenue\" labelKey=\"code\">\n *   <BubbleChart.Grid />\n *   <BubbleChart.Bubbles />\n *   <BubbleChart.Labels />\n *   <BubbleChart.XAxis />\n *   <BubbleChart.YAxis />\n *   <BubbleChart.Tooltip />\n * </BubbleChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's BubbleChart, so a chart written for\n * one reads the same in the other. What differs is what the web changes: a colour is\n * named by the theme's role or any CSS colour, a bubble is read by hovering, tapping or\n * dragging, a click pins the reading, and the arrow keys walk the bubbles.\n *\n * ## The parts describe, the root draws\n *\n * `Grid`, `Quadrants`, `Trend`, `Bubbles`, `Labels`, the axes and `Tooltip` draw\n * nothing themselves. Each one tells the root what it wants, and the root builds one\n * chart definition from them, so the rules, the line and the names all move with the\n * bubbles when the data changes. They are found by context and not by inspecting\n * children, so a part can be wrapped in a component of your own.\n *\n * ## Area, not radius\n *\n * The third quantity maps to area, because doubling a radius quadruples the ink. Every\n * edge of the plot holds back the largest radius, so the bubble with the largest value\n * is never the one cropped.\n */\nimport {\n  defineChart,\n  dot,\n  lineX,\n  rect,\n  ruleX,\n  ruleY,\n  text,\n  type ChartInteractionController,\n  type ChartMark,\n  type ChartMotionContext,\n  type ChartPoint,\n} from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport { tooltip as chartTooltip } from '@tanstack/charts/tooltip';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\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 {\n  bubbleDomains,\n  bubbleRadius,\n  bubbleRows,\n  colorOf,\n  fitLabel,\n  fitLine,\n  quadrantCentre,\n  sizeExtentOf,\n  skeletonField,\n  trendSegment,\n  type BubbleChartDatum,\n  type BubbleFit,\n  type BubbleRow,\n} from './bubble-scale';\n\nexport type { BubbleChartDatum } from './bubble-scale';\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 BubbleChartStatus = 'loading' | 'ready';\n\n/** One bubble, resolved back to the row it came from. */\nexport interface BubbleChartPoint {\n  /** Index into `data`. */\n  index: number;\n  x: number;\n  y: number;\n  /** The value behind the area, when `sizeKey` is set. */\n  size: number | null;\n  /** The name written inside the circle, when `labelKey` is set. */\n  label: string;\n  /** The colour it was drawn in. */\n  color: string;\n  datum: BubbleChartDatum;\n}\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'],\n};\n\nconst bubbleChartVariants = 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',\n    // `pan-y` hands a vertical drag to the page and a sideways one to the chart.\n    chart:\n      'absolute! inset-0 touch-pan-y rounded-lg text-xs [&_circle]:origin-center [&_circle]:[transform-box:fill-box] [--ts-chart-tooltip-background:var(--popover)] [--ts-chart-tooltip-border-radius:var(--radius-xl)] [--ts-chart-tooltip-border:1px_solid_var(--border)] [--ts-chart-tooltip-color:var(--popover-foreground)] [--ts-chart-tooltip-shadow:var(--shadow-lg)] [&_svg]:rounded-lg [&_svg]:outline-none [&_svg:focus-visible]:ring-2 [&_svg:focus-visible]:ring-ring',\n    legend: 'col-start-1 row-start-3 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 pt-3 text-xs text-muted-foreground',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    sizeKey: 'pointer-events-none absolute col-start-1 row-start-2 text-xs',\n    sizeKeyLabel: 'font-medium',\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: 'flex 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 left around the plot for the axis labels. Never less than the largest radius. */\nconst PADDING = { top: 18, right: 18, bottom: 22, left: 18 };\n\n/** Left gutter when a `YAxis` is present, for its labels to sit in. */\nconst Y_AXIS_WIDTH = 44;\n\n/** Room for an axis's own name, when it is given one. */\nconst AXIS_TITLE_SIZE = 18;\n\n/** Margin of a compact chart. */\nconst COMPACT_EDGE = 4;\n\n/** How much of the reveal goes to staggering the bubbles' start times. */\nconst STAGGER_SHARE = 0.4;\n\n/** Up past its size and back to it: a circle that only grows reads as still loading until the last frame. */\nconst LANDING = 'cubic-bezier(0.34, 1.56, 0.64, 1)';\n\n/** Milliseconds for a bubble to swell as it is read, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/** How much bigger a bubble is while it is read. */\nconst SWELL = 1.12;\n\n/** How far from a bubble's centre the pointer still reads it, at the least. */\nconst HIT_RADIUS = 22;\n\n/** The radii the size key maps onto. */\nconst SIZE_RANGE: [number, number] = [10, 28];\n\n/** Smallest radius that still gets a name: below it the name is wider than the circle. */\nconst LABEL_MIN_RADIUS = 10;\n\n/** The names' paint, on a fill that is a chart colour in every theme. Also how the entrance finds them. */\nconst LABEL_FILL = '#fff';\n\n/** Column the size key's values are written in, beside its circles. */\nconst SIZE_KEY_LABEL_WIDTH = 44;\n\n/** Gap between a quadrant's caption and the corner it is written in. */\nconst QUADRANT_INSET = 6;\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\nconst COMPACT_SMALL = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });\n\n/** Below one, a single decimal would round most values down to nothing. */\nconst compactNumber = (value: number) => (Math.abs(value) < 1 ? COMPACT_SMALL : COMPACT).format(value);\n\n/**\n * Grows the bubbles in, one after another, all landing inside the one duration, with\n * each name fading in behind its circle. The renderer gives a dot no entrance of its\n * own on a chart's first paint.\n */\nfunction landBubbles(container: Element, duration: number) {\n  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n  const dots = container.querySelectorAll('svg circle');\n  const share = dots.length > 1 ? STAGGER_SHARE : 0;\n  dots.forEach((node, order) => {\n    node.animate([{ transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }], {\n      duration: duration * (1 - share),\n      delay: (order / dots.length) * duration * share,\n      easing: LANDING,\n      fill: 'backwards',\n    });\n  });\n  container.querySelectorAll(`svg text[fill=\"${LABEL_FILL}\"]`).forEach((node) => {\n    node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: duration / 2, delay: duration / 2, fill: 'backwards' });\n  });\n}\n\n/** A mark with nothing to read: names and overlays must not take a stop from the keyboard's walk. */\nfunction withoutPoints(mark: ChartMark<BubbleRow, number, number>): ChartMark<BubbleRow, number, number> {\n  return {\n    ...mark,\n    initialize(context) {\n      const initialized = mark.initialize(context);\n      return {\n        ...initialized,\n        postDomain(scene) {\n          const base = initialized.postDomain ? initialized.postDomain(scene) : scene;\n          return { ...base, points: [] };\n        },\n      };\n    },\n  };\n}\n\n/**\n * What the parts have asked for. An axis formatter is part of the drawing, so a new\n * one redraws it. The readout's are read through `latest` when it opens, so a new one\n * costs nothing.\n */\ninterface Slots {\n  grid?: { rows: number; columns: number; dashArray: string; opacity: number };\n  bubbles?: { opacity: number; fill: string | undefined };\n  labels?: { minRadius: number; format: BubbleChartLabelsProps['format'] };\n  trend?: { stroke: string; strokeWidth: number; dashArray: string; opacity: number };\n  quadrants?: { x: number | undefined; y: number | undefined; labels: BubbleChartQuadrantsProps['labels']; tint: boolean; stroke: string };\n  xAxis?: { ticks: number; format: BubbleChartXAxisProps['format']; label: string | undefined };\n  yAxis?: { ticks: number; format: BubbleChartYAxisProps['format']; label: string | undefined };\n  tooltip?: {\n    showLabel: boolean;\n    hitRadius: number;\n    className: string | undefined;\n    latest: { current: Pick<BubbleChartTooltipProps, 'formatX' | 'formatY' | 'formatSize' | 'labels'> };\n  };\n  skeleton?: { count: number; fill: string };\n}\n\ninterface BubbleChartContextValue {\n  rows: readonly BubbleRow[];\n  sizeExtent: [number, number] | null;\n  sizeRange: readonly [number, number];\n  margin: { top: number; right: number; bottom: number; left: number };\n  loading: boolean;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n  activePoint: BubbleChartPoint | null;\n}\n\nconst BubbleChartContext = createContext<BubbleChartContextValue | null>(null);\n\nfunction useChart(component: string): BubbleChartContextValue {\n  const context = useContext(BubbleChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <BubbleChart>`);\n  }\n  return context;\n}\n\n/**\n * The bubble being read, for something rendered inside the chart. A readout outside it\n * takes `onActivePointChange` instead.\n */\nexport function useBubbleChart() {\n  const { activePoint } = useChart('useBubbleChart');\n  return { activeIndex: activePoint?.index ?? -1, activePoint };\n}\n\nexport interface BubbleChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The rows. One bubble each. */\n  data: readonly BubbleChartDatum[];\n  /** Key holding the horizontal value. */\n  xDataKey?: string;\n  /** Key holding the vertical value. */\n  yDataKey?: string;\n  /**\n   * Key holding the third quantity, spaced by each bubble's area. The lowest value takes\n   * the smallest radius in `sizeRange` and the highest the largest, so sizes compare rows\n   * without giving their exact ratio. Without it every bubble is drawn at the middle of\n   * `sizeRange`.\n   */\n  sizeKey?: string;\n  /** Key holding the name written inside the circle. */\n  labelKey?: string;\n  /**\n   * Key holding a colour for the row: a theme role, a CSS colour, or a number from 1\n   * to 5 naming a chart colour. Without it the ramp cycles by row.\n   */\n  colorKey?: string;\n  /**\n   * Smallest and largest radius `sizeKey` maps onto, in pixels. The largest is also\n   * what the plot holds back at every edge, so raising it costs room.\n   */\n  sizeRange?: [number, number];\n  /**\n   * `loading` holds the bubbles back and grows them in when it turns `ready`. Add a\n   * `BubbleChart.Skeleton` for something to stand in the plot meanwhile.\n   */\n  status?: BubbleChartStatus;\n  /** The plot's width ÷ height. `1` is the square shape a bubble field reads best in. */\n  aspectRatio?: number;\n  /** Milliseconds for the bubbles to grow in. `0` draws them at once. */\n  animationDuration?: number;\n  /** Milliseconds for the bubbles and the axes to settle after the data changes. */\n  domainDuration?: number;\n  /** Fix the horizontal axis instead of deriving it. */\n  xDomain?: [number, number];\n  /** Fix the vertical axis instead of deriving it. */\n  yDomain?: [number, number];\n  /**\n   * The bubble being read, and `null` when there is none. Fires when it changes, not per\n   * frame. Reading needs `BubbleChart.Tooltip`; `showLabel={false}` keeps it without the label.\n   */\n  onActivePointChange?: (point: BubbleChartPoint | null) => void;\n  /** Drop the axis padding so the field reaches the edges, for a thumbnail. */\n  compact?: boolean;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one row. Defaults to its name and values. */\n  labelForDatum?: (datum: BubbleChartDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the grow-in, for a \"replay\" control. */\nexport interface BubbleChartHandle {\n  replay: () => void;\n}\n\nconst BubbleChartRoot = forwardRef<BubbleChartHandle, BubbleChartProps & RenderProp<'div'>>(function BubbleChartRoot(\n  {\n    data,\n    xDataKey = 'x',\n    yDataKey = 'y',\n    sizeKey,\n    labelKey,\n    colorKey,\n    sizeRange = SIZE_RANGE,\n    status = 'ready',\n    aspectRatio = 1,\n    animationDuration = 800,\n    domainDuration = 500,\n    xDomain,\n    yDomain,\n    onActivePointChange,\n    compact = false,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = bubbleChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [slots, setSlots] = useState<Slots>({});\n  const [activePoint, setActivePoint] = useState<BubbleChartPoint | null>(null);\n  const [revision, setRevision] = useState(0);\n  const interactionRef = useRef<ChartInteractionController<BubbleRow, number, number> | null>(null);\n  const dragging = useRef(false);\n  const landed = useRef('');\n\n  /*\n   * The chart mounts once its width is known. Mounted at a guess, the first\n   * measurement is a resize, and a resize ends the entrance before it is seen.\n   */\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const read = () => setWidth(node.clientWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setSlot = useCallback<BubbleChartContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n    []\n  );\n\n  useImperativeHandle(ref, () => ({ replay: () => setRevision((current) => current + 1) }), []);\n\n  const loading = status === 'loading';\n  const { grid, bubbles, labels, trend, quadrants, xAxis, yAxis, tooltip, skeleton } = slots;\n  const [smallest, largest] = sizeRange;\n\n  // A colour on the Bubbles part is the colour they are drawn in, so the legend, the\n  // tooltip and the reported point all take it from here.\n  const fillOverride = bubbles?.fill;\n  const rows = useMemo(() => {\n    const drawn = bubbleRows(data, { x: xDataKey, y: yDataKey, size: sizeKey, label: labelKey, color: colorKey }, [smallest, largest]);\n    return fillOverride ? drawn.map((row) => ({ ...row, fill: fillOverride })) : drawn;\n  }, [data, xDataKey, yDataKey, sizeKey, labelKey, colorKey, smallest, largest, fillOverride]);\n  const sizeExtent = useMemo(() => sizeExtentOf(data, { x: xDataKey, y: yDataKey, size: sizeKey }), [data, xDataKey, yDataKey, sizeKey]);\n\n  const margin = compact\n    ? { top: COMPACT_EDGE, right: COMPACT_EDGE, bottom: COMPACT_EDGE, left: COMPACT_EDGE }\n    : {\n        top: Math.max(PADDING.top, largest),\n        right: Math.max(PADDING.right, largest),\n        bottom: Math.max(xAxis ? PADDING.bottom : 0, largest) + (xAxis?.label ? AXIS_TITLE_SIZE : 0),\n        left: Math.max(yAxis ? Y_AXIS_WIDTH + (yAxis.label ? AXIS_TITLE_SIZE : 0) : PADDING.left, largest),\n      };\n\n  const definition = useMemo(() => {\n    const domains = bubbleDomains(rows, xDomain, yDomain);\n    const overlays: ChartMark<BubbleRow, number, number>[] = [];\n    const place = (x: number, y: number, extra?: Partial<BubbleRow>): BubbleRow => ({\n      index: 0,\n      x,\n      y,\n      r: 0,\n      size: null,\n      label: '',\n      fill: '',\n      ...extra,\n    });\n\n    const centre = quadrants ? quadrantCentre(rows, quadrants.x, quadrants.y) : null;\n    if (quadrants && centre) {\n      const [left, right] = domains.x;\n      const [bottom, top] = domains.y;\n      if (quadrants.tint) {\n        overlays.push(\n          withoutPoints(\n            rect(\n              [\n                place(centre.x, centre.y, { index: 0, x2: right, y2: top }),\n                place(left, bottom, { index: 1, x2: centre.x, y2: centre.y }),\n              ],\n              {\n                id: 'bubble-tint',\n                key: 'index',\n                x1: 'x',\n                x2: (row: BubbleRow) => row.x2 ?? row.x,\n                y1: 'y',\n                y2: (row: BubbleRow) => row.y2 ?? row.y,\n                fill: 'var(--foreground)',\n                fillOpacity: 0.04,\n              }\n            )\n          )\n        );\n      }\n    }\n\n    const rules = quadrants && centre\n      ? [\n          ruleX([centre.x], { id: 'bubble-split-x', stroke: quadrants.stroke, strokeOpacity: 0.7 }),\n          ruleY([centre.y], { id: 'bubble-split-y', stroke: quadrants.stroke, strokeOpacity: 0.7 }),\n        ]\n      : [];\n\n    const captions: ChartMark<BubbleRow, number, number>[] = [];\n    if (quadrants && centre && quadrants.labels) {\n      const [left, right] = domains.x;\n      const [bottom, top] = domains.y;\n      const corners = [\n        { text: quadrants.labels.topLeft, x: left, y: top, west: true, north: true },\n        { text: quadrants.labels.topRight, x: right, y: top, west: false, north: true },\n        { text: quadrants.labels.bottomLeft, x: left, y: bottom, west: true, north: false },\n        { text: quadrants.labels.bottomRight, x: right, y: bottom, west: false, north: false },\n      ];\n      corners.forEach(({ text: caption, x, y, west, north }) => {\n        if (!caption) return;\n        captions.push(\n          withoutPoints(\n            text([place(x, y, { label: caption })], {\n              id: `bubble-corner-${west ? 'w' : 'e'}${north ? 'n' : 's'}`,\n              key: 'label',\n              x: 'x',\n              y: 'y',\n              text: 'label',\n              anchor: west ? 'start' : 'end',\n              dx: west ? QUADRANT_INSET : -QUADRANT_INSET,\n              dy: north ? QUADRANT_INSET + 8 : -QUADRANT_INSET,\n              fill: 'var(--foreground)',\n              fontWeight: 500,\n            })\n          )\n        );\n      });\n    }\n\n    const fit = trend ? fitLine(rows) : null;\n    const segment = fit ? trendSegment(fit, domains.x, domains.y) : null;\n    const trendLine =\n      trend && segment\n        ? [\n            withoutPoints(\n              lineX(\n                segment.map((end, index) => place(end.x, end.y, { index })),\n                {\n                  id: 'bubble-trend',\n                  key: 'index',\n                  x: 'x',\n                  y: 'y',\n                  stroke: trend.stroke,\n                  strokeWidth: trend.strokeWidth,\n                  strokeDasharray: trend.dashArray,\n                  strokeOpacity: trend.opacity,\n                }\n              )\n            ),\n          ]\n        : [];\n\n    // One mark per colour: a dot's fill is a constant, and a bubble's colour is the row's.\n    const fills = bubbles ? [...new Set(rows.map(({ fill }) => fill))] : [];\n    const circles = fills.map((fill) =>\n      dot(\n        rows.filter((row) => row.fill === fill),\n        {\n          id: `bubbles-${fill}`,\n          key: 'index',\n          x: 'x',\n          y: 'y',\n          z: () => 'bubbles',\n          r: 'r',\n          fill,\n          fillOpacity: bubbles?.opacity,\n          // A size change alone is easy to miss among neighbours, and an opacity\n          // change alone is invisible wherever circles overlap, so it does both.\n          states: [\n            {\n              when: { focus: 'primary' as const },\n              style: { r: ({ datum }: { datum: BubbleRow }) => datum.r * SWELL, fillOpacity: 1 },\n              transition: { type: 'tween' as const, duration: SELECT_DURATION, easing: 'ease-out' as const },\n            },\n          ],\n        }\n      )\n    );\n\n    const named = labels\n      ? rows\n          .filter((row) => row.r >= labels.minRadius && (labels.format || row.label))\n          .map((row) => {\n            const datum = data[row.index];\n            const name = labels.format && datum\n              ? labels.format({ index: row.index, x: row.x, y: row.y, size: row.size, label: row.label, color: row.fill, datum })\n              : row.label;\n            return { ...row, label: fitLabel(name, row.r) };\n          })\n      : [];\n    const names = named.length\n      ? [\n          withoutPoints(\n            text(named, {\n              id: 'bubble-names',\n              key: 'index',\n              x: 'x',\n              y: 'y',\n              text: 'label',\n              fill: LABEL_FILL,\n              fontWeight: 500,\n              dy: 4,\n            })\n          ),\n        ]\n      : [];\n\n    const placeholders = dot(loading && skeleton ? skeletonField(skeleton.count) : [], {\n      id: 'bubble-skeleton',\n      key: 'index',\n      x: 'x',\n      y: 'y',\n      r: 'r',\n      fill: skeleton?.fill ?? 'var(--muted)',\n      motion: false,\n    });\n\n    const axisOf = (\n      slot: { ticks: number; format: ((value: number) => string) | undefined; label: string | undefined } | undefined,\n      gridCount: number | undefined\n    ) => {\n      if (slot) {\n        return {\n          line: false as const,\n          ...(slot.label ? { label: slot.label } : {}),\n          tickLabels: { opacity: 1 },\n          ticks: {\n            size: 0,\n            count: slot.ticks,\n            format: (value: number) => (loading ? '' : slot.format ? slot.format(value) : compactNumber(value)),\n          },\n        };\n      }\n      return gridCount ? { line: false as const, ticks: { size: 0, count: gridCount, format: () => '' } } : (false as const);\n    };\n    const gridLines = grid\n      ? { stroke: 'var(--border)', strokeOpacity: grid.opacity, strokeDasharray: grid.dashArray }\n      : (false as const);\n\n    const interactive = tooltip !== undefined && !loading && rows.length > 0 && bubbles !== undefined;\n    const readout =\n      tooltip && tooltip.showLabel\n        ? {\n            use: chartTooltip,\n            className: tooltip.className,\n            content(focused: readonly ChartPoint<BubbleRow, number, number>[]) {\n              const { formatX, formatY, formatSize, labels: names_ } = tooltip.latest.current;\n              const point = focused[0];\n              if (!point) return { title: '', rows: [] };\n              const { x, y, size, label: title, fill } = point.datum;\n              return {\n                title,\n                rows: [\n                  { label: names_?.[xDataKey] ?? xDataKey, value: formatX ? formatX(x) : compactNumber(x), color: fill },\n                  { label: names_?.[yDataKey] ?? yDataKey, value: formatY ? formatY(y) : compactNumber(y) },\n                  ...(sizeKey && size !== null\n                    ? [{ label: names_?.[sizeKey] ?? sizeKey, value: formatSize ? formatSize(size) : compactNumber(size) }]\n                    : []),\n                ],\n              };\n            },\n          }\n        : undefined;\n\n    return defineChart({\n      theme: THEME,\n      marks: loading ? [placeholders] : [...overlays, ...rules, ...trendLine, ...circles, ...names, ...captions],\n      scales: {\n        // While loading, the placeholders are shares of the plot, and there may be\n        // no values yet to derive a domain from.\n        x: {\n          scale: scaleLinear(loading ? [0, 1] : domains.x, [0, 1]),\n          grid: gridLines,\n          axis: axisOf(xAxis, grid?.columns),\n        },\n        y: {\n          scale: scaleLinear(loading ? [0, 1] : domains.y, [0, 1]),\n          grid: gridLines,\n          axis: axisOf(yAxis, grid?.rows),\n        },\n      },\n      ...(compact ? { guides: false as const } : {}),\n      margin,\n      focusRing: false,\n      // Only a bubble added to a chart already showing fades in here, so a replay does\n      // not also fade the axes and grid in again. The first arrival is `landBubbles`.\n      motion: (context: ChartMotionContext<BubbleRow>) =>\n        context.phase === 'enter' && context.role !== 'dot' && context.role !== 'line'\n          ? (false as const)\n          : { transition: { type: 'tween' as const, duration: domainDuration, easing: 'ease-out' as const } },\n      keyboard: interactive,\n      pointer: interactive,\n      ...(interactive\n        ? {\n            // The largest bubble is wider than the floor, and all of it should read.\n            maxFocusDistance: Math.max(tooltip.hitRadius, largest * SWELL),\n            ...(readout ? { tooltip: readout } : {}),\n          }\n        : {}),\n    });\n  }, [\n    rows,\n    data,\n    xDataKey,\n    yDataKey,\n    sizeKey,\n    loading,\n    xDomain?.[0],\n    xDomain?.[1],\n    yDomain?.[0],\n    yDomain?.[1],\n    compact,\n    domainDuration,\n    largest,\n    margin.top,\n    margin.right,\n    margin.bottom,\n    margin.left,\n    grid,\n    bubbles,\n    labels,\n    trend,\n    quadrants,\n    xAxis,\n    yAxis,\n    tooltip,\n    skeleton,\n  ]);\n\n  // Marks take their timing from the definition. This one is what the readout\n  // moves with, and it should keep up with the pointer.\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n  const summary = label ?? `Bubble chart, ${data.length} ${data.length === 1 ? 'row' : 'rows'}`;\n\n  const context = useMemo<BubbleChartContextValue>(\n    () => ({ rows, sizeExtent, sizeRange: [smallest, largest], margin, loading, setSlot, activePoint }),\n    [rows, sizeExtent, smallest, largest, margin.top, margin.right, margin.bottom, margin.left, loading, setSlot, activePoint]\n  );\n\n  const spokenRow = (datum: BubbleChartDatum, index: number) =>\n    labelForDatum?.(datum, index) ??\n    [labelKey, xDataKey, yDataKey, sizeKey]\n      .flatMap((key) => (key && datum[key] !== null && datum[key] !== undefined ? [`${key}, ${datum[key]}`] : []))\n      .join('. ');\n\n  // The chart also reports a point again when the data is rebuilt under it. Only a\n  // different bubble, or new values on the same one, is news, and repeating it can loop\n  // a caller that keeps the datum.\n  const reportedId = useRef('');\n  const report = (point: BubbleChartPoint | null) => {\n    const id = point ? JSON.stringify([point.index, point.x, point.y, point.size, point.label, point.color, point.datum]) : '';\n    if (reportedId.current === id) return;\n    reportedId.current = id;\n    setActivePoint(point);\n    onActivePointChange?.(point);\n  };\n  const onFocusChange = (point: ChartPoint<BubbleRow, number, number> | null) => {\n    const datum = point ? data[point.datum.index] : undefined;\n    if (!point || !datum) return report(null);\n    const { index, x, y, size, label: name, fill } = point.datum;\n    report({ index, x, y, size, label: name, color: fill, datum });\n  };\n\n  // A remounted chart says nothing about the focus it lost.\n  useEffect(() => report(null), [revision, status]);\n\n  /*\n   * The chart follows a mouse on its own but not a finger, which it treats as a tap. A\n   * sideways drag reads the bubbles it passes, and the last one stays until the next\n   * press, the same as a tap's. A vertical drag never gets here: `pan-y` gives it to the\n   * page, and the browser cancels the pointer.\n   */\n  const readDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== 'touch' || !tooltip || loading) return;\n    const interaction = interactionRef.current;\n    if (!interaction) return;\n    interaction.setControlledFocus(interaction.resolvePointer(event.clientX, event.clientY));\n    if (dragging.current) return;\n    dragging.current = true;\n    document.addEventListener(\n      'pointerdown',\n      () => {\n        dragging.current = false;\n        interactionRef.current?.setControlledFocus(null);\n      },\n      { capture: true, once: true }\n    );\n  };\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'bubble-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}>{spokenRow(datum, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div\n              ref={plotRef}\n              data-slot=\"bubble-chart-plot\"\n              className={slotClasses.plot()}\n              style={{ aspectRatio }}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') interactionRef.current?.setControlledFocus(null);\n              }}\n              onPointerMove={readDrag}\n              onPointerCancel={() => interactionRef.current?.setControlledFocus(null)}\n            >\n              {width > 0 ? (\n                <Chart\n                  // An entrance only plays on a chart's first paint, so the bubbles\n                  // arriving after a load, or a replay, are a new chart.\n                  key={`${revision}:${status}`}\n                  definition={definition}\n                  renderer={renderer}\n                  ariaLabel={summary}\n                  aspectRatio={aspectRatio}\n                  initialWidth={width}\n                  className={slotClasses.chart()}\n                  onFocusChange={onFocusChange}\n                  onRender={({ container, scene, interaction }) => {\n                    interactionRef.current = interaction;\n                    // An entrance belongs to a chart's first paint with bubbles in it.\n                    const paint = `${revision}:${status}`;\n                    if (loading || landed.current === paint || scene.points.length === 0) return;\n                    landed.current = paint;\n                    // The chart swaps its surface right after this call, and a frame\n                    // callback still runs ahead of the first paint.\n                    requestAnimationFrame(() => landBubbles(container, animationDuration));\n                  }}\n                />\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <BubbleChartContext.Provider value={context}>{element}</BubbleChartContext.Provider>;\n});\nBubbleChartRoot.displayName = 'BubbleChart';\n\n/** Hands a part's formatters to the root without a new function rebuilding the chart. */\nfunction useLatest<T>(value: T) {\n  const [latest] = useState(() => ({ current: value }));\n  useEffect(() => {\n    latest.current = value;\n  });\n  return latest;\n}\n\nexport interface BubbleChartGridProps {\n  /** Roughly how many lines to draw across the plot. With a y-axis, the lines follow its `ticks` instead. */\n  rows?: number;\n  /** Roughly how many lines to draw up it. With an x-axis, the lines follow its `ticks` instead. */\n  columns?: number;\n  /** The stroke's dash pattern. */\n  dashArray?: string;\n  /** Opacity of the lines. */\n  opacity?: number;\n}\n\n/** Reference lines both ways, so a bubble can be placed against two numbers. */\nfunction BubbleChartGrid({\n  rows = 8,\n  columns = 8,\n  dashArray = '4 6',\n  opacity = 1,\n}: BubbleChartGridProps) {\n  const { setSlot } = useChart('BubbleChart.Grid');\n  useEffect(() => {\n    setSlot('grid', { rows, columns, dashArray, opacity });\n    return () => setSlot('grid', undefined);\n  }, [rows, columns, dashArray, opacity, setSlot]);\n  return null;\n}\nBubbleChartGrid.displayName = 'BubbleChart.Grid';\n\nexport interface BubbleChartTrendProps {\n  /**\n   * The line's slope and intercept, and how tightly the cloud sits on it. `r` runs 0\n   * to 1: 1 is every bubble on the line, 0 is a cloud with no direction. Fires when the\n   * numbers change, so putting the fit straight into state is safe. It does not fire when\n   * fewer than two bubbles with different x values remain, so clear a stored fit with the data.\n   */\n  onFit?: (fit: BubbleFit) => void;\n  /** A theme role or any CSS colour. Defaults to the muted foreground. */\n  color?: string;\n  /** Thickness of the line, in pixels. */\n  strokeWidth?: number;\n  /** Dash pattern. Dashed by default: the line is a reading, not a measurement. */\n  dashArray?: string;\n  /** Opacity of the line. */\n  opacity?: number;\n}\n\n/**\n * The straight line that fits the cloud best, drawn under the circles. Least squares on\n * the raw values, so resizing the chart never changes it. Fewer than two bubbles, or\n * every bubble on one vertical, has no line to draw.\n */\nfunction BubbleChartTrend({\n  onFit,\n  color,\n  strokeWidth = 1.5,\n  dashArray = '6 5',\n  opacity = 0.7,\n}: BubbleChartTrendProps) {\n  const { setSlot, rows } = useChart('BubbleChart.Trend');\n  const stroke = color ? colorOf(color) : 'var(--muted-foreground)';\n  useEffect(() => {\n    setSlot('trend', { stroke, strokeWidth, dashArray, opacity });\n    return () => setSlot('trend', undefined);\n  }, [stroke, strokeWidth, dashArray, opacity, setSlot]);\n\n  const latest = useLatest(onFit);\n  const fit = useMemo(() => fitLine(rows), [rows]);\n  useEffect(() => {\n    if (fit) latest.current?.(fit);\n  }, [fit?.slope, fit?.intercept, fit?.r, latest]);\n  return null;\n}\nBubbleChartTrend.displayName = 'BubbleChart.Trend';\n\nexport interface BubbleChartBubblesProps {\n  /** Fill opacity. Below 1 by default, so overlapping bubbles read as denser. */\n  opacity?: number;\n  /** One colour for every bubble, a theme role or any CSS colour, over the per-row ramp. */\n  color?: string;\n}\n\n/** The circles. */\nfunction BubbleChartBubbles({\n  opacity = 0.9,\n  color,\n}: BubbleChartBubblesProps) {\n  const { setSlot } = useChart('BubbleChart.Bubbles');\n  const fill = color ? colorOf(color) : undefined;\n  useEffect(() => {\n    setSlot('bubbles', { opacity, fill });\n    return () => setSlot('bubbles', undefined);\n  }, [opacity, fill, setSlot]);\n  return null;\n}\nBubbleChartBubbles.displayName = 'BubbleChart.Bubbles';\n\nexport interface BubbleChartSkeletonProps {\n  /** How many placeholder circles to scatter. */\n  count?: number;\n  /** A theme role or any CSS colour. Defaults to the muted surface. */\n  color?: string;\n}\n\n/**\n * The loading state: a still field of muted circles. Still, because a shimmer over\n * circles reads as them moving, and position is what this chart says.\n */\nfunction BubbleChartSkeleton({\n  count = 7,\n  color,\n}: BubbleChartSkeletonProps) {\n  const { setSlot } = useChart('BubbleChart.Skeleton');\n  const fill = color ? colorOf(color) : 'var(--muted)';\n  useEffect(() => {\n    setSlot('skeleton', { count, fill });\n    return () => setSlot('skeleton', undefined);\n  }, [count, fill, setSlot]);\n  return null;\n}\nBubbleChartSkeleton.displayName = 'BubbleChart.Skeleton';\n\nexport interface BubbleChartLabelsProps {\n  /** Smallest radius a bubble may have and still be given its name. */\n  minRadius?: number;\n  /** Turn a bubble into its name. Defaults to the value at `labelKey`. */\n  format?: (point: BubbleChartPoint) => string;\n}\n\n/**\n * The names, written inside the circles. A bubble too small to hold its name goes\n * without, and the readout names it instead. A name longer than its circle is cut.\n */\nfunction BubbleChartLabels({\n  minRadius = LABEL_MIN_RADIUS,\n  format,\n}: BubbleChartLabelsProps) {\n  const { setSlot } = useChart('BubbleChart.Labels');\n  useEffect(() => {\n    setSlot('labels', { minRadius, format });\n    return () => setSlot('labels', undefined);\n  }, [minRadius, format, setSlot]);\n  return null;\n}\nBubbleChartLabels.displayName = 'BubbleChart.Labels';\n\nexport interface BubbleChartQuadrantsProps {\n  /** Where the vertical rule stands. Defaults to the mean of the x values. */\n  x?: number;\n  /** Where the horizontal rule lies. Defaults to the mean of the y values. */\n  y?: number;\n  /** A word for each corner, written in the corner it belongs to. */\n  labels?: { topLeft?: string; topRight?: string; bottomLeft?: string; bottomRight?: string };\n  /** Tint the high-high and low-low corners. */\n  tint?: boolean;\n  /** Colour of the rules, a theme role or any CSS colour. Defaults to the muted foreground. */\n  color?: string;\n}\n\n/**\n * A crosshair splitting the plot into four, with a name for each corner. It stands at\n * the mean of each axis unless `x` and `y` pin it to a target or a budget.\n */\nfunction BubbleChartQuadrants({\n  x,\n  y,\n  labels,\n  tint = true,\n  color,\n}: BubbleChartQuadrantsProps) {\n  const { setSlot } = useChart('BubbleChart.Quadrants');\n  const stroke = color ? colorOf(color) : 'var(--muted-foreground)';\n  const key = JSON.stringify(labels ?? null);\n  useEffect(() => {\n    setSlot('quadrants', { x, y, labels, tint, stroke });\n    return () => setSlot('quadrants', undefined);\n  }, [x, y, key, tint, stroke, setSlot]);\n  return null;\n}\nBubbleChartQuadrants.displayName = 'BubbleChart.Quadrants';\n\nexport interface BubbleChartSizeKeyProps extends ComponentProps<'div'> {\n  /** Which corner of the plot it sits in. */\n  placement?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';\n  /** Turn a value into its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n  /** A word for what the area means: \"people\", \"revenue\". */\n  label?: string;\n}\n\n/**\n * Three nested circles saying what a bubble's area is worth, because area has no axis\n * to read it off. It needs a `sizeKey` on the chart: without one every bubble is the\n * same size and there is no scale to key.\n */\nfunction BubbleChartSizeKey({\n  placement = 'bottom-right',\n  format,\n  label,\n  className,\n  style,\n  render,\n  ...props\n}: BubbleChartSizeKeyProps & RenderProp<'div'>) {\n  const { sizeExtent, sizeRange, margin, loading } = useChart('BubbleChart.SizeKey');\n  const slots = bubbleChartVariants();\n  const steps = sizeExtent\n    ? // A Set, because every bubble being the same size leaves one step to show.\n      [...new Set([sizeExtent[1], (sizeExtent[0] + sizeExtent[1]) / 2, sizeExtent[0]])].map((value) => ({\n        value,\n        r: bubbleRadius(value, sizeExtent, sizeRange),\n      }))\n    : [];\n  const outer = steps[0]?.r ?? 0;\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'bubble-chart-size-key',\n        'aria-hidden': true,\n        className: slots.sizeKey({ className }),\n        style: {\n          ...style,\n          [placement.startsWith('top') ? 'top' : 'bottom']: placement.startsWith('top') ? margin.top : margin.bottom,\n          [placement.endsWith('left') ? 'left' : 'right']: placement.endsWith('left') ? margin.left : margin.right,\n        },\n        children: (\n          <>\n            {label ? <div className={slots.sizeKeyLabel()}>{label}</div> : null}\n            <svg width={outer * 2 + SIZE_KEY_LABEL_WIDTH} height={outer * 2 + 1} className=\"block overflow-visible\">\n              {steps.map(({ value, r }) => (\n                <g key={value}>\n                  <circle\n                    cx={outer}\n                    cy={outer * 2 - r}\n                    r={r}\n                    fill=\"none\"\n                    stroke=\"var(--muted-foreground)\"\n                    strokeOpacity={0.85}\n                  />\n                  <text x={outer * 2 + 6} y={outer * 2 - r * 2 + 8} fill=\"var(--muted-foreground)\" fontSize={10}>\n                    {format ? format(value) : compactNumber(value)}\n                  </text>\n                </g>\n              ))}\n            </svg>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n  return loading || !steps.length ? null : element;\n}\nBubbleChartSizeKey.displayName = 'BubbleChart.SizeKey';\n\nexport interface BubbleChartXAxisProps {\n  /** Roughly how many labels to show. */\n  ticks?: number;\n  /** Turn a value into its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n  /** The axis's own name: what is being measured. */\n  label?: string;\n}\n\n/** The horizontal labels, on round numbers. */\nfunction BubbleChartXAxis({\n  ticks = 4,\n  format,\n  label,\n}: BubbleChartXAxisProps) {\n  const { setSlot } = useChart('BubbleChart.XAxis');\n  useEffect(() => {\n    setSlot('xAxis', { ticks, format, label });\n    return () => setSlot('xAxis', undefined);\n  }, [ticks, format, label, setSlot]);\n  return null;\n}\nBubbleChartXAxis.displayName = 'BubbleChart.XAxis';\n\nexport interface BubbleChartYAxisProps {\n  /** Roughly how many labels to show. */\n  ticks?: number;\n  /** Turn a value into its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n  /** The axis's own name: what is being measured. */\n  label?: string;\n}\n\n/** The vertical labels, with a gutter made for them. */\nfunction BubbleChartYAxis({\n  ticks = 4,\n  format,\n  label,\n}: BubbleChartYAxisProps) {\n  const { setSlot } = useChart('BubbleChart.YAxis');\n  useEffect(() => {\n    setSlot('yAxis', { ticks, format, label });\n    return () => setSlot('yAxis', undefined);\n  }, [ticks, format, label, setSlot]);\n  return null;\n}\nBubbleChartYAxis.displayName = 'BubbleChart.YAxis';\n\nexport interface BubbleChartTooltipProps {\n  /** Float a small readout beside the bubble. Without it the bubble still swells and reports. */\n  showLabel?: boolean;\n  /** Format the x value for the readout. Defaults to a compact number. */\n  formatX?: (value: number) => string;\n  /** Format the y value for the readout. Defaults to a compact number. */\n  formatY?: (value: number) => string;\n  /** Format the size value for the readout. Defaults to a compact number. */\n  formatSize?: (value: number) => string;\n  /** Prettier names for the x, y and size keys. */\n  labels?: Record<string, string>;\n  /** How far from a bubble's centre the pointer still reads it, at the least, in pixels. */\n  hitRadius?: number;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/**\n * The readout, and what drives it: hovering a bubble, tapping it, dragging across the\n * field, or the arrow keys. A click or a tap pins the reading until the next press\n * elsewhere or Escape.\n */\nfunction BubbleChartTooltip({\n  showLabel = true,\n  formatX,\n  formatY,\n  formatSize,\n  labels,\n  hitRadius = HIT_RADIUS,\n  className,\n}: BubbleChartTooltipProps) {\n  const { setSlot } = useChart('BubbleChart.Tooltip');\n  const latest = useLatest({ formatX, formatY, formatSize, labels });\n  useEffect(() => {\n    setSlot('tooltip', { showLabel, hitRadius, className, latest });\n    return () => setSlot('tooltip', undefined);\n  }, [showLabel, hitRadius, className, latest, setSlot]);\n  return null;\n}\nBubbleChartTooltip.displayName = 'BubbleChart.Tooltip';\n\nexport interface BubbleChartLegendProps extends ComponentProps<'div'> {\n  /** How many rows to list before stopping. */\n  limit?: number;\n}\n\n/** A swatch and a name per row, because in this chart a row is the category. */\nfunction BubbleChartLegend({\n  limit = 8,\n  className,\n  render,\n  ...props\n}: BubbleChartLegendProps & RenderProp<'div'>) {\n  const { rows } = useChart('BubbleChart.Legend');\n  const slots = bubbleChartVariants();\n  const shown = rows.filter((row) => row.label).slice(0, limit);\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'bubble-chart-legend',\n        'aria-hidden': true,\n        className: slots.legend({ className }),\n        children: shown.map((row) => (\n          <span key={row.index} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: row.fill }} />\n            {row.label}\n          </span>\n        )),\n      },\n      props\n    ),\n  });\n  return shown.length ? element : null;\n}\nBubbleChartLegend.displayName = 'BubbleChart.Legend';\n\nexport interface BubbleChartHeaderProps 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: what the area means, usually. */\n  caption?: ReactNode;\n  /** Trailing slot: a control, a badge, a range picker. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the plot. The value is not derived here: a readout that follows the\n * pointer takes it from `onActivePointChange` and passes the string down.\n */\nfunction BubbleChartHeader({\n  title,\n  value,\n  caption,\n  className,\n  children,\n  render,\n  ...props\n}: BubbleChartHeaderProps & RenderProp<'div'>) {\n  const slots = bubbleChartVariants();\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'bubble-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 !== undefined && value !== null ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {children ? <div className={slots.headerTrailing()}>{children}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nBubbleChartHeader.displayName = 'BubbleChart.Header';\n\nexport const BubbleChart = Object.assign(BubbleChartRoot, {\n  Header: BubbleChartHeader,\n  Grid: BubbleChartGrid,\n  Quadrants: BubbleChartQuadrants,\n  Trend: BubbleChartTrend,\n  Bubbles: BubbleChartBubbles,\n  Labels: BubbleChartLabels,\n  SizeKey: BubbleChartSizeKey,\n  Skeleton: BubbleChartSkeleton,\n  XAxis: BubbleChartXAxis,\n  YAxis: BubbleChartYAxis,\n  Tooltip: BubbleChartTooltip,\n  Legend: BubbleChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  BubbleChartHeader,\n  BubbleChartGrid,\n  BubbleChartQuadrants,\n  BubbleChartTrend,\n  BubbleChartBubbles,\n  BubbleChartLabels,\n  BubbleChartSizeKey,\n  BubbleChartSkeleton,\n  BubbleChartXAxis,\n  BubbleChartYAxis,\n  BubbleChartTooltip,\n  BubbleChartLegend,\n};\n"
    },
    {
      "path": "components/panelui/bubble-chart/bubble-scale.ts",
      "type": "registry:component",
      "target": "@components/panelui/bubble-chart/bubble-scale.ts",
      "content": "/**\n * The arithmetic behind BubbleChart: which rows are bubbles, the domains they share,\n * the radius and colour each one takes, and the line that fits them.\n */\n\nexport type BubbleChartDatum = Record<string, string | number | null | undefined>;\n\n/**\n * One drawn thing, carrying the row it came from by index. Every mark in the chart\n * takes this shape. `x2` and `y2` are the far corner of a quadrant's tint.\n */\nexport interface BubbleRow {\n  index: number;\n  x: number;\n  y: number;\n  x2?: number;\n  y2?: number;\n  r: number;\n  /** The value behind the area, when the chart has a size key. */\n  size: number | null;\n  label: string;\n  fill: string;\n}\n\n/** The straight line through a cloud. `r` runs from 0, no direction, to 1, every bubble on it. */\nexport interface BubbleFit {\n  slope: number;\n  intercept: number;\n  r: number;\n}\n\n/** Steps each axis is rounded out to, matching the labels an axis draws by default. */\nconst AXIS_STEPS = 4;\n\nconst PALETTE_SIZE = 5;\n\n/** About the width of a character of the chart's small label type, in pixels. */\nconst LABEL_CHAR_WIDTH = 6.2;\n\nconst ROLES = new Set(['primary', 'info', 'success', 'warning', 'destructive', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']);\n\nconst isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);\n\n/** A theme role by name, or anything else as the CSS colour it already is. */\nexport const colorOf = (color: string) => (ROLES.has(color) ? `var(--${color})` : color);\n\n/**\n * A row's colour: a role or CSS colour by name, a number naming a chart colour from\n * 1 to 5, or the ramp in row order when the row says nothing.\n */\nexport function fillOf(explicit: unknown, index: number): string {\n  if (typeof explicit === 'string') return colorOf(explicit);\n  const step = isNumber(explicit) ? Math.round(explicit) - 1 : index;\n  return `var(--chart-${(((step % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE) + 1})`;\n}\n\n/**\n * An extent widened to round numbers: 1, 2 or 5 times a power of ten, so the ends and\n * the steps between them are numbers a reader can divide in their head. No bubbles\n * gets a usable domain, and identical readings get a width, because a domain of zero\n * width puts every bubble on one edge.\n */\nexport function roundExtent(min: number, max: number): [number, number] {\n  if (min === Infinity) return [0, 1];\n  if (min === max) return [min - 1, max + 1];\n  const rough = (max - min) / AXIS_STEPS;\n  const magnitude = 10 ** Math.floor(Math.log10(rough));\n  const error = rough / magnitude;\n  const step = (error >= Math.sqrt(50) ? 10 : error >= Math.sqrt(10) ? 5 : error >= Math.sqrt(2) ? 2 : 1) * magnitude;\n  return [Math.floor(min / step) * step, Math.ceil(max / step) * step];\n}\n\n/**\n * A value's radius. Its place in the extent sets an area between the range's two ends,\n * and the radius is taken from that, so equal steps in value add equal ink. The range's\n * floor keeps small values visible, so two areas are not in the ratio of their values.\n */\nexport function bubbleRadius(value: number, extent: readonly [number, number], range: readonly [number, number]): number {\n  const [min, max] = extent;\n  const [smallest, largest] = range;\n  const ratio = max === min ? 1 : (value - min) / (max - min);\n  return Math.sqrt(smallest * smallest + ratio * (largest * largest - smallest * smallest));\n}\n\n/**\n * The lowest and highest value behind the areas, or null with nothing to scale. Only\n * rows that are drawn count, so a row with no position cannot flatten the others.\n */\nexport function sizeExtentOf(data: readonly BubbleChartDatum[], keys: { x: string; y: string; size?: string }): [number, number] | null {\n  const sizeKey = keys.size;\n  if (!sizeKey) return null;\n  let min = Infinity;\n  let max = -Infinity;\n  for (const row of data) {\n    const value = row[sizeKey];\n    if (!isNumber(value) || !isNumber(row[keys.x]) || !isNumber(row[keys.y])) continue;\n    if (value < min) min = value;\n    if (value > max) max = value;\n  }\n  return min === Infinity ? null : [min, max];\n}\n\n/**\n * One row per bubble. A row missing either coordinate is left out rather than drawn\n * at the origin. Without a size, a bubble takes the middle of the range.\n */\nexport function bubbleRows(\n  data: readonly BubbleChartDatum[],\n  keys: { x: string; y: string; size?: string; label?: string; color?: string },\n  sizeRange: readonly [number, number]\n): BubbleRow[] {\n  const extent = sizeExtentOf(data, keys);\n  const middle = (sizeRange[0] + sizeRange[1]) / 2;\n  return data.flatMap((datum, index) => {\n    const x = datum[keys.x];\n    const y = datum[keys.y];\n    if (!isNumber(x) || !isNumber(y)) return [];\n    const raw = keys.size ? datum[keys.size] : undefined;\n    const size = isNumber(raw) ? raw : null;\n    return [\n      {\n        index,\n        x,\n        y,\n        size,\n        r: extent && size !== null ? bubbleRadius(size, extent, sizeRange) : middle,\n        label: keys.label ? String(datum[keys.label] ?? '') : '',\n        fill: fillOf(keys.color ? datum[keys.color] : undefined, index),\n      },\n    ];\n  });\n}\n\n/** Both domains, from the bubbles actually drawn. A fixed domain wins over the derived one. */\nexport function bubbleDomains(\n  rows: readonly BubbleRow[],\n  xDomain?: readonly [number, number],\n  yDomain?: readonly [number, number]\n): { x: [number, number]; y: [number, number] } {\n  const xs = rows.map(({ x }) => x);\n  const ys = rows.map(({ y }) => y);\n  return {\n    x: xDomain ? [xDomain[0], xDomain[1]] : roundExtent(Math.min(Infinity, ...xs), Math.max(-Infinity, ...xs)),\n    y: yDomain ? [yDomain[0], yDomain[1]] : roundExtent(Math.min(Infinity, ...ys), Math.max(-Infinity, ...ys)),\n  };\n}\n\n/** Least squares on the raw values. Fewer than two bubbles, or all on one vertical, has no line. */\nexport function fitLine(rows: readonly BubbleRow[]): BubbleFit | null {\n  const n = rows.length;\n  if (n < 2) return null;\n  const meanX = rows.reduce((sum, { x }) => sum + x, 0) / n;\n  const meanY = rows.reduce((sum, { y }) => sum + y, 0) / n;\n  let sxy = 0;\n  let sxx = 0;\n  let syy = 0;\n  for (const { x, y } of rows) {\n    sxy += (x - meanX) * (y - meanY);\n    sxx += (x - meanX) ** 2;\n    syy += (y - meanY) ** 2;\n  }\n  if (sxx === 0) return null;\n  const slope = sxy / sxx;\n  const denominator = Math.sqrt(sxx * syy);\n  return { slope, intercept: meanY - slope * meanX, r: denominator === 0 ? 0 : Math.abs(sxy / denominator) };\n}\n\n/**\n * Where the line enters and leaves the visible box, solved in data space so a line\n * leaving by the top stops where it leaves. Null when it never crosses the box.\n */\nexport function trendSegment(\n  { slope, intercept }: BubbleFit,\n  xDomain: readonly [number, number],\n  yDomain: readonly [number, number]\n): [{ x: number; y: number }, { x: number; y: number }] | null {\n  let from = xDomain[0];\n  let to = xDomain[1];\n  if (slope !== 0) {\n    const atLow = (yDomain[0] - intercept) / slope;\n    const atHigh = (yDomain[1] - intercept) / slope;\n    from = Math.max(from, Math.min(atLow, atHigh));\n    to = Math.min(to, Math.max(atLow, atHigh));\n  } else if (intercept < yDomain[0] || intercept > yDomain[1]) {\n    return null;\n  }\n  if (to < from) return null;\n  return [\n    { x: from, y: intercept + slope * from },\n    { x: to, y: intercept + slope * to },\n  ];\n}\n\n/** Where the quadrants' rules stand: the mean of each axis, unless one is pinned. */\nexport function quadrantCentre(rows: readonly BubbleRow[], x?: number, y?: number): { x: number; y: number } | null {\n  if (!rows.length) return null;\n  return {\n    x: x ?? rows.reduce((sum, row) => sum + row.x, 0) / rows.length,\n    y: y ?? rows.reduce((sum, row) => sum + row.y, 0) / rows.length,\n  };\n}\n\n/** A name cut to fit its circle, because a label running past the edge reads as lying on the plot. */\nexport function fitLabel(label: string, r: number): string {\n  const room = Math.floor((r * 2 - 4) / LABEL_CHAR_WIDTH);\n  if (label.length <= room) return label;\n  // One letter and an ellipsis need two characters of room; with less, no name fits.\n  return room >= 2 ? `${label.slice(0, room - 1)}…` : '';\n}\n\n/**\n * Where the placeholder circles go, as shares of the plot. The two strides stay out of\n * step, because a pair that moves together lays the circles along a diagonal, which\n * reads as a trend in data nobody has yet. The same count always lands the same way.\n */\nexport function skeletonField(count: number): BubbleRow[] {\n  return Array.from({ length: Math.max(0, count) }, (_unused, index) => ({\n    index,\n    x: ((index * 0.7549) % 1) * 0.84 + 0.08,\n    y: ((index * 0.5698) % 1) * 0.84 + 0.08,\n    r: 16 + ((index * 0.472) % 1) * 12,\n    size: null,\n    label: '',\n    fill: '',\n  }));\n}\n"
    }
  ]
}
