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

# Geom renderers

A geom renderer owns the React paint for one `(geom, coord)` composition — the **render half** of a geom, opposite its [compile half](/sdk-next/extending/custom-geoms). Renderers paint observations and respond to hover; they don't own layout, guides, or data-label placement.

The registry is keyed on the `(geom, coord)` pair, so `bar × cartesian` (a column) and `bar × polar` (a pie wedge) are independent renderers that share no code. Adding one never touches the other.

## `defineGeomRenderer` is dual-target

The same function binds a renderer two ways, depending on what you pass first:

### A whole custom geom

Pass a `Geom` **instance** to pair both halves. The result carries the compile definition on `.definition`, so dropping it into `plugins` registers the geom *and* derives its typed builder method:

```tsx theme={null}
import { createGraphyKit, defineGeomRenderer } from '@graphysdk/react';

const lollipop = defineGeomRenderer(new LollipopGeom(), {
  coord: 'cartesian',
  render: ({ layer }) => <LollipopMarks layer={layer} />,
  renderHover: ({ layer, primary }) => (
    <LollipopMark observation={primary.observation} isHovered />
  ),
  renderHoverCompanions: () => null,
});

const kit = createGraphyKit({ plugins: [lollipop] });
// kit.geom.lollipop(...) is now typed from the definition
```

### Render-only overrides

Pass a built-in geom **name** to rebind only the paint half. The built-in compile half keeps running — positions, stacking, scales, and axes are unchanged — so only the look differs. This is how you restyle bars without reinventing bar geometry:

```tsx theme={null}
import {
  defineGeomRenderer,
  GraphProvider,
  GraphRenderer,
} from '@graphysdk/react';

const sketchyBar = defineGeomRenderer('bar', {
  coord: 'cartesian',
  guideMode: 'band',
  render: ({ layer, coordSystem }) => {
    if (coordSystem.type !== 'cartesian') return null;
    return <SketchyBars layer={layer} coordSystem={coordSystem} />;
  },
  renderHover: ({ primary, coordSystem }) =>
    coordSystem.type === 'cartesian' ? (
      <SketchyHighlight observation={primary.observation} />
    ) : null,
  renderHoverCompanions: () => null,
});

// no kit needed — the built-in geom.bar() authors the spec, this only repaints it
<GraphProvider spec={spec} data={data} plugins={[sketchyBar]}>
  <GraphRenderer />
</GraphProvider>;
```

The name is constrained to the built-in geom union, so overriding an unknown name is a compile-time error. To restyle a *custom* geom, rebind the definition you already hold rather than its name.

## The contract

Every renderer implements a `GeomRenderContract`:

<ParamField path="coord" type="'cartesian' | 'polar'" required>
  The coordinate system this contract paints under — the discriminant of the
  `coordSystem` the render receives. A geom binds one contract per coord.
</ParamField>

<Note>
  A flipped chart resolves the `'cartesian'` contract: flip is an
  axis-assignment variant, a `CartesianCoordSystem` with `mainAxis: 'y'`, not a
  geometric paradigm of its own. Branch on `coordSystem.mainAxis` inside the
  cartesian render rather than writing a third contract. The spec-level
  `'cartesian' | 'polar' | 'flip'` union is a different one — it's what a
  definition's
  [`supportedCoordTypes`](/sdk-next/extending/custom-geoms#what-the-definition-declares)
  answers with.
</Note>

<ParamField path="render" type="(input) => ReactNode" required>
  Paints the observations. Receives `{ layer, coordSystem, shouldAnimateTransitions, formattingLocale, intro, panelRect }` — see
  [Reading the panel rect](#reading-the-panel-rect) and
  [Entrances and transitions](#entrances-and-transitions). The `{ fn, options: { overlay: true } }`
  form paints into a screen-aligned portal instead — see [Live geoms](#live-and-draggable-geoms).
</ParamField>

<ParamField path="renderHover" type="(input) => ReactNode" required>
  Paints the hover state for this layer's observations. Receives the hovered
  `primary` hit (a `HoverHit`, exported from `@graphysdk/react`) plus its
  `group` and `related` hits, the `coordSystem`, and the `panelRect`.
</ParamField>

<ParamField path="renderHoverCompanions" type="(input) => ReactNode" required>
  Cross-layer hints — e.g. dots dropped on a line at the hovered x. Return
  `null` to opt out.
</ParamField>

<ParamField path="guideMode" type="'crosshair' | 'band' | null">
  The positional guide drawn when this is the hovered layer: `'band'` shades the
  hovered category (bars), `'crosshair'` draws a rule at the hovered value
  (lines, areas). Omit or pass `null` to draw none (scatter points).
</ParamField>

<ParamField path="swatchShape" type="SwatchShape">
  The swatch shape shown for this geom in the legend and tooltip.
</ParamField>

<ParamField path="renderHighlight" type="(input) => ReactNode">
  Optional repaint of the matched subset for the
  [highlight](/sdk-next/emphasis/highlights) overlay. Falls back to `render`
  when omitted. Override it when the plain grouped render would misrepresent a
  matched subset. Its input is the render input plus `sourceLayer` (the full
  layer the subset was filtered from — context the subset can't see, like a
  stack's silhouette).
</ParamField>

<ParamField path="getOverlayAnchor" type="(input) => OverlayAnchor | null">
  Where a matched observation's highlight marker sits, in normalized `[0,1]`
  coord space. Receives `{ layer, coordSystem, observation }` and returns
  `{ x, y }`, or `null` for an observation it can't place. Built-in geoms whose
  highlight strategy is `'overlay-anchor'` are required to supply it; for a
  custom geom it's optional.
</ParamField>

<Note>
  `renderHighlight` and `getOverlayAnchor` serve layers that compose highlights,
  which the compiler resolves per built-in geom — so they're live on a
  render-only override of a bar or a line, and idle on a custom geom's own
  layers.
</Note>

<ParamField path="getEditOutlineShapes" type="(input) => EditOutlineShape[]">
  Editing only: the shapes the editor outlines when this layer's observations are hovered or selected in point and edit. Unlike the `render…` methods it returns data, not JSX, always in panel pixels: `{ kind: 'region', pathData }` for the area the observations cover (one path where there are many, with rounded corners traced in), `{ kind: 'stroke', pathData, width }` for a line at the width it is drawn, and `{ kind: 'dots', centers, size }` for round marks. The editor draws the outline around them in its own look. Receives `{ layer, sourceLayer, coordSystem, panelRect, styleReaders, shouldAnimateTransitions }`; copy `shouldAnimateTransitions` onto a region or stroke whose paint springs, so the outline morphs with it. Omit it and the editor outlines each observation's bounding box.
</ParamField>

<ParamField path="hitTest" type="(input) => RenderHitTester">
  A per-cursor spatial query for a [layout geom](#hit-testing-layout-geoms).
  Only consulted when the compile half declares `spatialKind:
      'render-hit-test'`.
</ParamField>

Registering a renderer whose `(geom, coord)` pair never appears, or whose compile half has no render half at all, surfaces as a [plugin diagnostic](/sdk-next/extending/index#diagnostics) rather than a throw — `MISSING_GEOM_RENDERER`, `DUPLICATE_REGISTERED_TYPE`, and the three `render-hit-test` codes all originate here.

## Reading the panel rect

`render`, `hitTest`, `renderHover`, and `renderHighlight` all receive `panelRect`: the data rectangle in SVG-local pixels, already inset from axes and chrome. Marks paint in local `0…width` / `0…height` — `x`/`y` already positioned the geom-layers SVG, so adding them double-offsets. `GraphRenderer`'s `onResize` reports the whole graph container instead — don't scrape `[data-testid="plot"]`.

Width and height are the aspect a layout geom needs for marks that must stay circular or uniformly spaced:

```tsx theme={null}
render: ({ panelRect }) => {
  const radius = Math.min(panelRect.width, panelRect.height) / 4;
  return (
    <circle cx={panelRect.width / 2} cy={panelRect.height / 2} r={radius} />
  );
};
```

An overlay-hosted render also gets `overlay.panelRect`, which is the same panel in **client** pixels for HTML placement — not a second copy of the layout rect.

## Reading scaled positions

The renderer reads coordinates off each `Observation` with the value readers — it never re-derives a position. The compiler already scaled them; the reader hands you the resolved value, and helpers convert it to the paint frame:

```tsx theme={null}
import {
  getX,
  getYMin,
  getYMax,
  toPercent,
  toViewBoxX,
  toViewBoxY,
} from '@graphysdk/react';

const x = getX(observation); // scaled x
const yTop = getYMax(observation); // scaled dot height
const yBase = getYMin(observation); // scaled baseline

<line
  x1={toPercent(toViewBoxX(x))}
  x2={toPercent(toViewBoxX(x))}
  y1={toPercent(toViewBoxY(yBase))}
  y2={toPercent(toViewBoxY(yTop))}
/>;
```

A reader returns `null` for a missing or unscaled value — guard for it and skip the observation rather than painting `NaN`. For a custom scalar aesthetic (a candlestick's `open`/`close`), `getScaledAesthetic(observation, 'open')` reads the scaled position the mapper wrote into a derived column. For a render-only override of a built-in geom, higher-level readers like `getBarRectBounds(mainAxis, observation)` and `getTileRectBounds(observation)` hand you the geom's normalized `[0,1]` bounds directly.

## Paint frames

`UnitSpaceSvg` stretches the `[0, 1]` square onto the panel (`preserveAspectRatio="none"`) so paint and hit-test share one frame. Paths belong there. A `<circle>` in that frame becomes an ellipse on a non-square panel.

Round marks and glyphs go in `UnitBoxSvg`: a nested SVG over a unit-space box with no viewBox. Children live in an unscaled local space — `50%` is the box centre, a pixel radius stays a pixel.

```tsx theme={null}
import { UnitBoxSvg, UnitSpaceSvg } from '@graphysdk/react-renderer';

<UnitSpaceSvg>
  <path d={cellPath} />
</UnitSpaceSvg>
<UnitBoxSvg box={{ x0, y0, x1, y1 }}>
  <circle cx="50%" cy="50%" r={4} />
</UnitBoxSvg>
```

A point circle can also sit as a sibling of `UnitSpaceSvg` with percent positions and a pixel radius, the same way the built-in point geom paints.

## Reading paint

Paint resolves through the [stylesheet](/sdk-next/config/styling)'s three-tier cascade — **override → data → default**, first answer wins. The value readers `getColor`, `getAlpha`, `getSize`, `getStrokeWidth` and `getLineType` are the **data** tier alone: they hand you what the encoding decided and nothing else. Reading one and falling back to a constant therefore skips the two tiers around it — a chart's `style.geom({ fill }, { where })` override, and any default a stylesheet gives an unmapped layer. The mark paints its own hard-coded value where the chart's stylesheet says otherwise.

Read the whole cascade from `styleReaders` on the render input. Inside a React renderer under the provider, `useGeomStyleReader(layer)` is the same object.

A kind the registry does not name reads the shared geom vocabulary, whose `fill` and `stroke` have no built-in value: the `color` aesthetic still feeds both, so a mapped colour arrives, but a renderer paints `DEFAULT_GEOM_COLOR` when `get('fill')` answers nothing.

```tsx theme={null}
import type { GeomRendererInput, Observation } from '@graphysdk/react';

const LollipopMarks = ({ layer, styleReaders }: GeomRendererInput) => (
  <>
    {[...layer.data].map((observation: Observation, index: number) => (
      <circle
        key={index}
        fill={styleReaders.get('fill', observation) ?? DEFAULT_GEOM_COLOR}
      />
    ))}
  </>
);
```

<Warning>The render input already carries the graph's colour scheme.</Warning>

The [Styling](/sdk-next/config/styling) page covers the cascade itself — tiers, selectors, tokens and states.

### A mark with several parts

A geom whose mark paints more than one thing declares [a `styleTarget`](/sdk-next/extending/custom-geoms#what-the-mark-paints) naming each one — a lollipop's stem beside its dot. `styleReaders` answers for the observations, as above; `styleReaders.part(name)` answers for a declared part. Pass the observation you paint it for, so an entry's `where` can test it:

```tsx theme={null}
const stem = styleReaders.part('stem');

<line
  stroke={stem?.get('stroke', observation)}
  strokeOpacity={stem?.get('alpha', observation)}
/>;
```

A part reads the encoding only through the `aesthetics` it declares, so otherwise its paint comes from the stylesheet and the geom's declared defaults alone, and a part the geom didn't declare reads back `undefined`. Pass a state third to read one — `stem.get('stroke', observation, 'hovered')` is the look [`renderHover`](#hover) paints.

## Entrances and transitions

Two members of the render input drive motion, and both are offered rather than imposed — a renderer that ignores them simply appears at once.

<ParamField path="intro" type="LayerIntroPlan | null">
  The engine's plan for how this layer enters on first mount, discriminated on
  `type`: `{ type: 'grow', durationSeconds, baseline, growAxis, delayByKey }`,
  `{ type: 'wipe', durationSeconds, axis }` or `{ type: 'fade', durationSeconds }`.
  Every timing is already in seconds with the chart's duration scale applied —
  apply the numbers verbatim.
</ParamField>

<ParamField path="shouldAnimateTransitions" type="boolean">
  Whether an ongoing *data change* should tween rather than cut. Separate from
  the one-shot entrance.
</ParamField>

Which plan a layer gets follows from its compile half's `spatialKind`:

| `spatialKind` | Coord | Plan |
| - | - | - |
| `'rects'` | cartesian | `grow` along `growAxis: 'x' \| 'y'` from a `baseline` |
| `'rects'` | polar | `grow` along `growAxis: 'angle' \| 'radius'` |
| `'buckets'`, `'filled-buckets'` | cartesian | `wipe` travelling along `axis: 'x' \| 'y'` |
| `'spanned-buckets'`, `'cells'` | any | `fade` |
| `'points'` | any | `grow` along `growAxis: 'size'` |
| `'buckets'`, `'filled-buckets'` | polar | `null` |
| `'noop'`, `'render-hit-test'` | any | `null` |

A wipe travels along a continuous path, so it is reserved for the shapes that draw one across the layer. A spanned bucket strokes within its own bucket and joins nothing to the next, so it fades instead, as a tile grid does.

An empty layer also resolves to `null`. So a layout or overlay geom never receives a plan and owns its own entrance, while a custom geom declaring any of the shapes above **does** get a plan and has to consume it or it pops in while every built-in layer animates.

`@graphysdk/react` ships the same primitives the built-in geoms animate through, so a plugin never takes a `motion` dependency of its own:

```tsx theme={null}
import {
  GeomCircle,
  GeomLine,
  GeomRect,
  IntroFade,
  IntroWipeClip,
} from '@graphysdk/react';

// The entrance: wrap the layer's whole paint in the wrapper its plan calls for.
const paint = (
  <GeomCircle
    cx="50%"
    cy="20%"
    r={5}
    fill={color}
    shouldAnimateTransitions={shouldAnimateTransitions}
  />
);

if (intro?.type === 'fade') return <IntroFade intro={intro}>{paint}</IntroFade>;
if (intro?.type === 'wipe')
  return <IntroWipeClip intro={intro}>{paint}</IntroWipeClip>;
return paint;
```

`GeomCircle`, `GeomLine` and `GeomRect` spring their geometry to each new value when `shouldAnimateTransitions`, and snap when it is off — the data-change half, independent of the entrance. All three take positions in whichever space the geom paints in, percentages or unit coords, as long as a value and its successor share units.

A mark springs only while it keeps its element, so key each one on something that survives the change. That holds for the layer too: a generated layer id churns across recompiles and remounts every mark under it, so a chart whose marks should tween pins its geom — `geom.dumbbell({ id: 'pay-gap' })`. A `grow` plan is per-mark rather than per-layer, so a geom declaring `'rects'` or `'points'` still animates its own entrance from the plan's `baseline`, `growAxis` and `delayByKey`.

## Hover

Hover has two render entry points, both in the contract:

* **`renderHover`** repaints *this layer's* hovered observation — usually the same one drawn bolder or haloed. Because positions come from the compiled observation, the highlight lands exactly over the base shape.
* **`renderHoverCompanions`** paints hints on *other* layers at the hovered x. Return `null` when the observation is its own highlight.

```tsx theme={null}
renderHover: ({ primary }) => <LollipopMark observation={primary.observation} isHovered />,
renderHoverCompanions: () => null,
```

## Hit-testing layout geoms

Most geoms are hit-tested from their scaled positions — the compiler builds the index from `spatialKind`. But a **layout geom** computes its geometry with an algorithm render-side (Sankey ribbons, Treemap tiles, Voronoi cells), so the compiler can't see the shapes to index them.

Such a geom declares `spatialKind: 'render-hit-test'` on its compile half and supplies a `hitTest` **factory** on the contract. The factory runs once per data change or panel resize and returns a per-cursor tester; the cursor arrives in panel-local `[0,1]` (top-left origin), and the tester returns the identity key of the observation under it, or `null`. It receives the same `panelRect` as `render`:

```tsx theme={null}
const treemap = defineGeomRenderer(new TreemapGeom(), {
  coord: 'cartesian',
  render: ({ layer }) => <TreemapTiles layer={layer} />,
  renderHover: ({ primary }) => (
    <TreemapTileHighlight observation={primary.observation} />
  ),
  renderHoverCompanions: () => null,
  hitTest: ({ layer }) => {
    const tiles = buildTiles(layer.data);
    return ({ x, y }) => findTileKey(tiles, x, y); // → identity key or null
  },
});
```

The renderer registers the tester on your behalf, so central hover, tooltips, and guides work the same as for a built-in geom. (`useGeomHitTest` is the underlying hook, but the contract's `hitTest` is the path you write against.)

The key the tester returns is the observation's `identityKey` value, so the compile half has to name an identity its own geometry owns — `{ variable: 'markId' }` or `'index'`. Left on the default `'x-group'`, the lookup is built empty and every hit resolves to nothing (`RENDER_HIT_TEST_IDENTITY`).

## Live and draggable geoms

A geom that runs its own simulation or drag interaction — a force-directed graph — must own its pointer events. For that, `render` takes the overlay-hosted form: `{ fn, options: { overlay: true } }`. Its function receives `input.overlay` with a `pushHover` to feed the central hover store and `overlay.panelRect` (client pixels) for HTML placement. Layout `panelRect` is on the same input.

The overlay resolves each `pushHover` against the same `render-hit-test` index, so the compile half declares `spatialKind: 'render-hit-test'` and an identity of its own here too:

```tsx theme={null}
class ForceGeom extends Geom<ForceParams> {
  readonly type = 'force' as const;
  override readonly defaultParams = { chargeStrength: 450 };
  override readonly supportedCoordTypes = ['cartesian'] as const;
  // The overlay pushes hover by key, and the key comes from this index.
  override readonly spatialKind = 'render-hit-test';
  override readonly identityKey = { variable: 'markId' } as const;

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

const force = defineGeomRenderer(new ForceGeom(), {
  coord: 'cartesian',
  render: {
    fn: ({ layer, overlay }) => (
      <ForceSim
        layer={layer}
        pushHover={overlay.pushHover}
        rect={overlay.panelRect}
      />
    ),
    options: { overlay: true },
  },
  renderHover: () => null,
  renderHoverCompanions: () => null,
});
```

Without that `spatialKind`, the overlay still paints but every `pushHover` resolves against no index — no hover, no tooltip — and the chart reports `OVERLAY_REQUIRES_RENDER_HIT_TEST`.

An overlay render and a `hitTest` factory are alternatives, not a pair: the overlay pushes hover from its own handlers, the factory answers the central pull. Declaring both is `CONFLICTING_RENDER_HIT_TEST`, and only the overlay is used. Declaring neither on a `'render-hit-test'` layer is `MISSING_RENDER_HIT_TEST` — the layer paints with no hover at all.

`useGeomHover(layerId)` is the lower-level push hook the overlay form wraps — reach for it only when the overlay form doesn't cover your case.

## Related

* [Custom geoms](/sdk-next/extending/custom-geoms) — the compile half a renderer pairs with
* [Styling](/sdk-next/config/styling) — the cascade a renderer reads paint from
* [Interactivity](/sdk-next/rendering/interactivity) — the hover model renderers plug into
* [Slots](/sdk-next/extending/slots) — replace a *region's* render without writing a geom
* [Diagnostics](/sdk-next/extending/index#diagnostics) — what a misdeclared renderer reports


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