> ## 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.

# Custom geoms

A custom geom is a new kind of shape a chart can draw. It has two halves: a **compile half** — a `Geom` definition that declares the geom's aesthetics and places its observations in data space — and a **render half** that paints them. This page covers the compile half; the [geom renderers](/sdk-next/extending/geom-renderers) page covers the render half.

The guiding rule: **the compiler owns every position, the renderer invents none.** Your geom declares which position roles it needs, writes their values in data units, and the compiler scales them onto the axes. An observation then stays anchored through any domain change — zoom, a log scale, a flipped coordinate — because its coordinates were resolved by the same scales as every other geom.

## Anatomy of a geom

Extend the `Geom` class, parameterised by its params type. Here's a lollipop — a dot on a stem dropped to the baseline:

```tsx theme={null}
import {
  defineGeomStyleTarget,
  Geom,
  POSITION_VARIABLES,
  token,
} from '@graphysdk/react';
import type { GeomCompileResult, GeomCompilerInput } from '@graphysdk/react';

class LollipopGeom extends Geom<{ stemWidth: number }> {
  readonly type = 'lollipop' as const;
  override readonly defaultParams = { stemWidth: 2 };

  override readonly positionRoles = [
    { axis: 'x', role: 'point' }, // sources the spec-level `x` mapping
    { axis: 'y', role: 'min' }, // the baseline
    { axis: 'y', role: 'max', aes: 'y' }, // the dot height
  ] as const;

  override readonly aesthetics = [{ kind: 'visual', name: 'color' }] as const;
  override readonly supportedCoordTypes = ['cartesian'] as const;
  override readonly spatialKind = 'spanned-buckets';

  // The dot is the observation; 
  // the stem is a part a stylesheet addresses on its own.
  override readonly styleTarget = defineGeomStyleTarget({
    observation: { vocabulary: { size: 'pixels' }, rest: { size: 8 } },
    parts: {
      stem: {
        vocabulary: { stroke: 'color', alpha: 'unitInterval' },
        aesthetics: { stroke: 'color' },
        rest: { stroke: token('geomBorder') },
        hovered: { stroke: token('hoverAffordance') },
      },
    },
  });

  compile({ data }: GeomCompilerInput): GeomCompileResult {
    // The baseline is a position, not a render constant: 
    // write it in data units so the y-scale maps it.
    const withBaseline = data.hasVariable(POSITION_VARIABLES.yMin)
      ? data
      : data.addConstantVariable(POSITION_VARIABLES.yMin, 'numeric', 0);
      
    return { data: withBaseline, mapping: {} };
  }
}
```

## What the definition declares

<ParamField path="type" type="string" required>
  The geom's name, written `as const`. It becomes the typed builder method
  (`kit.geom.lollipop`) and the renderer registry key.
</ParamField>

<ParamField path="defaultParams" type="object">
  Default values for the geom's styling params. The type parameter on `Geom<Params>` types both these and the `params`
  the builder accepts.
</ParamField>

<ParamField path="positionRoles" type="readonly PositionRole[]" required>
  The positions the geom occupies, each `{ axis, role }`. A `'point'` role is a single coordinate on its axis and sources
  that axis aesthetic implicitly — `createSpec({ x })` satisfies `{ axis: 'x', role: 'point' }`, with no `aes` on the
  role. `'min'` / `'max'` form an interval (like a bar or area); `'scalar'` is a magnitude. A `min` / `max` / `scalar`
  role may name a custom positional `aes` — the lollipop's `max` role is exposed as the `y` aesthetic. Write the array
  `as const` so the typed builder can constrain `aes` to exactly these aesthetics.
</ParamField>

<ParamField path="aesthetics" type="readonly GeomAesthetic[]">
  The visual and data aesthetics the geom reads beyond position — here, `color`. Each is
  `{ kind, name, required? }`, with `kind` one of `'visual'` (trained through a visual scale, like `color` or `size`) or
  `'data'` (a raw input a layout geom hands straight to its algorithm, unscaled). Declaring a name registers it so the
  mapping is recognised; `required: true` also enforces its presence.
</ParamField>

<ParamField path="styleTarget" type="GeomStyleTarget">
  The paint a [stylesheet](/sdk-next/config/styling) can address on this geom:
  `observation` for the layer's own marks, and one entry of `parts` per other
  piece the mark paints. Each names the properties it paints and its built-in
  look per state. Geometry stays in `params` — a style target is what a chart
  restyles without changing the shape. See [what the mark
  paints](#what-the-mark-paints).
</ParamField>

<ParamField path="supportedCoordTypes" type="readonly CoordType[]" default="['cartesian', 'flip']">
  Which coordinate systems the geom compiles under, drawn from the spec-level
  union `'cartesian' | 'polar' | 'flip'`. The lollipop narrows the default to
  cartesian alone. This union is the authoring one — the render half keys its
  contracts on `'cartesian' | 'polar'`, where a flipped chart is a cartesian
  variant. See [the render
  contract](/sdk-next/extending/geom-renderers#the-contract).
</ParamField>

<ParamField path="spatialKind" type="'buckets' | 'filled-buckets' | 'spanned-buckets' | 'rects' | 'cells' | 'points' | 'noop' | 'render-hit-test'">
  How the compiler builds the hover hit-test index from the geom's scaled
  positions.

  * `'buckets'`: groups by x and joins each group's marks along the layer, so the cursor snaps to the nearest x (lines).
  * `'filled-buckets'`: the same, and the band between each mark's cross-axis bounds counts as painted (areas), so a cursor inside the fill is on the observation rather than snapped to its edge.
  * `'spanned-buckets'`: groups by x, but the stroke runs across each x rather than joining one to the next, so a press anywhere along it takes that x (a dumbbell's connector, this lollipop's stem). The stroke spans the x's marks and any interval they declare, so a single observation still has length.
  * `'rects'`: rectangular bounds (bars).
  * `'cells'`: self-contained boxes read by containment, one observation each, with no band or stroke to snap along (tiles, heatmaps). Cartesian only — a bandless polar layer declares `'rects'` and hit-tests as an enclosure (pie, donut) instead.
  * `'points'`: proximity to the mark itself (scatter).
  * `'noop'`: opts out of hit-testing (rules).
  * `'render-hit-test'`: for a [layout geom](/sdk-next/extending/geom-renderers#hit-testing-layout-geoms) whose geometry the compiler can't see.
</ParamField>

<ParamField path="identityKey" type="IdentityKey" default="'x-group'">
  What makes "the same observation" across recompiles — the key morphs and hover
  resolve against. `'x-group'` derives it from the layer's x + group columns
  (the standard cartesian case), `'index'` from position in the dataset, and
  `{ variable: 'nodeId' }` names a column the geom owns. A geom whose
  observations aren't x-and-group keyed must say so: a `'render-hit-test'` geom
  left on the default raises `RENDER_HIT_TEST_IDENTITY` and its hover lookup is
  built empty.
</ParamField>

<ParamField path="scaleConstraints" type="ScaleConstraints">
  Domain constraints the geom imposes on the inferred position scales:
  `discreteMainAxis` forces a categorical band on the main axis (what a bar
  needs), `zeroBaseline` anchors the value scale at zero. Unset imposes none.
</ParamField>

<ParamField path="defaultPosition" type="PositionAdjustment" default="'identity'">
  How overlapping marks of this geom arrange when the layer names no position —
  `'stack'`, `'dodge'`, `'fill'`, or `'identity'` to leave them where they fall.
  Under `'dodge'`, `x` is the center of the observation's share of the band and
  `xMin`/`xMax` its edges, so a geom drawn at `x` dodges with no extra work.
</ParamField>

<ParamField path="defaultStat" type="Stat | null" default="null">
  The stat a layer of this geom runs when it names none — `null` draws the data
  as-is (`identity`). Registering the geom registers the stat with it, so the kit
  also gains `kit.stat.<type>()`. A layer can name another stat, or `identity`
  to map already-summarised columns itself. See [a summary is a
  stat](#a-summary-is-a-stat).
</ParamField>

<ParamField path="tooltip" type="readonly { key, aes }[]" default="[]">
  The rows the hover tooltip reads off an observation, each pairing a display
  `key` with the `aes` it reads. The default is empty, so a geom that declares
  nothing shows no tooltip body.
</ParamField>

<ParamField path="highlightStrategy" type="HighlightStrategy | null" default="'overlay-anchor'">
  How the geom composes [highlight](/sdk-next/emphasis/highlights) matches above
  its base render: `'overlay-anchor'` surfaces matched observations as a marker
  at the renderer's `getOverlayAnchor`, `'observation-rerender'` repaints the
  matched subset through the renderer's `renderHighlight` (or `render`) above
  the dimmed layer, and `null` opts out.
</ParamField>

## The compile step

`compile(input)` receives `{ data, mapping, params }` — the layer's post-stat `Dataset`, its effective mapping (which carries any custom positional aesthetics the geom declared), and the resolved params — and returns a `GeomCompileResult`, `{ data, mapping }`. This is where the geom writes any positions it derives rather than reads from the mapping. The lollipop's baseline isn't in the data, so `compile` adds it as a constant column in data units; the compiler then scales `yMin` and `yMax` through the shared y-scale like any other position.

A geom with `'data'`-kind aesthetics reads `mapping` here: those columns never pass through a scale, so the algorithm has to fetch them itself.

Do the minimum here: derive positions, and leave scaling, stacking, and axis placement to the pipeline. A geom that computes screen pixels in `compile` has crossed the [compile/render seam](/sdk-next/concepts/how-a-chart-is-built). A geom whose marks summarise many rows doesn't summarise here either: the summary is a [stat](#a-summary-is-a-stat), so an author can replace it.

## A summary is a stat

Some geoms draw a summary rather than the rows: a boxplot draws quartiles, a range bar the lowest and highest value per category. In the grammar that summary is a **stat**, and the geom names it as its `defaultStat`. A layer that names no stat runs it; a layer whose data is already summarised names `identity` and maps the summary columns itself.

Here's a span — a bar from the lowest to the highest `y` of each `x` — whose stat computes the range:

```tsx theme={null}
import { Geom, getGroupVariables, Stat, UserInputError } from '@graphysdk/react';
import type {
  GeomCompileResult,
  GeomCompilerInput,
  StatCompileResult,
  StatCompilerInput,
} from '@graphysdk/react';

class SpanStat extends Stat {
  readonly type = 'span' as const;
  // The aesthetics this stat maps, so a layer running it needn't map them.
  readonly computedVariables = new Set(['low', 'high']);

  protected computeStat({ data, mapping }: StatCompilerInput): StatCompileResult {
    const { x, y } = mapping;
    if (typeof x !== 'string' || typeof y !== 'string') {
      throw new UserInputError({
        code: 'MISSING_STAT_VARIABLE',
        message: "'span' needs x and y mapped to columns",
      });
    }

    // Split by x and by the variables the layer groups on, so each color gets its own span.
    const summary = data
      .groupBy(...new Set([x, ...getGroupVariables(data, mapping)]))
      .rollup({
        spanLow: { variableName: y, aggregation: 'min' },
        spanHigh: { variableName: y, aggregation: 'max' },
      });

    return { data: summary, mapping: { low: 'spanLow', high: 'spanHigh' } };
  }
}

class SpanGeom extends Geom {
  readonly type = 'span' as const;
  override readonly defaultParams = {};
  override readonly defaultStat = new SpanStat();

  override readonly positionRoles = [
    { axis: 'x', role: 'point' },
    { axis: 'y', role: 'min', aes: 'low' },
    { axis: 'y', role: 'max', aes: 'high' },
  ] as const;
  override readonly aesthetics = [{ kind: 'visual', name: 'color' }] as const;

  compile({ data }: GeomCompilerInput): GeomCompileResult {
    return { data, mapping: {} };
  }
}
```

The layer maps the raw values, and the stat turns them into the span:

```tsx theme={null}
kit.pipe(
  kit.createSpec({ x: 'month', y: 'sales' }),
  kit.geom.span({ aes: { color: 'region' } }),
  kit.scale.x(),
  kit.scale.y()
);

// Already summarised: skip the stat and map the columns.
kit.geom.span({ stat: kit.stat.identity(), aes: { low: 'minSales', high: 'maxSales' } });
```

What a stat gets and owes:

* **`computedVariables`** names the aesthetics the stat maps, including a geom's custom positional ones. Layer validation takes it at its word and doesn't ask the author to map them, so the stat must deliver each one the geom requires, mapped to a column it writes. A stat that doesn't fails with `MISSING_STAT_OUTPUT`, naming the stat.
* **The stat runs before grouping.** Split the rows by `x` and by `getGroupVariables(data, mapping)` — the same variables the layer groups on — as the built-in `count` does. A group column the output keeps stays mapped, so `color: 'region'` still colours each span.
* **Consumed aesthetics are dropped.** The rolled-up rows no longer hold `sales`, so after the stat `y` is no longer mapped. An aesthetic stays mapped only if the stat maps it or its column is still in the output.
* **`params` are the layer's params.** One param list serves the geom and its stat, so a stat reads its settings where the author sets the geom's — `kit.geom.span({ params: { … } })`. A geom and its default stat must not use one param name for two meanings.
* **The stat runs on an empty layer too.** When every row is filtered out, `computeStat` still runs, so the aesthetics it maps stay mapped. An empty frame's column types are placeholders, so check a column's type only when there are rows.
* **Report a bad input** with `UserInputError` and a user-input code, as the built-in stats do.

## Anchoring annotations

[Annotations](/sdk-next/emphasis/annotations) attach to an observation, and the geom is what knows where that observation sits. Implement `resolveAnchorPosition` to answer, in normalized panel `[0, 1]` space:

```tsx theme={null}
import { getX, getYMax } from '@graphysdk/react';
import type {
  AnchorContext,
  AnchorPosition,
  Observation,
} from '@graphysdk/react';

const resolveLollipopAnchor = (
  observation: Observation,
  { coordSystem }: AnchorContext
): AnchorPosition | null => {
  if (coordSystem.type === 'polar') return null;
  const x = getX(observation);
  const y = getYMax(observation); // the dot — the lollipop's one anchor point
  if (x === null || y === null) return null;
  return { x, y };
};
```

Assign it on the definition — `override readonly resolveAnchorPosition = resolveLollipopAnchor;`.

The second argument is an `AnchorContext`, not a bare coord system: `{ coordSystem, position, purpose, align? }`. `position` is the layer's resolved position adjuster — the sole authority on whether the value columns hold cumulative stack bounds. `purpose` is `'pin'` (something sits *on* the observation, like a comment, so it moves off an edge shared with a neighbouring segment) or `'value'` (something reads the value off the axis, like a difference arrow, so it stays on the outer edge). `align` names a point of the geom's box when the anchor asks for one. A geom that anchors every purpose to the same point — the lollipop's dot, wherever its stem starts — can ignore all three.

The annotation stage skips geoms that don't implement the hook, so their observations can't be annotated; on a `'render-hit-test'` geom the omission also raises `MISSING_ANCHOR_CAPABILITY`.

## Data labels

A geom places its own [data labels](/sdk-next/config/data-labels). Declare the coords it places under on `dataLabelCoordTypes` and implement `getDataLabelPlacements`, which returns each label's box center in panel pixels:

```tsx theme={null}
class LollipopGeom extends Geom<{ stemWidth: number }> {
  override readonly dataLabelCoordTypes = ['cartesian'] as const;

  override readonly getDataLabelPlacements = (context: PluginPlacementContext): PlacementResult => {
    const labels = context.layer.data.map((observation) => {
      const text = context.formatLabel(observation);
      const size = context.measureDataLabel('observation', 'outside', text);
      const center = resolveAnchoredLabelCenter({ box: dotBox(observation), placement: 'outside', ...size });
      return { layerId: context.layer.id, role: 'observation', text, ...size, ...center };
    });
    return { labels, droppedCount: 0 };
  };
}
```

Simplified: `dotBox` stands in for converting the dot's position to panel pixels, and `resolveAnchoredLabelCenter` takes a few more options than shown.

The context carries the formatters, so a label prints what the axis and the tooltip print without the geom formatting anything itself, and `measureDataLabel` returns the themed box the placement must fit. `resolveAnchoredLabelCenter` resolves the author's `position`, `justify`, `align` and `offset` against a geom's box, so every geom means the same thing by them; `'auto'` is the geom's own heuristic to resolve before calling it. The hook runs only when the layer asks for a label of some kind, and reads which of `showDataLabels`, `showStackTotals` and `showCategoryLabels` it honours — the last two are bar vocabulary that most geoms ignore.

Declaring the coords without implementing the hook raises `MISSING_DATA_LABEL_PLACEMENT`: the flag would be accepted and nothing would paint.

## What the mark paints

A `styleTarget` declares the paint a [stylesheet](/sdk-next/config/styling) can address on the geom: `observation` for the layer's own marks, and one entry of `parts` per other piece the mark paints — the lollipop's stem beside its dot.

`observation` is the layer's own paint: like a built-in geom it reads the encoding, and it takes every geom entry that names no part. It paints the shared geom vocabulary — `fill`, `stroke`, `alpha`, `fillAlpha`, `strokeAlpha` and `saturation` — plus whatever it declares, so the lollipop above adds `size` and keeps the rest. `sharedPaint: false` declines the shared words, for a mark that speaks fewer of them. `aesthetics` names which aesthetic answers each property from the encoding; unset, `color` answers both `fill` and `stroke` and `alpha` answers `alpha`. A mark whose outline keeps a colour of its own declares `{ fill: 'color', alpha: 'alpha' }`, and a line-like mark `{ stroke: 'color', strokeAlpha: 'alpha' }`.

Each entry of `parts` declares everything it paints and takes only the entries that name it. A part does not read the encoding unless it declares `aesthetics`. A part that joins observations, such as a dumbbell's connector, has no row to read from. A part painted once per observation, such as the lollipop's stem, is painted for that observation, so `aesthetics: { stroke: 'color' }` gives the stem its dot's colour from the scale.

`rest`, `dimmed` and `hovered` are the built-in look in each state, checked against the vocabulary they belong to. `rest` is what paints when nothing is happening to the mark. The shared geom defaults, the house colour and the dimmed alpha included, reach only the observations.

The kit types the geom's own style builder from the observation vocabulary, and adds one per part:

```tsx theme={null}
const kit = createGraphyKit({ plugins: [lollipop] });

styles({
  overrides: [
    kit.style.geom.lollipop({ size: 12 }),
    kit.style.geom.lollipop.stem({ stroke: token('brand') }),
  ],
});
```

The render half reads all of it through `styleReaders` — see [a mark with several parts](/sdk-next/extending/geom-renderers#a-mark-with-several-parts).

## Further declarations

The base class defaults everything else, and a geom overrides only what differs:

| Declaration | Default | What it decides |
| - | - | - |
| `defaultInteractive` | `true` | Whether layers of this geom take part in hover hit-testing |
| `derivedVariables` | `[]` | Names the geom computes in its own output that an author may map an aesthetic to |
| `isComposite` | `false` | `true` when the geom draws one geometry per group (a path) rather than one mark per row |
| `grid` | `{}` | Per-coord grid and panel-border visibility the geom requests from the axes guide |
| `legend` | `{}` | How the geom relates to the color legend — suppression, placement, direct labels |
| `dataLabels` / `dataLabelCoordTypes` | `{}` / `[]` | Per-coord data-label defaults, and the coords this geom places labels under |
| `summaries` | `{}` | Aggregates the geom opts into — grand total, stack totals, per-group headline |
| `validateMapping` | optional | A bespoke mapping requirement no position role expresses |
| `resolveBandFraction` | optional | The fraction of its band the geom occupies, so an axis anchor aligns to the mark's edge |
| `resolveAnchorBox` | optional | An observation's extent box in normalized panel space, for selection anchors |
| `resolveValueSource` | optional | The aesthetic an observation's value lives on, where that is not its `y` |
| `resolveDataLabelDefaults` | optional | Data-label defaults that depend on the layer's position adjuster |
| `getDataLabelPlacements` | optional | Places this layer's data labels; see [data labels](#data-labels) |

## Registering and using it

The compile half pairs with a render half through [`defineGeomRenderer`](/sdk-next/extending/geom-renderers), and the paired result goes into the `plugins` array. From there the typed builder method appears, constrained to exactly the aesthetics and params the definition declared:

```tsx theme={null}
const lollipop = defineGeomRenderer(new LollipopGeom(), {
  coord: 'cartesian',
  render: ({ layer }) => <LollipopMarks layer={layer} />,
  renderHover: ({ primary }) => (
    <LollipopMark observation={primary.observation} isHovered />
  ),
  renderHoverCompanions: () => null,
});
const kit = createGraphyKit({ plugins: [lollipop] });

const spec = kit.pipe(
  kit.createSpec({ x: 'category', y: 'revenue' }),
  kit.geom.lollipop({ aes: { color: 'category' }, params: { stemWidth: 3 } }),
  kit.scale.x(), // custom geoms declare their own position scales
  kit.scale.y(),
  kit.scale.color.palette()
);
```

<Note>
  A native spec doesn't auto-infer position scales the way the chart-type
  recipes do — declare `scale.x()` and `scale.y()` explicitly, or the positions
  resolve to `NaN`. See
  [Scales](/sdk-next/concepts/scales#position-scales-must-be-declared).
</Note>

## Related

* [Geom renderers](/sdk-next/extending/geom-renderers) — the render half, `defineGeomRenderer`, hit-testing, hover
* [How a chart is built](/sdk-next/concepts/how-a-chart-is-built) — where `compile` sits in the pipeline
* [Scales](/sdk-next/concepts/scales) — how declared positions get trained and mapped
* [Diagnostics](/sdk-next/extending/index#diagnostics) — what a misdeclared definition reports


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.