# ScatterChart

Two measures against each other, one mark per observation.

- Group: Charts
- Import: `import { ScatterChart } from '@misoto22/folio/charts'`
- Page: https://ui.misoto22.com/components/scatter-chart/
- Related: line-chart, heatmap

## When to reach for it

Correlation, clustering, outliers — the questions that do not survive being bucketed into a bar. The only chart here whose x axis is a number rather than a category.

## Anatomy

- **Figure frame** (required) — ChartFigure’s <figure>, with isLoading and an empty state. Emptiness is read from the declared table rows, for the same reason the table is declared at all: the observations live on each <Scatter>, and the root cannot see them.
- **Numeric axes** (required) — <ScatterChart.XAxis> and <ScatterChart.YAxis>, both type="number" by default. The only chart in the group whose horizontal axis is a measurement rather than a category.
- **Clouds** (required) — <ScatterChart.Scatter>, one per series, each carrying its OWN data array rather than reading the root’s. Six mark shapes, and a solid mark takes a 1px --chart-surface ring so two coincident observations stay countable.
- **Size channel** — <ScatterChart.ZAxis>, range [40, 400] by default. That range is in AREA, not radius: doubling a radius quadruples the ink, which is how a bubble chart usually lies.
- **Crosshair** — The tooltip’s cursor, a pair of rules rather than one band. A scatter point is located by two coordinates and a single vertical cursor answers half of that.
- **Declared table** — table is a PROP here, not inferred. Scatter data lives on each series, so there are no rows on the root to read off — and passing nothing ships a figure with no table at all.

## Best practices

### Do

- Declare table. It is the one chart in the group whose hidden table cannot be derived, so omitting it fails silently: the figure renders, is named, and has no numbers behind it.
- Separate series by shape before anything else. Circle against cross stays legible where two steps of grey do not, and shape survives overprinting and forced colours — where every --series-* token becomes CanvasText and a lightness step is gone.
- Reach for variant="outline" or shape="ring" on a dense cloud. A hollow mark shows what is under it; a solid one at two thousand points is a silhouette of the densest region and nothing else.

### Don’t

- Do not vary size between series to mean something. size is a flat radius in pixels, so it encodes nothing while looking exactly as if it does; <ScatterChart.ZAxis> is the only path that maps a value to a mark’s area.
- Past three series shape stops separating them — circle, cross and triangle are distinct, and a fourth glyph is a diamond most readers see as a rotated square. Small multiples on shared axes is the answer, not a fourth mark.

## Accessibility

- title is required. The table view is declared rather than inferred: scatter data lives on each series, so there is no single set of rows to read off the root.
- Shape does the work hue does elsewhere. Two overlapping clouds separate far better by circle-versus-cross than by two steps of grey — and shape survives overprinting, which a lightness step does not.
- A solid mark carries a surface-coloured ring, so two observations that land on top of each other stay countable.
- ZAxis maps its measure to a mark’s AREA, not its radius: doubling a radius quadruples the ink, which is the most common way a bubble chart lies.

## ScatterChart

Two measures against each other, one mark per observation — the shape for "is there a relationship here". The only chart in the set whose x axis is a NUMBER rather than a category, which is the whole point: a scatter answers correlation, clustering and outliers, and none of those questions survive being bucketed into a bar. Past three series, shape stops separating them and the answer is small multiples — one chart per series, same axes — rather than a fourth mark.

### Props

- `config` (required) — `ChartConfig`. Series keys → their label and paint. Declaration order is ramp order.
- `title` (required) — `string`. What the chart shows, in a sentence a reader could act on. Required, and announced to a screen reader even when it is not printed.
- `showTitle` — `boolean`. Prints the title above the plot instead of hiding it from sight.
- `description` — `ReactNode`. A line under the title — the unit, the window, the caveat.
- `children` (required) — `ReactNode`. The composed parts — axes, grid, tooltip, legend, and the marks themselves.
- `className` — `string`. Merged onto the figure, last, so a call site can size or space it.
- `chartProps` — `ComponentProps<typeof RechartsScatterChart>`. Escape hatch onto the raw Recharts chart element.
- `defaultSelectedDataKey` — `string | null` default `null`. The series lit on first render, when the chart keeps its own selection.
- `selectedDataKey` — `string | null`. The selected series, driven from outside. Give this and the chart follows it; leave it undefined and the chart keeps its own, starting from `defaultSelectedDataKey`.
- `onSelectionChange` — `(selectedDataKey: string | null) => void`. Fires when the selection changes, and with null when it is cleared.
- `table` — `ScatterTable | false`. The rows behind the hidden table view, with the fields to print. Scatter data lives on each `<Scatter>` rather than on the root, so unlike every other chart here the table cannot be inferred — it is declared.
- `isLoading` — `boolean` default `false`. Swaps the marks for an animated skeleton, keeping the measured height so the page does not jump when the data lands.
- `empty` — `ChartEmptyProps | false`. What the chart shows when it has nothing to draw. `false` keeps the empty plot, for a chart whose emptiness is itself the reading. Read from the declared `table` rows, for the same reason the table is declared: the observations live on each `<Scatter>`, and the root cannot see them.

## Example — default

```tsx
import { ScatterChart, type ChartConfig } from '@misoto22/folio/charts'

<ScatterChart
  title="Load time against bundle size"
  config={config}
  table={{
    rows: desktop,
    rowKey: 'kb',
    columns: [{ key: 'ms', label: 'Load (ms)' }],
  }}
>
  <ScatterChart.Grid />
  <ScatterChart.XAxis dataKey="kb" name="Bundle" unit=" kB" />
  <ScatterChart.YAxis dataKey="ms" name="Load" unit=" ms" />
  <ScatterChart.Tooltip />
  <ScatterChart.Scatter dataKey="desktop" data={desktop} />
</ScatterChart>
```

## Example — shape and variant

```tsx
import { ToggleGroup, ToggleGroupItem } from '@misoto22/folio'

<div className="flex w-full flex-col gap-4">
  <div className="flex flex-wrap gap-3">
    <ToggleGroup
      type="single"
      value={shape}
      onValueChange={(next) => next && setShape(next as ScatterShape)}
      aria-label="Mark shape"
    >
      {SHAPES.map((option) => (
        <ToggleGroupItem key={option} value={option}>
          {option}
        </ToggleGroupItem>
      ))}
    </ToggleGroup>

    <ToggleGroup
      type="single"
      value={variant}
      onValueChange={(next) => next && setVariant(next as ScatterVariant)}
      aria-label="Mark fill"
    >
      {VARIANTS.map((option) => (
        <ToggleGroupItem key={option} value={option}>
          {option}
        </ToggleGroupItem>
      ))}
    </ToggleGroup>
  </div>

  <ScatterChart
    title="Load time against bundle size"
    config={config}
    table={{
      rows: [...desktop, ...mobile],
      rowKey: 'kb',
      columns: [{ key: 'ms', label: 'Load (ms)' }],
    }}
  >
    <ScatterChart.Grid />
    <ScatterChart.XAxis dataKey="kb" name="Bundle" unit=" kB" />
    <ScatterChart.YAxis dataKey="ms" name="Load" unit=" ms" />
    <ScatterChart.Legend isClickable />
    <ScatterChart.Tooltip />
    <ScatterChart.Scatter
      dataKey="desktop"
      data={desktop}
      shape={shape}
      variant={variant}
      isClickable
    />
    <ScatterChart.Scatter dataKey="mobile" data={mobile} shape="cross" isClickable />
  </ScatterChart>
</div>
```

## Example — bubbles

```tsx
import { ScatterChart, type ChartConfig } from '@misoto22/folio/charts'

<ScatterChart
  title="Load time, bundle size and downloads"
  config={config}
  table={{
    rows: releases,
    rowKey: 'kb',
    columns: [
      { key: 'ms', label: 'Load (ms)' },
      { key: 'downloads', label: 'Downloads' },
    ],
  }}
>
  <ScatterChart.Grid />
  <ScatterChart.XAxis dataKey="kb" name="Bundle" unit=" kB" />
  <ScatterChart.YAxis dataKey="ms" name="Load" unit=" ms" />
  <ScatterChart.ZAxis dataKey="downloads" name="Downloads" range={[60, 900]} />
  <ScatterChart.Tooltip />
  <ScatterChart.Scatter dataKey="releases" data={releases} variant="outline" />
</ScatterChart>
```
