Skip to main content
Annotations layer callouts on top of the plot — a shaded region, a labelled arrow, a note pinned to a spike. They carry information the data doesn’t contain, and the annotation builder pipes them into the spec like any other feature. Because they’re a spec feature, annotations re-resolve on every compile: panel-anchored ones re-flow when the chart resizes, and data-anchored ones move with the observation they’re pinned to.

Anchoring

Every annotation is positioned by an anchor that describes a relationship to the graph, not a fixed pixel. Anchors come in two shapes: a point for the kinds that sit somewhere, and a region for the kinds that fill an area.

Point anchors

PointAnchorInput is a union of five anchorTypes:

Addressing a grid cell

An observation anchor normally narrows by series: anchorValue names the position on the main axis, groupValue picks the series. On a heatmap there are no series — anchorValue names a whole column of cells — so crossValue names the band on the other axis that picks one cell out of it:
crossValue is read only by a layer whose observations partition a grid, and ignored everywhere else. A cell anchor resolves to the centre of its tile.

Region anchors

RegionAnchorInput is a union of three: padding is either a number (both axes, in panel fractions) or an AnchorOffset. Omit it and a selection region gets { x: 8, y: 8, unit: 'px' }, so a box wrapping bars doesn’t sit flush against them.

align and offset

align names which point of a box an anchor resolves to:
Omit it on a data anchor and you get the geom-natural point — a bar’s top-edge midpoint. offset is a nudge applied once the target has resolved: { x?, y?, unit?: 'panel' | 'px' }, with unit defaulting to 'panel'. Pixel offsets are resolved at render time, against the measured box.

Naming the layer

On a combo chart, more than one layer can hold the same address — bars and a trend line both have a value at 'Jun'. Name the layer you mean and pass its id as layerId:
Omit layerId and the anchor resolves against the first layer that matches. An id no layer carries drops that annotation with a warning rather than silently moving it. Note the asymmetry in what each kind takes: pinnedNumber and comment take a bare ObservationAnchorInput — the fields above with no anchorType key — while text, sticker and arrow take a full PointAnchorInput, where anchorType is required.

Ids and references

An annotation’s id is unique across every kind, and it is what an annotation anchor’s ref points at. The first annotation to declare an id keeps it; a second one declaring the same id gets a generated one plus an ANNOTATION_DUPLICATE_ID warning. Only ids you write yourself can be referenced — a ref naming nothing raises ANNOTATION_REF_NOT_FOUND and drops the annotation that referred to it.

One attachment per observation

An observation carries at most one of sticker, pinnedNumber, comment or image. Adding a second displaces the first — a comment replaces the pinned number on its bar rather than joining it. AddAnnotationCommand reverts by putting the displaced annotation back, so one undo restores both halves of a replacement. (An image has no observation region anchor of its own, so today it never displaces anything.)

The annotation kinds

Each method on annotation appends one callout. Multiple calls of the same kind accumulate.

Difference arrow

A difference arrow spans two observations and labels the change between them — you choose what the label measures:
Distinct from annotation.arrow(), which is a plain arrow between two positions you specify — it doesn’t read any data. A difference arrow’s paint comes from the stylesheet: style.annotation.differenceArrow({ color, strokeWidth }) styles every arrow’s line, style.annotation.differenceArrow.label({ fontSize, fontWeight, textColor, background, borderColor, borderWidth, borderRadius, paddingInline, paddingBlock, ... }) its label, and { annotation: id } in the options narrows an entry to one. With nothing declared the line is 2px and the label a 12px boxed number; the route around the observations scales with strokeWidth. An arrow whose colour no entry names takes the series colour its two ends share, when they share one, else black (white under a dark scheme); the label’s box border takes the arrow’s colour unless borderColor is declared.
A difference is only meaningful when both ends measure the same thing. Endpoints on different bases — one on the primary y axis and one on the secondary, or a revenue value against a margin value in the same reshaped column — raise an INCOMPARABLE_ARROW_ENDPOINTS warning. The arrow still draws, so you can see what it claims.

Shaded region

zOrder decides whether a shape or image sits behind the data ('background') or in front of it ('foreground', the default). A wash of colour under the marks wants 'background' written out, as above. A shape’s paint comes from the stylesheet: style.annotation.shape({ color, alpha, borderColor, borderWidth }) styles every shape, and { annotation: id } in the options narrows an entry to one. With nothing declared a shape is a quarter-strength blue wash inside a 1px border of the same blue at full strength; the border wears color unless borderColor says otherwise, borderWidth: 0 removes it, and color: 'transparent' makes a stroke-only outline.

Text

A text annotation takes a rich-text node and a point anchor. width is a fraction of the plot width; height follows the content:
at places the point of the text’s own box named by align, which defaults to 'center' — so the sample above centres the block on (0.4, 0.1). Pass align: 'top-left' to hang it from that corner instead. A text annotation’s box and base type come from the stylesheet: style.annotation.text({ background, alpha, borderColor, borderWidth, borderRadius, paddingInline, paddingBlock, fontFamily, fontSize, fontWeight, lineHeight, textColor }) styles every text annotation, and { annotation: id } in the options narrows an entry to one. With nothing declared the words sit on no box at all, in 15px medium type; declare a background and the box appears, with a border a shade darker than it unless borderColor says otherwise, and alpha fades the fill. The content’s own marks — a colour, a font, a size — paint over the base type, so a size mark is a multiple of the stylesheet’s fontSize.

Arrow

annotation.arrow() draws a plain arrow between two point anchors. Either end can float in panel fractions or pin to data:
An arrow’s paint comes from the stylesheet: style.annotation.arrow({ color, strokeWidth, lineType, borderColor, borderWidth, shadow }) styles every arrow, and { annotation: id } in the options narrows an entry to one. With nothing declared an arrow is a solid 4px line in black (white under a dark scheme). strokeWidth is the width on a wide panel, scaled down on narrow ones, and the arrowheads scale with it. borderWidth and shadow are the sticker look — an outline around the line and a drop shadow; the outline picks a colour that reads against the arrow unless borderColor is declared. An endpoint on a data anchor with a directional align and no offset of its own is backed off 4px from the target’s edge, so an arrow pointing at a bar stops just short of touching it. Supply an explicit offset to place it yourself.

Image

Place an image in a panel region. fit decides how it scales inside the box — 'fill' stretches, 'contain' letterboxes, 'cover' crops to fill:
Like a shape, an image also takes an optional zOrder ('background' or 'foreground', defaulting to 'foreground'). fit defaults to 'contain'. An image’s paint comes from the stylesheet: style.annotation.image({ alpha, borderRadius }) styles every image, and { annotation: id } in the options narrows an entry to one. With nothing declared an image is opaque with square corners; borderRadius rounds them, in pixels.

Sticker

A sticker pins a built-in emoji-like image to a single observation. Give it a sticker id from the catalogue and an observation anchor — it then travels with its data point through re-sorts and filters:
The catalogue holds exactly five ids — 'rocket', 'thumbs-up', 'thumbs-down', 'clapping-hands' and 'grinning-face'. An id outside it renders nothing.

Pinned number and comment

Both pin a marker dot to one observation and place a mini bubble beside it: a pinned number shows the observation’s formatted value behind a swatch, a comment its text on one line, ellipsized when long. Hovering a bubble opens the full tooltip. Both take a bare observation anchor:
Their paint comes from the stylesheet, in two parts per kind. The marker is style.annotation.pinnedNumber({ color, size, borderColor, borderWidth, shadow }) — likewise style.annotation.comment(...) — and the bubble is their .label(...) part, on the dataLabel words plus shadow; { annotation: id } in the options narrows an entry to one. A marker whose color no entry declares borrows the observation’s colour, which the swatch inside a pinned-number bubble always keeps.

Editing annotations at runtime

Four commands cover the editing surface: MoveAnnotationCommand is the drag path: a translation in panel fractions, clamped inside the panel on apply. It is relative, so applying it twice moves twice, and it reverts through the inverse patch rather than the opposite translation — clamping isn’t invertible. AddAnnotationCommand takes a fully resolved annotation, so callers spread the kind’s defaults: { ...SHAPE_DEFAULTS, region }. Only id is optional, and it is settled in the constructor so a serialized command replays to the same spec.
  • Reference lines — for marking a constant value rather than a region
  • Highlights — the predicate language selection anchors take
  • Content — the rich-text node shape used by text and comment annotations
  • Mappings & aesthetics — the series and x-values an observation anchor names