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

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 { Geom, POSITION_VARIABLES } from '@graphysdk/viz-engine';
import type { CompiledGeom, GeomCompilerInput } from '@graphysdk/viz-engine';

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

  override readonly positionRoles = [
    { axis: 'x', role: 'point', valueKind: 'value' },
    { axis: 'y', role: 'min', valueKind: 'value' }, // the baseline
    { axis: 'y', role: 'max', valueKind: 'value', 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 = 'buckets';

  compile({ data }: GeomCompilerInput): CompiledGeom {
    // 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, valueKind }`. A `'point'` role is a single coordinate on its
  axis; `'min'` / `'max'` form an interval (like a bar or area); `'scalar'` is a magnitude. A 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 }`, with `kind` one
  of `'visual'` (trained through a visual scale, like `color` or `size`) or `'data'` (a raw input a layout geom hands to
  its algorithm).
</ParamField>

<ParamField path="supportedCoordTypes" type="readonly CoordType[]">
  Which coordinate systems the geom compiles under. The lollipop is
  cartesian-only.
</ParamField>

<ParamField path="spatialKind" type="'buckets' | 'rects' | 'points' | 'noop' | 'render-hit-test'">
  How the compiler builds the hover hit-test index from the geom's scaled
  positions. `'buckets'` groups by x (lines, areas), `'rects'` uses rectangular
  bounds (bars), `'points'` uses point proximity, `'noop'` opts out. Use
  `'render-hit-test'` for a [layout
  geom](/sdk-next/extending/geom-renderers#hit-testing-layout-geoms) whose
  geometry the compiler can't see.
</ParamField>

## The compile step

`compile(input)` receives the layer's `data` (a `Dataset`) and returns a `CompiledGeom` — `{ 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.

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

## 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(), lollipopRenderer);
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
