> ## Documentation Index
> Fetch the complete documentation index at: https://docs.graphy.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Dumbbell

export const GraphEmbed = ({src, story, aspectRatio, borderRadius = '14px', args}) => {
  const STORYBOOK_BASE_URL = typeof window !== 'undefined' && window.location.hostname === 'localhost' ? 'http://localhost:6006' : 'https://storybook-sdk.vercel.app';
  function buildArgsString(args) {
    if (!args) return '';
    const encodeValue = value => {
      if (value === null) return '!null';
      if (value === undefined) return '!undefined';
      if (typeof value === 'boolean') return `!${value}`;
      if (typeof value === 'number') return String(value);
      return encodeURIComponent(String(value));
    };
    const properties = Object.entries(args).map(([key, value]) => `${key}:${encodeValue(value)}`).join(';');
    return `&args=${properties}`;
  }
  const resolvedSrc = story ? `${STORYBOOK_BASE_URL}/iframe.html?id=${story}&viewMode=story&embed=1&globals=mode:readonly${buildArgsString(args)}` : src;
  const resolvedAspectRatio = aspectRatio ?? (story ? '16 / 9' : '10 / 6');
  return <iframe src={resolvedSrc} loading="lazy" allowfullscreen="true" style={{
    width: '100%',
    aspectRatio: resolvedAspectRatio,
    border: 'none',
    borderRadius,
    colorScheme: 'light'
  }} />;
};

A dumbbell compares values within each category: a dot per value on the value axis, joined by a connector from the smallest to the largest. It reads the gap between groups, such as pay by sex across roles or life expectancy across two years.

<Warning>
  The dumbbell ships as its own package, `@graphysdk/geom-dumbbell`, on a `0.x`
  version line. While it is on `0.x`, a minor release may change its aesthetics,
  style parts or look; pin a caret range (`^0.1.0`) to take only patches. Tell
  us what works and what doesn't on [Discord](https://discord.gg/yGmYCjkSr).
</Warning>

## Install

```bash theme={null}
npm install @graphysdk/geom-dumbbell
```

It peer-depends on `@graphysdk/react` and nothing else. Register the plugin once with `createGraphyKit`, which adds `kit.geom.dumbbell()` and binds `kit.GraphProvider` to the same plugin:

```tsx theme={null}
import {
  createGraphyKit,
  GraphRenderer,
  highlight,
  styles,
} from '@graphysdk/react';
import { dumbbell } from '@graphysdk/geom-dumbbell';

const kit = createGraphyKit({ plugins: [dumbbell] });
```

## Long data: one row per dot

Map the category to `x`, the value to `y` and the group to `color`. The geom groups the dots of each category itself, so the legend, palette, tooltip order and highlights all come from the color scale:

<GraphEmbed story="plugins-dumbbell--pay-gap" args={{ orientation: 'vertical' }} />

```tsx theme={null}
const data = {
  columns: [{ key: 'role' }, { key: 'sex' }, { key: 'salary' }],
  rows: [
    { role: 'Product', sex: 'Women', salary: 125 },
    { role: 'Product', sex: 'Men', salary: 140 },
    { role: 'Eng', sex: 'Women', salary: 118 },
    { role: 'Eng', sex: 'Men', salary: 132 },
    // …
  ],
};

const spec = kit.pipe(
  kit.createSpec({ x: 'role', y: 'salary', color: 'sex' }),
  kit.geom.dumbbell(),
  kit.scale.x.discrete(),
  kit.scale.y.continuous(),
  kit.scale.color.palette()
);

export function PayGap() {
  return (
    <kit.GraphProvider spec={spec} data={data}>
      <GraphRenderer />
    </kit.GraphProvider>
  );
}
```

A dumbbell encodes position, not length, so the value axis fits the data rather than holding zero the way a bar axis does, and the gaps read at full width.

A category with one dot draws the dot and no connector. With three or more dots, the connector spans the smallest to the largest and the others sit on it. Leave `color` unmapped and every dot takes the default geom colour, with no legend.

## Wide data

When each group is its own column, reshape it to long in the spec:

<GraphEmbed story="plugins-dumbbell--life-expectancy" />

```tsx theme={null}
const data = {
  columns: [{ key: 'region' }, { key: '1970' }, { key: '2020' }],
  rows: [
    { region: 'Europe', 1970: 71, 2020: 81 },
    { region: 'Africa', 1970: 45, 2020: 61 },
    // …
  ],
};

const spec = kit.pipe(
  kit.createSpec({ x: 'region', y: 'years', color: 'year' }),
  kit.transform.reshape({
    keep: ['region'],
    reshape: ['1970', '2020'],
    keyName: 'year',
    valueName: 'years',
  }),
  kit.geom.dumbbell(),
  kit.scale.x.discrete(),
  kit.scale.y.continuous(),
  kit.scale.color.palette(),
  kit.coord.flip()
);
```

## Horizontal

Add `kit.coord.flip()`, as for a horizontal bar. The categories move to the vertical axis and the connectors lie across.

<GraphEmbed story="plugins-dumbbell--pay-gap" args={{ orientation: 'horizontal' }} />

## Styling

The dots are the layer's observations and style through the kind's own builder. The connector is a part beside them, with a builder of its own:

| Builder                               | Properties                                                                                 | Default                                 |
| ------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------- |
| `kit.style.geom.dumbbell()`           | `fill`, `stroke`, `alpha`, `fillAlpha`, `strokeAlpha`, `saturation`, `size`, `strokeWidth` | Fill from the scale, 10px, white border |
| `kit.style.geom.dumbbell.connector()` | `stroke`, `alpha`, `strokeWidth`, `dashArray`                                              | A neutral grey, 2px, solid              |

<GraphEmbed story="plugins-dumbbell--styled" />

```tsx theme={null}
const spec = kit.pipe(
  // …
  styles({
    defaults: [
      kit.style.geom.dumbbell({ size: 14, strokeWidth: 2 }),
      kit.style.geom.dumbbell.connector({ strokeWidth: 6, stroke: '#d9d4c7' }),
    ],
  })
);
```

An entry that names no part, such as `style.geom({ fill: 'navy' })`, styles the dots like any other geom's observations and never the connector. The connector takes no colour from the scale: it joins dots of different groups, so a scale colour would be arbitrary. A connector entry's `where` is tested against the category's first dot, so write it against what every dot of the category shares, such as its `x`.

## Hover and highlight

Hovering a category emphasises its whole dumbbell. The tooltip lists every dot in legend order, with the dot nearest the cursor marked in place.

A highlight matches dots. A connector is raised only when every dot it joins is: highlighting one group raises that group's dots and no connector, and highlighting one category raises its whole dumbbell.

<GraphEmbed story="plugins-dumbbell--highlight" args={{ target: 'one role' }} />

```tsx theme={null}
const spec = kit.pipe(
  // …
  highlight({ variable: 'role', eq: 'Eng' })
);
```

## Data labels

`dataLabels.showDataLabels` prints every dot's value beside it. Under the default `position: 'auto'` each label sits outward from the connector — the low dot's away from the high one and the high one's away from it — so the gap the chart is read from stays clear. An explicit `position` puts every label on the side `justify` names, for a chart that wants them in a row.

<GraphEmbed story="plugins-dumbbell--data-labels" args={{ position: 'auto' }} />

```tsx theme={null}
const spec = kit.pipe(
  // …
  kit.geom.dumbbell({ dataLabels: { showDataLabels: true } })
);
```

The label prints the dot's `y`, formatted as the value axis formats it; map `label` to print another column instead. `showStackTotals` and `showCategoryLabels` are bar settings and a dumbbell places neither.

## Not yet supported

* **The editor's chart-type picker and the chart agent.** In editable mode the dumbbell renders, hovers, selects and takes annotations on its dots, but it is authored in code.
* **Aggregation.** Duplicate rows for one category and group all draw; aggregate them first with `transform.aggregate()`.
