{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scatter-chart",
  "type": "registry:component",
  "title": "Scatter Chart",
  "description": "Two measured quantities against each other, to show how they relate.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/scatter-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/scatter-chart/index.tsx",
      "content": "'use client';\n\n/**\n * ScatterChart — two measured quantities against each other, to show how they relate.\n *\n * ```tsx\n * <ScatterChart data={sessions} xDataKey=\"spend\">\n *   <ScatterChart.Grid />\n *   <ScatterChart.Points dataKey=\"revenue\" />\n *   <ScatterChart.XAxis />\n *   <ScatterChart.YAxis />\n *   <ScatterChart.Tooltip />\n * </ScatterChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's ScatterChart, so a chart written\n * for one reads the same in the other. What differs is what the web changes: a\n * colour is named by the theme's role, a point is read by hovering, tapping or\n * dragging, a click pins the reading, and the arrow keys walk the points.\n *\n * ## The parts describe, the root draws\n *\n * `Grid`, `Points`, the axes and `Tooltip` draw nothing themselves. Each one tells\n * the root what it wants, and the root builds one chart definition from them. They\n * are found by context and not by inspecting children, so a part can be wrapped in\n * a component of your own. It also means the chart draws in the browser, in a box\n * its `aspectRatio` has reserved.\n *\n * ## Neither axis is floored at zero\n *\n * The subject is the spread. Forcing readings between 80 and 90 to share a frame\n * with zero squeezes them into a corner, so each domain is the data's own with a\n * little air around it. `xDomain` and `yDomain` fix one instead.\n *\n * ## A point is found by distance\n *\n * Two points can share an x, so there is no column to snap to. The nearest point\n * wins, and only within `hitRadius`, so an empty corner of the plot reads nothing.\n */\nimport { defineChart, dot, type ChartInteractionController, type ChartMotionContext, type ChartPoint } 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  scatterDomains,\n  scatterRows,\n  skeletonField,\n  type ScatterChartDatum,\n  type ScatterRow,\n} from './scatter-scale';\n\nexport type { ScatterChartDatum } from './scatter-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 ScatterChartStatus = 'loading' | 'ready';\n\n/** Which of the theme's roles a series is drawn in. */\nexport type ScatterChartColor =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\n\n/** One plotted point, resolved back to the row it came from. */\nexport interface ScatterChartPoint {\n  /** Index into `data`. */\n  index: number;\n  /** The series key this point belongs to. */\n  dataKey: string;\n  x: number;\n  y: number;\n  datum: ScatterChartDatum;\n}\n\nconst COLOR_VAR: Record<ScatterChartColor, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\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 scatterChartVariants = 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:\n      'pointer-events-none absolute right-2 top-1 col-start-1 row-start-2 flex gap-3 text-xs text-muted-foreground',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\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/** The value-axis gutter held while loading, when there are no labels to size it from. */\nconst LOADING_GUTTER = 44;\n\n/** Room round the plot for the outermost dots, which the axes do not measure. */\nconst EDGE = 14;\n\n/** Margin of a compact chart: enough that a dot on the edge keeps its whole shape. */\nconst COMPACT_EDGE = 4;\n\n/** How much of the reveal goes to staggering the points' start times. */\nconst STAGGER_SHARE = 0.4;\n\n/** Up past its size and back to it: a dot 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 point to swell as it is read, and settle as it is not. */\nconst SELECT_DURATION = 140;\n\n/** How much bigger a point is while it is read. */\nconst SWELL = 1.5;\n\n/** How far from a point the pointer still reads it. A fingertip is wider than a dot. */\nconst HIT_RADIUS = 32;\n\n/** The radii a size key maps onto when a series names none. */\nconst SIZE_RANGE: [number, number] = [3, 14];\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 drawn points in, one after another, all landing inside the one duration.\n * The renderer gives a dot no entrance of its own on a chart's first paint. A scale\n * leaves `r` to the swell, and costs no repaint once it has finished.\n */\nfunction landPoints(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((dot, order) => {\n    dot.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}\n\ninterface SeriesEntry {\n  key: string;\n  fill: string;\n  opacity: number;\n  size: number;\n  sizeKey: string | undefined;\n  sizeRange: readonly [number, number];\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\n * one costs nothing.\n */\ninterface Slots {\n  grid?: { rows: number; columns: number; dashArray: string; opacity: number };\n  xAxis?: { ticks: number; format: ScatterChartXAxisProps['format'] };\n  yAxis?: { ticks: number; format: ScatterChartYAxisProps['format'] };\n  tooltip?: {\n    showLabel: boolean;\n    hitRadius: number;\n    className: string | undefined;\n    latest: { current: Pick<ScatterChartTooltipProps, 'formatX' | 'formatY' | 'formatTitle' | 'labels'> };\n  };\n  skeleton?: { count: number; fill: string };\n}\n\ninterface ScatterChartContextValue {\n  xDataKey: string;\n  series: readonly SeriesEntry[];\n  setSeries: (entry: SeriesEntry) => void;\n  removeSeries: (key: string) => void;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n  activePoint: ScatterChartPoint | null;\n}\n\nconst ScatterChartContext = createContext<ScatterChartContextValue | null>(null);\n\nfunction useChart(component: string): ScatterChartContextValue {\n  const context = useContext(ScatterChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <ScatterChart>`);\n  }\n  return context;\n}\n\n/**\n * The point being read, for something rendered inside the chart. A readout outside\n * it takes `onActivePointChange` instead.\n */\nexport function useScatterChart() {\n  const { activePoint, xDataKey } = useChart('useScatterChart');\n  return { activePoint, xDataKey };\n}\n\nexport interface ScatterChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The rows. Each one is a point, placed by two of its values. */\n  data: readonly ScatterChartDatum[];\n  /** Key holding the x value. Unlike a category label, this must be a number. */\n  xDataKey?: string;\n  /**\n   * `loading` holds the points back and grows them in when it turns `ready`. Add a\n   * `ScatterChart.Skeleton` for something to stand in the plot meanwhile.\n   */\n  status?: ScatterChartStatus;\n  /** The plot's width ÷ height. `1` suits a scatter plot: neither axis is the important one. */\n  aspectRatio?: number;\n  /** Milliseconds for the points to arrive. */\n  animationDuration?: number;\n  /** Milliseconds for the points and the axes to settle after the data changes. */\n  domainDuration?: number;\n  /** Fix the x-axis instead of deriving it from the data. */\n  xDomain?: [number, number];\n  /** Fix the y-axis instead of deriving it from the data. */\n  yDomain?: [number, number];\n  /**\n   * The point being read, and `null` when there is none. Fires when it changes, not per\n   * frame. Reading needs `ScatterChart.Tooltip`; `showLabel={false}` keeps it without the label.\n   */\n  onActivePointChange?: (point: ScatterChartPoint | 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 x, series and size keys and values. */\n  labelForDatum?: (datum: ScatterChartDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the reveal, for a \"replay\" control. */\nexport interface ScatterChartHandle {\n  replay: () => void;\n}\n\nconst ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps & RenderProp<'div'>>(function ScatterChartRoot(\n  {\n    data,\n    xDataKey = 'x',\n    status = 'ready',\n    aspectRatio = 1,\n    animationDuration = 650,\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 = scatterChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [series, setSeriesState] = useState<readonly SeriesEntry[]>([]);\n  const [slots, setSlots] = useState<Slots>({});\n  const [activePoint, setActivePoint] = useState<ScatterChartPoint | null>(null);\n  const [revision, setRevision] = useState(0);\n  const interactionRef = useRef<ChartInteractionController<ScatterRow, 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 setSeries = useCallback(\n    (entry: SeriesEntry) =>\n      setSeriesState((current) =>\n        current.some(({ key }) => key === entry.key)\n          ? current.map((existing) => (existing.key === entry.key ? entry : existing))\n          : [...current, entry]\n      ),\n    []\n  );\n  const removeSeries = useCallback(\n    (key: string) => setSeriesState((current) => current.filter((entry) => entry.key !== key)),\n    []\n  );\n  const setSlot = useCallback<ScatterChartContextValue['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, xAxis, yAxis, tooltip, skeleton } = slots;\n\n  const definition = useMemo(() => {\n    const rows = scatterRows(data, xDataKey, series);\n    const domains = scatterDomains(rows, xDomain, yDomain);\n\n    const points = series.map(({ key, fill, opacity }) =>\n      dot(\n        rows.filter((row) => row.series === key),\n        {\n          id: `scatter-${key}`,\n          key: (row: ScatterRow) => `${row.series}:${row.index}`,\n          x: 'x',\n          y: 'y',\n          z: 'series',\n          r: 'r',\n          fill,\n          fillOpacity: opacity,\n          // A size change alone is easy to miss among neighbours, and an opacity\n          // change alone is invisible wherever points overlap, so it does both.\n          states: [\n            {\n              when: { focus: 'primary' as const },\n              style: { r: ({ datum }: { datum: ScatterRow }) => 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 placeholders = dot(loading && skeleton ? skeletonField(skeleton.count) : [], {\n      id: 'scatter-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 } | undefined,\n      gridCount: number | undefined\n    ) => {\n      if (slot) {\n        return {\n          line: false as const,\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;\n    // Kept even with the label hidden, because a press only pins through a tooltip.\n    const readout =\n      tooltip\n        ? {\n            use: chartTooltip,\n            className: tooltip.showLabel ? tooltip.className : 'hidden!',\n            content(focused: readonly ChartPoint<ScatterRow, number, number>[]) {\n              const { formatX, formatY, formatTitle, labels } = tooltip.latest.current;\n              const point = focused[0];\n              if (!point) return { title: '', rows: [] };\n              const { index, series: key, x, y } = point.datum;\n              const datum = data[index];\n              return {\n                title: datum && formatTitle ? formatTitle(datum) : '',\n                rows: [\n                  { label: labels?.[xDataKey] ?? xDataKey, value: formatX ? formatX(x) : compactNumber(x) },\n                  {\n                    label: labels?.[key] ?? key,\n                    value: formatY ? formatY(y, key) : compactNumber(y),\n                    color: point.color,\n                  },\n                ],\n              };\n            },\n          }\n        : undefined;\n\n    return defineChart({\n      theme: THEME,\n      marks: loading ? [placeholders] : points,\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\n        ? { guides: false as const, margin: COMPACT_EDGE }\n        : {\n            margin: {\n              top: EDGE,\n              right: EDGE,\n              ...(xAxis ? {} : { bottom: EDGE }),\n              ...(yAxis ? (loading ? { left: LOADING_GUTTER } : {}) : { left: EDGE }),\n            },\n          }),\n      focusRing: false,\n      // Only a point 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 `landPoints`.\n      motion: (context: ChartMotionContext<ScatterRow>) =>\n        context.phase === 'enter' && context.role !== 'dot'\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            maxFocusDistance: tooltip.hitRadius,\n            ...(readout ? { tooltip: readout } : {}),\n          }\n        : {}),\n    });\n  }, [\n    data,\n    xDataKey,\n    series,\n    loading,\n    xDomain?.[0],\n    xDomain?.[1],\n    yDomain?.[0],\n    yDomain?.[1],\n    compact,\n    domainDuration,\n    grid,\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 ?? `Scatter chart, ${data.length} ${data.length === 1 ? 'row' : 'rows'}`;\n\n  const context = useMemo<ScatterChartContextValue>(\n    () => ({ xDataKey, series, setSeries, removeSeries, setSlot, activePoint }),\n    [xDataKey, series, setSeries, removeSeries, setSlot, activePoint]\n  );\n\n  // Kept apart from the render, because every active point re-renders the root and a\n  // large chart has thousands of rows to format.\n  const spokenRows = useMemo(() => {\n    const keys = [xDataKey, ...series.flatMap(({ key, sizeKey }) => (sizeKey ? [key, sizeKey] : [key]))];\n    return data.map((datum, index) => (\n      <li key={index}>\n        {labelForDatum?.(datum, index) ??\n          keys\n            .filter((key) => datum[key] !== null && datum[key] !== undefined)\n            .map((key) => `${key}, ${datum[key]}`)\n            .join('. ')}\n      </li>\n    ));\n  }, [data, labelForDatum, series, xDataKey]);\n\n  // The chart also reports a point again when the data is rebuilt under it. Only a\n  // different point, 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: ScatterChartPoint | null) => {\n    const id = point ? `${point.dataKey}:${point.index}:${point.x}:${point.y}:${JSON.stringify(point.datum)}` : '';\n    if (reportedId.current === id) return;\n    reportedId.current = id;\n    setActivePoint(point);\n    onActivePointChange?.(point);\n  };\n  const onFocusChange = (point: ChartPoint<ScatterRow, number, number> | null) => {\n    const datum = point ? data[point.datum.index] : undefined;\n    report(\n      point && datum\n        ? { index: point.datum.index, dataKey: point.datum.series, x: point.datum.x, y: point.datum.y, datum }\n        : null\n    );\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\n   * tap. A sideways drag reads the points it passes, and the last one stays until\n   * the next press, the same as a tap's. A vertical drag never gets here: `pan-y`\n   * gives it to the 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': 'scatter-chart',\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {spokenRows}\n              </ul>\n            )}\n            <div\n              ref={plotRef}\n              data-slot=\"scatter-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 points\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 points 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(() => landPoints(container, animationDuration));\n                  }}\n                />\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <ScatterChartContext.Provider value={context}>{element}</ScatterChartContext.Provider>;\n});\nScatterChartRoot.displayName = 'ScatterChart';\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 ScatterChartGridProps {\n  /** Roughly how many lines to draw across the plot. With a y-axis, the lines follow its `ticks` instead. */\n  rows?: number;\n  /**\n   * Roughly how many lines to draw down it. x is a quantity here, so it earns a grid\n   * of its own. With an x-axis, the lines follow its `ticks` instead.\n   */\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, on the same round numbers as the axes. */\nfunction ScatterChartGrid({\n  rows = 4,\n  columns = 4,\n  dashArray = '4 6',\n  opacity = 1,\n}: ScatterChartGridProps) {\n  const { setSlot } = useChart('ScatterChart.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}\nScatterChartGrid.displayName = 'ScatterChart.Grid';\n\nexport interface ScatterChartPointsProps {\n  /** Key holding this series' y values. */\n  dataKey: string;\n  /** Which of the theme's roles the series is drawn in. */\n  color?: ScatterChartColor;\n  /** Radius of a point, in pixels. With `sizeKey`, the radius of a row with no finite value there. */\n  size?: number;\n  /**\n   * Key holding a third quantity, spaced by each point's area rather than its radius. The\n   * lowest value takes the smallest radius in `sizeRange` and the highest the largest, so\n   * sizes compare rows without giving their exact ratio.\n   */\n  sizeKey?: string;\n  /** Smallest and largest radius `sizeKey` maps onto. */\n  sizeRange?: [number, number];\n  /** Fill opacity. Below 1 by default, so overlapping points read as denser. */\n  opacity?: number;\n}\n\n/** One series, as a field of dots. */\nfunction ScatterChartPoints({\n  dataKey,\n  color = 'chart-1',\n  size = 4.5,\n  sizeKey,\n  sizeRange = SIZE_RANGE,\n  opacity = 0.75,\n}: ScatterChartPointsProps) {\n  const { setSeries, removeSeries } = useChart('ScatterChart.Points');\n  const fill = COLOR_VAR[color];\n  const [smallest, largest] = sizeRange;\n  useEffect(() => {\n    setSeries({ key: dataKey, fill, opacity, size, sizeKey, sizeRange: [smallest, largest] });\n  }, [dataKey, fill, opacity, size, sizeKey, smallest, largest, setSeries]);\n  // Apart from the update above, so a new colour keeps the series' place in the order.\n  useEffect(() => () => removeSeries(dataKey), [dataKey, removeSeries]);\n  return null;\n}\nScatterChartPoints.displayName = 'ScatterChart.Points';\n\nexport interface ScatterChartSkeletonProps {\n  /** How many placeholder dots to scatter. */\n  count?: number;\n  /** Which of the theme's roles to draw them in. Defaults to the muted surface. */\n  color?: ScatterChartColor;\n}\n\n/**\n * The loading state: a still field of muted dots where the data will be. Still,\n * because a shimmer over dots reads as the points moving, and position is what a\n * scatter plot says. The layout is the same on every render.\n */\nfunction ScatterChartSkeleton({\n  count = 24,\n  color,\n}: ScatterChartSkeletonProps) {\n  const { setSlot } = useChart('ScatterChart.Skeleton');\n  const fill = color ? COLOR_VAR[color] : 'var(--muted)';\n  useEffect(() => {\n    setSlot('skeleton', { count, fill });\n    return () => setSlot('skeleton', undefined);\n  }, [count, fill, setSlot]);\n  return null;\n}\nScatterChartSkeleton.displayName = 'ScatterChart.Skeleton';\n\nexport interface ScatterChartXAxisProps {\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}\n\n/** The x labels, on round numbers along a continuous scale. */\nfunction ScatterChartXAxis({\n  ticks = 4,\n  format,\n}: ScatterChartXAxisProps) {\n  const { setSlot } = useChart('ScatterChart.XAxis');\n  useEffect(() => {\n    setSlot('xAxis', { ticks, format });\n    return () => setSlot('xAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nScatterChartXAxis.displayName = 'ScatterChart.XAxis';\n\nexport interface ScatterChartYAxisProps {\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}\n\n/** Value labels down the side, with room made for them. */\nfunction ScatterChartYAxis({\n  ticks = 4,\n  format,\n}: ScatterChartYAxisProps) {\n  const { setSlot } = useChart('ScatterChart.YAxis');\n  useEffect(() => {\n    setSlot('yAxis', { ticks, format });\n    return () => setSlot('yAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nScatterChartYAxis.displayName = 'ScatterChart.YAxis';\n\nexport interface ScatterChartTooltipProps {\n  /** Float a small readout beside the point. Without it the point 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, key: string) => string;\n  /** A heading for the readout, from the row: a name, a label, a category. */\n  formatTitle?: (datum: ScatterChartDatum) => string;\n  /** Prettier names for the x key and the series keys, as the legend takes. */\n  labels?: Record<string, string>;\n  /** How far from a point the pointer still reads it, 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 near a point, tapping it, dragging across\n * the field, or the arrow keys. A click or a tap pins the reading until the next press\n * elsewhere or Escape.\n */\nfunction ScatterChartTooltip({\n  showLabel = true,\n  formatX,\n  formatY,\n  formatTitle,\n  labels,\n  hitRadius = HIT_RADIUS,\n  className,\n}: ScatterChartTooltipProps) {\n  const { setSlot } = useChart('ScatterChart.Tooltip');\n  const latest = useLatest({ formatX, formatY, formatTitle, 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}\nScatterChartTooltip.displayName = 'ScatterChart.Tooltip';\n\nexport interface ScatterChartLegendProps extends ComponentProps<'div'> {\n  /** Prettier names for the series keys. */\n  labels?: Record<string, string>;\n}\n\n/** A swatch and a name per series, in the order the series were declared. */\nfunction ScatterChartLegend({\n  labels,\n  className,\n  render,\n  ...props\n}: ScatterChartLegendProps & RenderProp<'div'>) {\n  const { series } = useChart('ScatterChart.Legend');\n  const slots = scatterChartVariants();\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scatter-chart-legend',\n        'aria-hidden': true,\n        className: slots.legend({ className }),\n        children: series.map(({ key, fill: color }) => (\n          <span key={key} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: color }} />\n            {labels?.[key] ?? key}\n          </span>\n        )),\n      },\n      props\n    ),\n  });\n\n  return series.length ? element : null;\n}\nScatterChartLegend.displayName = 'ScatterChart.Legend';\n\nexport interface ScatterChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a total. */\n  caption?: ReactNode;\n  /** Prettier names for the series keys, as the legend takes. */\n  labels?: Record<string, string>;\n  /**\n   * Draw a swatch and a name per series along the trailing edge. Prefer this to\n   * `ScatterChart.Legend` on a chart that has a header: the legend sits over the plot,\n   * in a corner the points also want.\n   */\n  legend?: boolean;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it reads and what the colours\n * mean. The value is not derived here. A readout that follows the pointer takes it\n * from `onActivePointChange` and passes the string down, so one header can show a\n * summary at rest and a point's values while one is read.\n */\nfunction ScatterChartHeader({\n  title,\n  value,\n  caption,\n  labels,\n  legend = false,\n  className,\n  children,\n  render,\n  ...props\n}: ScatterChartHeaderProps & RenderProp<'div'>) {\n  const { series } = useChart('ScatterChart.Header');\n  const slots = scatterChartVariants();\n  const trailing =\n    children ??\n    (legend && series.length\n      ? series.map(({ key, fill: color }) => (\n          <span key={key} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: color }} />\n            {labels?.[key] ?? key}\n          </span>\n        ))\n      : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scatter-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            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nScatterChartHeader.displayName = 'ScatterChart.Header';\n\nexport const ScatterChart = Object.assign(ScatterChartRoot, {\n  Header: ScatterChartHeader,\n  Grid: ScatterChartGrid,\n  Points: ScatterChartPoints,\n  Skeleton: ScatterChartSkeleton,\n  XAxis: ScatterChartXAxis,\n  YAxis: ScatterChartYAxis,\n  Tooltip: ScatterChartTooltip,\n  Legend: ScatterChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  ScatterChartHeader,\n  ScatterChartGrid,\n  ScatterChartPoints,\n  ScatterChartSkeleton,\n  ScatterChartXAxis,\n  ScatterChartYAxis,\n  ScatterChartTooltip,\n  ScatterChartLegend,\n};\n"
    },
    {
      "path": "components/panelui/scatter-chart/scatter-scale.ts",
      "type": "registry:component",
      "target": "@components/panelui/scatter-chart/scatter-scale.ts",
      "content": "/**\n * The arithmetic behind ScatterChart: which rows are points, the two domains they\n * share, and the radius a third quantity gives each one.\n */\n\nexport type ScatterChartDatum = Record<string, string | number | null | undefined>;\n\n/** One drawn dot, carrying the row it came from by index. */\nexport interface ScatterRow {\n  index: number;\n  series: string;\n  x: number;\n  y: number;\n  r: number;\n}\n\n/** What a series asks for, as far as the scale is concerned. */\nexport interface ScatterSeriesScale {\n  key: string;\n  size: number;\n  sizeKey: string | undefined;\n  sizeRange: readonly [number, number];\n}\n\nconst isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);\n\n/**\n * A domain with some air around it. No points gets a usable domain, and identical\n * readings get a width, because a domain of zero width puts every point on one edge.\n */\nexport function padExtent(min: number, max: number): [number, number] {\n  if (min === Infinity) return [0, 1];\n  if (min === max) return [min - 1, max + 1];\n  const pad = (max - min) * 0.08;\n  return [min - pad, max + pad];\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 radiusFor(\n  value: unknown,\n  extent: readonly [number, number] | null,\n  range: readonly [number, number],\n  fallback: number\n): number {\n  if (!extent || !isNumber(value)) return fallback;\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/** The lowest and highest of one key, over every row, so a size means the same on all of them. */\nfunction sizeExtent(data: readonly ScatterChartDatum[], key: string | undefined): [number, number] | null {\n  if (!key) return null;\n  let min = Infinity;\n  let max = -Infinity;\n  for (const row of data) {\n    const value = row[key];\n    if (!isNumber(value)) 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 dot, in series order. A row missing either reading is not a point:\n * it is left out rather than drawn at the origin.\n */\nexport function scatterRows(\n  data: readonly ScatterChartDatum[],\n  xDataKey: string,\n  series: readonly ScatterSeriesScale[]\n): ScatterRow[] {\n  return series.flatMap(({ key, size, sizeKey, sizeRange }) => {\n    const extent = sizeExtent(data, sizeKey);\n    return data.flatMap((datum, index) => {\n      const x = datum[xDataKey];\n      const y = datum[key];\n      if (!isNumber(x) || !isNumber(y)) return [];\n      return [{ index, series: key, x, y, r: radiusFor(sizeKey ? datum[sizeKey] : undefined, extent, sizeRange, size) }];\n    });\n  });\n}\n\n/** Both domains, from the dots actually drawn. A fixed domain wins over the derived one. */\nexport function scatterDomains(\n  rows: readonly ScatterRow[],\n  xDomain?: readonly [number, number],\n  yDomain?: readonly [number, number]\n): { x: [number, number]; y: [number, number] } {\n  let lowX = Infinity;\n  let highX = -Infinity;\n  let lowY = Infinity;\n  let highY = -Infinity;\n  for (const { x, y } of rows) {\n    if (x < lowX) lowX = x;\n    if (x > highX) highX = x;\n    if (y < lowY) lowY = y;\n    if (y > highY) highY = y;\n  }\n  return {\n    x: xDomain ? [xDomain[0], xDomain[1]] : padExtent(lowX, highX),\n    y: yDomain ? [yDomain[0], yDomain[1]] : padExtent(lowY, highY),\n  };\n}\n\n/**\n * Where the placeholder dots go, as shares of the plot. The two strides are chosen to\n * stay out of step: a pair that moves together lays the dots 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): ScatterRow[] {\n  return Array.from({ length: Math.max(0, count) }, (_unused, index) => ({\n    index,\n    series: '',\n    x: ((index * 0.7549) % 1) * 0.92 + 0.04,\n    y: ((index * 0.5698) % 1) * 0.92 + 0.04,\n    r: 4.5,\n  }));\n}\n"
    }
  ]
}
