Skip to main content
Annotations draw callouts over the plot: a shaded region, a labeled arrow, a note pinned to a spike. They carry information the data does not contain, and the annotation builder pipes them into the spec like any other feature. Annotations resolve again on every compile. Panel-anchored ones re-flow when the chart resizes; data-anchored ones move with the observation they are pinned to.

Anchoring

An anchor positions every annotation. It 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

PointAnchorSpec is a union of five anchorTypes:

Addressing a cell or a point

An observation anchor normally narrows by group: anchorValue names the position on the main axis and groupValue picks the group. On a heatmap or a scatter one main-axis value names several observations, a whole column of cells or every point at that x, so crossValue names the value on the other axis that picks one out:
Only a layer whose geom places observations in two dimensions (tiles and points) reads crossValue; every other layer ignores it. A tile layer has no groups, so it ignores groupValue. A cell anchor resolves to the center of its tile.

Region anchors

RegionAnchorSpec is a union of three: padding is 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 around bars does not touch 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’s natural point, such as the midpoint of a bar’s top edge. 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'. Give the layer you mean an id and pass it as layerId:
Omit layerId and the anchor resolves against the first layer that matches, skipping layers marked interactive: false. A named layer is searched whatever it is. An id no layer carries drops that annotation with an UNKNOWN_LAYER_ID warning rather than moving it somewhere else. The kinds differ in what they take: pinnedNumber and comment take a bare ObservationAnchorSpec, the fields above with no anchorType key, while text, sticker and arrow take a full PointAnchorSpec, where anchorType is required.

Ids and references

An annotation’s id is unique across every kind, and an annotation anchor’s ref points at it. 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 it never displaces anything.

The annotation kinds

Each method on annotation adds 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:
This is distinct from annotation.arrow(), a plain arrow between two positions you specify, which reads no data. A difference arrow’s paint comes from the stylesheet. style.annotation.differenceArrow({ stroke, strokeWidth }) styles every arrow’s line; style.annotation.differenceArrow.label({ fontSize, fontWeight, textColor, fill, stroke, strokeWidth, cornerRadius, paddingInline, paddingBlock, ... }) styles its label; and { annotation: id } in the options narrows an entry to one arrow. With nothing declared the line is 2px and the label an 11.5px boxed number, and the route around the observations scales with strokeWidth. An arrow with no declared stroke takes the color its two ends share, when both sit on one layer and one group with the same color; otherwise it is a dark stroke (light under a dark scheme). The label’s box border takes the arrow’s color unless the label declares its own stroke.
A difference only means something when both ends measure the same thing. Endpoints on different bases, such as 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 color under the geoms needs 'background' written out, as above. A shape’s paint comes from the stylesheet: style.annotation.shape({ fill, fillAlpha, alpha, stroke, strokeWidth }) 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 takes fill unless stroke says otherwise, strokeWidth: 0 removes it, and fill: 'transparent' leaves only the border.

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 that align names, 'center' by default, so the sample above centers 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({ fill, fillAlpha, alpha, stroke, strokeWidth, cornerRadius, 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 fill and the box appears, with a border a shade darker than the fill unless stroke says otherwise; fillAlpha fades the fill and alpha the whole box. The content’s own formatting (a color, a font, a size) paints over the base type, so a size in the content 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({ stroke, strokeWidth, dashArray }) styles every arrow, and { annotation: id } in the options narrows an entry to one. With nothing declared an arrow is a solid 4px dark line (light under a dark scheme). strokeWidth is the width on a wide panel, scaled down on narrow ones, and the arrowheads scale with it. The sticker look is the arrow’s outline part, style.annotation.arrow.outline({ stroke, strokeWidth, shadow }): an outline around the line plus a drop shadow. The outline picks a color that reads against the arrow unless stroke 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 takes an optional zOrder ('background' or 'foreground', default 'foreground'). fit defaults to 'contain'. An image’s paint comes from the stylesheet: style.annotation.image({ alpha, cornerRadius, shadow }) styles every image, and { annotation: id } in the options narrows an entry to one. With nothing declared an image is opaque with square corners and no shadow; cornerRadius rounds the corners, in pixels.

Sticker

A sticker pins a built-in emoji-like image to one observation. Give it a sticker id from the catalog and an observation anchor, and it travels with that observation through re-sorts and filters:
The catalog holds five ids: 'rocket', 'thumbs-up', 'thumbs-down', 'clapping-hands' and 'grinning-face'. Any other id renders nothing.

Pinned number and comment

Both pin a marker dot to one observation and place a small bubble beside it. A pinned number shows the observation’s formatted value behind a swatch; a comment shows its text on one line, cut with an ellipsis 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({ fill, size, stroke, strokeWidth, shadow }), or style.annotation.comment(...), and the bubble is their .label(...) part, which takes the dataLabel properties plus shadow. { annotation: id } in the options narrows an entry to one. A marker with no declared fill takes the observation’s color, and the swatch inside a pinned-number bubble always keeps that color.

Editing annotations at runtime

Four commands cover editing: 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. It reverts through the inverse patch rather than the opposite translation, because clamping cannot be inverted. 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 groups and x-values an observation anchor names