{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scatter-chart",
  "title": "Scatter Chart",
  "description": "Correlation dots per series that become bubbles when a size key is set, with a per-point tooltip and legend.",
  "dependencies": [
    "recharts"
  ],
  "registryDependencies": [
    "chart"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/ui/scatter-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CartesianGrid,\n  Scatter,\n  ScatterChart as RechartsScatterChart,\n  XAxis,\n  YAxis,\n  ZAxis,\n} from \"recharts\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  ChartContainer,\n  ChartLegend,\n  ChartLegendContent,\n  ChartTooltip,\n  ChartTooltipContent,\n  type ChartConfig,\n} from \"@/components/ui/chart\"\n\nexport interface ScatterSeries {\n  /** Stable identifier. Defaults to a slug of the label. */\n  key?: string\n  label: string\n  /** Rows for this series. Each needs the x and y keys, plus the size and name keys when set. */\n  data: Record<string, unknown>[]\n  /** Any CSS color. Defaults to chart-1 through chart-5 in order. */\n  color?: string\n}\n\nexport interface ScatterChartProps\n  extends Omit<React.ComponentProps<typeof ChartContainer>, \"config\" | \"children\"> {\n  series: ScatterSeries[]\n  /** Key of the x value in each row. */\n  xKey: string\n  /** Key of the y value in each row. */\n  yKey: string\n  /** Key of a value that scales each point into a bubble. */\n  sizeKey?: string\n  /** Key of a per-point label shown as the tooltip title. */\n  nameKey?: string\n  /** Names for the tooltip rows. Default to the keys. */\n  xLabel?: string\n  yLabel?: string\n  sizeLabel?: string\n  showGrid?: boolean\n  showXAxis?: boolean\n  showYAxis?: boolean\n  showLegend?: boolean\n  showTooltip?: boolean\n  xFormatter?: (value: number) => string\n  yFormatter?: (value: number) => string\n  sizeFormatter?: (value: number) => string\n  /** Radius of each point in px when sizeKey is not set. */\n  dotRadius?: number\n  /** Bubble area range in px² when sizeKey is set, smallest to largest. */\n  sizeRange?: [number, number]\n  /** Recharts domain for the x-axis, e.g. [\"auto\", \"auto\"]. Defaults to [0, \"auto\"]. */\n  xDomain?: React.ComponentProps<typeof XAxis>[\"domain\"]\n  /** Recharts domain for the y-axis, e.g. [\"auto\", \"auto\"]. Defaults to [0, \"auto\"]. */\n  yDomain?: React.ComponentProps<typeof YAxis>[\"domain\"]\n  /** Animate points after hydration and when data changes. Respects reduced motion. */\n  animate?: boolean\n  animationDuration?: number\n}\n\nconst reducedMotionQuery = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeToReducedMotion(onChange: () => void) {\n  const query = window.matchMedia(reducedMotionQuery)\n  query.addEventListener(\"change\", onChange)\n  return () => query.removeEventListener(\"change\", onChange)\n}\n\nfunction getReducedMotionSnapshot() {\n  return window.matchMedia(reducedMotionQuery).matches\n}\n\nfunction getReducedMotionServerSnapshot() {\n  return true\n}\n\n/** Hidden key stamped onto each row so the tooltip can find the point's series. */\nconst SERIES_KEY = \"__scatterSeries\"\n\nfunction slug(label: string) {\n  return label.toLowerCase().replace(/[^a-z0-9]+/g, \"-\")\n}\n\ninterface ScatterTooltipContentProps\n  extends Pick<React.ComponentProps<typeof ChartTooltipContent>, \"active\" | \"payload\"> {\n  config: ChartConfig\n  rows: { key: string; label: string; format: (value: number) => string }[]\n  nameKey?: string\n}\n\nfunction ScatterTooltipContent({\n  active,\n  payload,\n  config,\n  rows,\n  nameKey,\n}: ScatterTooltipContentProps) {\n  const point = payload?.[0]?.payload as Record<string, unknown> | undefined\n  if (!active || !point) return null\n  const seriesKey = String(point[SERIES_KEY] ?? \"\")\n  const seriesLabel = config[seriesKey]?.label ?? seriesKey\n  const name = nameKey ? point[nameKey] : undefined\n  const title = name != null ? String(name) : seriesLabel\n\n  return (\n    <div className=\"grid min-w-[8rem] gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl\">\n      <div className=\"flex items-center gap-2 font-medium\">\n        <span\n          className=\"size-2 shrink-0 rounded-[2px]\"\n          style={{ backgroundColor: `var(--color-${seriesKey})` }}\n        />\n        <span className=\"flex-1\">{title}</span>\n        {name != null ? (\n          <span className=\"text-muted-foreground font-normal\">{seriesLabel}</span>\n        ) : null}\n      </div>\n      <div className=\"grid gap-1\">\n        {rows.map((row) => (\n          <div key={row.key} className=\"flex items-center justify-between gap-4\">\n            <span className=\"text-muted-foreground\">{row.label}</span>\n            <span className=\"font-mono font-medium tabular-nums\">\n              {row.format(Number(point[row.key]))}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/** A scatter chart on shadcn's chart primitives: correlation dots per series, or bubbles when a size key is set. */\nfunction ScatterChart({\n  series,\n  xKey,\n  yKey,\n  sizeKey,\n  nameKey,\n  xLabel,\n  yLabel,\n  sizeLabel,\n  showGrid = true,\n  showXAxis = true,\n  showYAxis = true,\n  showLegend = false,\n  showTooltip = true,\n  xFormatter = (value) => formatNumber(value, { format: \"compact\" }),\n  yFormatter = (value) => formatNumber(value, { format: \"compact\" }),\n  sizeFormatter = (value) => formatNumber(value, { format: \"compact\" }),\n  dotRadius = 4,\n  sizeRange = [40, 400],\n  xDomain,\n  yDomain,\n  animate = true,\n  animationDuration = 500,\n  className,\n  ...props\n}: ScatterChartProps) {\n  const reducedMotion = React.useSyncExternalStore(\n    subscribeToReducedMotion,\n    getReducedMotionSnapshot,\n    getReducedMotionServerSnapshot\n  )\n  const resolved = React.useMemo(\n    () =>\n      series.map((s, index) => {\n        const key = s.key ?? slug(s.label)\n        return {\n          key,\n          label: s.label,\n          color: s.color ?? `var(--chart-${(index % 5) + 1})`,\n          data: s.data.map((row) => ({ ...row, [SERIES_KEY]: key })),\n        }\n      }),\n    [series]\n  )\n  const config = Object.fromEntries(\n    resolved.map((s) => [s.key, { label: s.label, color: s.color }])\n  ) satisfies ChartConfig\n  const rows = [\n    { key: xKey, label: xLabel ?? xKey, format: xFormatter },\n    { key: yKey, label: yLabel ?? yKey, format: yFormatter },\n    ...(sizeKey ? [{ key: sizeKey, label: sizeLabel ?? sizeKey, format: sizeFormatter }] : []),\n  ]\n  // Recharts sizes symbols by area, so a fixed radius becomes a fixed area.\n  const dotArea = Math.round(Math.PI * dotRadius * dotRadius)\n\n  return (\n    <ChartContainer\n      data-slot=\"scatter-chart\"\n      config={config}\n      className={cn(\"aspect-auto h-64 w-full\", className)}\n      {...props}\n    >\n      <RechartsScatterChart margin={{ top: 12, right: 12, bottom: 0, left: showYAxis ? 0 : 8 }}>\n        {showGrid ? <CartesianGrid /> : null}\n        <XAxis\n          type=\"number\"\n          dataKey={xKey}\n          hide={!showXAxis}\n          domain={xDomain}\n          tickLine={false}\n          axisLine={false}\n          tickMargin={8}\n          tickFormatter={xFormatter}\n        />\n        <YAxis\n          type=\"number\"\n          dataKey={yKey}\n          hide={!showYAxis}\n          domain={yDomain}\n          tickLine={false}\n          axisLine={false}\n          tickMargin={8}\n          width=\"auto\"\n          tickFormatter={yFormatter}\n        />\n        {sizeKey ? (\n          <ZAxis type=\"number\" dataKey={sizeKey} range={sizeRange} />\n        ) : (\n          <ZAxis type=\"number\" range={[dotArea, dotArea]} />\n        )}\n        {showTooltip ? (\n          <ChartTooltip\n            cursor={false}\n            content={<ScatterTooltipContent config={config} rows={rows} nameKey={nameKey} />}\n          />\n        ) : null}\n        {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}\n        {resolved.map((s) => {\n          const color = `var(--color-${s.key})`\n          return (\n            <Scatter\n              key={s.key}\n              name={s.key}\n              data={s.data}\n              fill={color}\n              fillOpacity={0.35}\n              stroke={color}\n              strokeWidth={1.5}\n              isAnimationActive={animate && !reducedMotion}\n              animationDuration={animationDuration}\n              animationEasing=\"ease-out\"\n            />\n          )\n        })}\n      </RechartsScatterChart>\n    </ChartContainer>\n  )\n}\n\nexport { ScatterChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}