> ## 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 { defineGeomRenderer, createGraphyKit } from '@graphysdk/react-renderer';

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 } from '@graphysdk/react-renderer';

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 input={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="CoordKind" required>
  The coordinate system this contract paints under — `'cartesian'`, `'flip'`, or
  `'polar'`. A geom binds one contract per coord.
</ParamField>

<ParamField path="render" type="(input) => ReactNode" required>
  Paints the observations. Receives `{ layer, coordSystem, isAnimated, formattingLocale }`. 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 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.
</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>

## 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,
  getColor,
  toPercent,
  toViewBoxX,
  toViewBoxY,
} from '@graphysdk/viz-engine';

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))}
  stroke={getColor(observation) ?? '#4e79a7'}
/>;
```

A reader returns `null` for a missing or unscaled value — guard for it and skip the observation rather than painting `NaN`. For a render-only override of a built-in geom, higher-level readers like `getBarRectBounds(mainAxis, observation)` hand you the geom's normalized `[0,1]` bounds directly.

## 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 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`:

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

## 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 the `panelRect` for placement:

```tsx theme={null}
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,
});
```

`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
* [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
