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 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 theGeom class, parameterised by its params type. Here’s a lollipop — a dot on a stem dropped to the baseline:
What the definition declares
string
required
The geom’s name, written
as const. It becomes the typed builder method
(kit.geom.lollipop) and the renderer registry key.object
Default values for the geom’s styling params. The type parameter on
Geom<Params> types both these and the params
the builder accepts.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.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.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.'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 whose
geometry the compiler can’t see.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.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.PositionType
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.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.HighlightStrategy | null
default:"'overlay-anchor'"
How the geom composes highlight matches above
its base render:
'overlay-anchor' surfaces matched observations as a marker
at the geom’s own anchor, 'observation-rerender' repaints the matched subset
in place, null opts out. Highlight composition resolves per built-in geom,
so a custom geom’s layers carry none either way; declaring null states that
plainly.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 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.
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.
Anchoring annotations
Annotations attach to an observation, and the geom is what knows where that observation sits. ImplementresolveAnchorPosition to answer, in normalized panel [0, 1] space:
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 whose observations carry no extent — the lollipop’s dot — resolves every purpose to the same place and 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.
Further declarations
The base class defaults everything else, and a geom overrides only what differs:Registering and using it
The compile half pairs with a render half throughdefineGeomRenderer, 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:
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.Related
- Geom renderers — the render half,
defineGeomRenderer, hit-testing, hover - How a chart is built — where
compilesits in the pipeline - Scales — how declared positions get trained and mapped
- Diagnostics — what a misdeclared definition reports

