{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-scatter",
  "title": "Chart Scatter",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A scatter or bubble chart with grouped colors, point sizes from a third number and titled axes, in a frame with a toggling legend, a table of every point with CSV download, and states.",
  "dependencies": [
    "recharts@^3"
  ],
  "registryDependencies": [
    "https://ui.ballmac.com/r/chart.json",
    "https://ui.ballmac.com/r/chart-frame.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/chart-scatter.tsx",
      "content": "// Ballmac UI: Chart Scatter. https://ui.ballmac.com/components/chart-scatter\n\"use client\"\n\nimport * as React from \"react\"\nimport { CartesianGrid, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from \"recharts\"\n\nimport { ChartTooltip } from \"@/components/ballmac/chart\"\nimport { ChartFrame, domainTicks, niceScale, type ChartFrameProps, type ChartRow, type ChartSeries } from \"@/components/ballmac/chart-frame\"\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\"\n\ntype ChartScatterProps = Omit<ChartFrameProps, \"children\" | \"series\" | \"xKey\" | \"data\" | \"summaryMode\" | \"skeleton\" | \"table\"> & {\n  /** One row per point: a number under `xKey` and one under `yKey`, optionally a size, a group and a name. */\n  data: ChartRow[]\n  xKey: string\n  yKey: string\n  /** The key of a number that sets each point's size, which turns the scatter into a bubble chart. */\n  zKey?: string\n  /** The key that says which group a point belongs to. Each group gets a color and a legend item. */\n  groupKey?: string\n  /** Names and colors for the groups. Without it every point is one group. */\n  groups?: ChartSeries[]\n  /** The key of each point's name, shown in the tooltip and the table. */\n  nameKey?: string\n  /** Axis titles, also used in the tooltip and the table. */\n  xLabel: string\n  yLabel: string\n  zLabel?: string\n  xFormatter?: (value: number) => string\n  yFormatter?: (value: number) => string\n  xDomain?: [number | \"auto\", number | \"auto\"]\n  yDomain?: [number | \"auto\", number | \"auto\"]\n  /** Smallest and largest point size in pixels, when `zKey` is set. */\n  sizeRange?: [number, number]\n  grid?: \"both\" | \"horizontal\" | \"none\"\n  tooltip?: boolean\n}\n\n/**\n * A scatter or bubble chart in a ChartFrame: grouped points with colors, sizes from a third number, titled axes, a legend that switches groups off,\n * a table of every point with a CSV download, a written summary of the ranges, and loading, empty and error states.\n */\nfunction ChartScatter({\n  data,\n  xKey,\n  yKey,\n  zKey,\n  groupKey,\n  groups,\n  nameKey,\n  xLabel,\n  yLabel,\n  zLabel,\n  xFormatter,\n  yFormatter,\n  xDomain,\n  yDomain,\n  sizeRange = [80, 520],\n  grid = \"both\",\n  tooltip = true,\n  categoryLabel,\n  summary,\n  aspect = \"video\",\n  ...frame\n}: ChartScatterProps) {\n  const msg = useMessages()\n  const locale = useLocale()\n  const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const items: ChartSeries[] = groups ?? [{ key: \"all\", label: yLabel }]\n  const number = (v: unknown) => (typeof v === \"number\" && Number.isFinite(v) ? v : null)\n  const rangeOf = (key: string) => {\n    const values = data.map((r) => number(r[key])).filter((v): v is number => v !== null)\n    return values.length ? { min: Math.min(...values), max: Math.max(...values) } : null\n  }\n  const xr = rangeOf(xKey)\n  const yr = rangeOf(yKey)\n  const written =\n    summary ??\n    (data.length && xr && yr\n      ? msg(\"chart-scatter.summary\", { one: \"{count} point. {x} from {xMin} to {xMax}; {y} from {yMin} to {yMax}.\", other: \"{count} points. {x} from {xMin} to {xMax}; {y} from {yMin} to {yMax}.\" }, { count: data.length, x: xLabel, xMin: nf.format(xr.min), xMax: nf.format(xr.max), y: yLabel, yMin: nf.format(yr.min), yMax: nf.format(yr.max) })\n      : \"\")\n  const writeX = (v: unknown) => (number(v) === null ? \"–\" : xFormatter ? xFormatter(v as number) : nf.format(v as number))\n  const writeY = (v: unknown) => (number(v) === null ? \"–\" : yFormatter ? yFormatter(v as number) : nf.format(v as number))\n\n  return (\n    <ChartFrame\n      data={data}\n      xKey={nameKey ?? xKey}\n      series={items}\n      summary={written}\n      aspect={aspect}\n      skeleton=\"area\"\n      legend={groups ? \"toggle\" : \"none\"}\n      categoryLabel={categoryLabel}\n      table={{\n        columns: [\n          ...(nameKey ? [{ key: nameKey, label: categoryLabel ?? msg(\"chart-scatter.name\", \"Name\") }] : []),\n          ...(groupKey && groups ? [{ key: groupKey, label: msg(\"chart-scatter.group\", \"Group\"), format: (v: unknown) => groups.find((g) => g.key === String(v))?.label ?? String(v) }] : []),\n          { key: xKey, label: xLabel, numeric: true, format: writeX },\n          { key: yKey, label: yLabel, numeric: true, format: writeY },\n          ...(zKey ? [{ key: zKey, label: zLabel ?? zKey, numeric: true, format: (v: unknown) => (number(v) === null ? \"–\" : nf.format(v as number)) }] : []),\n        ],\n        rows: data,\n      }}\n      {...frame}\n    >\n      {(chart) => {\n        const visibleKeys = groups ? chart.visible : [\"all\"]\n        const rows = groupKey && groups ? data.filter((r) => visibleKeys.includes(String(r[groupKey]))) : data\n        const xs = xDomain ? undefined : niceScale({ data: rows, keys: [xKey], zero: false })\n        const ys = yDomain ? undefined : niceScale({ data: rows, keys: [yKey], zero: false })\n        return (\n          <ScatterChart margin={chart.margin({ top: 8, right: 16, bottom: 18, left: 8 })} accessibilityLayer>\n            <defs>{chart.defs}</defs>\n            <CartesianGrid vertical={grid === \"both\"} horizontal={grid !== \"none\"} />\n            <XAxis type=\"number\" dataKey={xKey} name={xLabel} reversed={chart.rtl} tickLine={false} axisLine={false} tickMargin={8} domain={xDomain ?? xs?.domain} ticks={xs?.ticks ?? domainTicks(xDomain)} tickFormatter={(v: number) => (xFormatter ? xFormatter(v) : nf.format(v))} label={{ value: xLabel, position: \"insideBottom\", offset: -14, fill: \"var(--muted-foreground)\", fontSize: 12 }} />\n            <YAxis type=\"number\" dataKey={yKey} name={yLabel} width=\"auto\" orientation={chart.rtl ? \"right\" : \"left\"} tickLine={false} axisLine={false} tickMargin={8} domain={yDomain ?? ys?.domain} ticks={ys?.ticks ?? domainTicks(yDomain)} tickFormatter={(v: number) => (yFormatter ? yFormatter(v) : nf.format(v))} label={{ value: yLabel, angle: chart.rtl ? 90 : -90, position: chart.rtl ? \"insideRight\" : \"insideLeft\", offset: 4, style: { textAnchor: \"middle\" }, fill: \"var(--muted-foreground)\", fontSize: 12 }} />\n            {zKey ? <ZAxis type=\"number\" dataKey={zKey} name={zLabel ?? zKey} range={sizeRange} /> : <ZAxis range={[90, 90]} />}\n            {tooltip ? (\n              <ChartTooltip\n                cursor={{ strokeDasharray: \"3 3\" }}\n                content={({ active, payload }) => {\n                  const row = active ? (payload?.[0]?.payload as ChartRow | undefined) : undefined\n                  if (!row) return null\n                  const group = groupKey && groups ? groups.find((g) => g.key === String(row[groupKey])) : undefined\n                  return (\n                    <div className=\"grid min-w-36 gap-1.5 rounded-lg border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-[0_12px_36px_-10px_rgb(0_0_0/0.25)]\">\n                      {nameKey || group ? <p className=\"font-medium\">{nameKey ? String(row[nameKey]) : group?.label}</p> : null}\n                      {nameKey && group ? <p className=\"text-muted-foreground\">{group.label}</p> : null}\n                      <dl className=\"grid gap-1\">\n                        {[\n                          [xLabel, writeX(row[xKey])],\n                          [yLabel, writeY(row[yKey])],\n                          ...(zKey ? [[zLabel ?? zKey, number(row[zKey]) === null ? \"–\" : nf.format(row[zKey] as number)]] : []),\n                        ].map(([k, v]) => (\n                          <div key={k} className=\"flex items-center justify-between gap-4\">\n                            <dt className=\"text-muted-foreground\">{k}</dt>\n                            <dd className=\"font-mono font-medium tabular-nums\">{v}</dd>\n                          </div>\n                        ))}\n                      </dl>\n                    </div>\n                  )\n                }}\n              />\n            ) : null}\n            {items.map((g) => (\n              <Scatter\n                key={g.key}\n                name={g.label}\n                data={groupKey && groups ? data.filter((r) => String(r[groupKey]) === g.key) : data}\n                hide={chart.isHidden(g.key)}\n                fill={chart.patterns ? chart.fill(g.key) : chart.color(g.key)}\n                fillOpacity={zKey ? 0.7 : 0.9}\n                stroke={chart.color(g.key)}\n                strokeWidth={chart.patterns || zKey ? 1.5 : 0}\n                isAnimationActive={chart.animate}\n                animationDuration={600}\n              />\n            ))}\n          </ScatterChart>\n        )\n      }}\n    </ChartFrame>\n  )\n}\n\nexport { ChartScatter, type ChartScatterProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/chart-scatter.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "chart",
      "scatter",
      "bubble",
      "correlation",
      "distribution",
      "recharts",
      "graph"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "ai": {
      "summary": "<ChartScatter title description data xKey yKey xLabel yLabel /> with optional zKey (point size), groupKey plus groups ([{ key, label, color? }]), nameKey, xDomain, yDomain, sizeRange, formatters, legend, patterns, state. Every row is one point.",
      "whenToUse": [
        "Relationships between two numbers",
        "Distributions and outliers",
        "Bubble charts with a third measure"
      ],
      "whenNotToUse": [
        "Trends over time (use chart-line)",
        "Categories with one value each (use chart-bar)"
      ],
      "composesWith": [
        "chart-frame"
      ],
      "a11y": [
        {
          "keys": "Arrow keys",
          "action": "Move between points (accessibilityLayer)"
        },
        {
          "keys": "Tab, Enter / Space",
          "action": "Legend buttons switch series off and on; the Chart / Table switch shows the numbers as a table"
        },
        {
          "keys": "Screen readers",
          "action": "The figure has a name and a summary written from the data"
        },
        {
          "keys": "Table",
          "action": "Every point, with its name, group and values, in a table you can download as CSV"
        }
      ],
      "customization": [
        "groups and colors",
        "zKey and sizeRange for bubbles",
        "xDomain and yDomain"
      ]
    },
    "examples": [
      "chart-scatter-demo",
      "chart-scatter-groups",
      "chart-scatter-bubble"
    ],
    "url": "https://ui.ballmac.com/components/chart-scatter"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}