Skip to main content

The annotation model

Annotations are the marks you add to a chart to call out meaning — an incident window, a deploy, an SLA line. @pond-ts/charts has four primitives, all rendered as <Layers> children alongside the data: Region (a shaded x span), Marker (a vertical line at an x), Baseline (a horizontal line at a y), and Zone (a shaded y span — Region's counterpart on the value axis, for scales like AQI categories or heart-rate zones). This page is the shared mental model; the next pages are the per-primitive props, the editing/creating surface, and axis indicators.

Chapter 7 teaches placing them; this section is the reference. See Learn charts, chapter 7 for the tutorial and the Annotations/Scenarios Storybook group for worked scenarios.

src/examples/learn-07-annotations.tsx
import {
Baseline,
ChartContainer,
ChartRow,
Layers,
LineChart,
Marker,
Region,
YAxis,
YAxisIndicator,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';

const STEP_MS = 60_000;

export default function LearnAnnotations() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
// singleHostSeries() always returns a non-empty, fixed-length series, so
// timeRange() is never undefined here.
const base = series.timeRange()!.begin();
const latest = series.at(series.length - 1)?.get('cpu');

return (
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
<ChartRow height={220}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
<Region
from={base + 40 * STEP_MS}
to={base + 55 * STEP_MS}
label="busy"
/>
<Marker at={base + 40 * STEP_MS} label="deploy" />
<Baseline value={0.4} axis="pct" label="target" />
{latest !== undefined && <YAxisIndicator value={latest} axis="pct" />}
</Layers>
</ChartRow>
</ChartContainer>
);
}

Two registers

A chart draws in two colour registers, and annotations live in the second:

  • Data uses the series palette (the theme's viz roles).
  • Annotations use the theme's dedicated annotation hue — never a data hue. This is deliberate: a mark you added should never be mistaken for a series. Don't override an annotation's colour to match a series; that collapses the two registers.

Roles within the register

One register doesn't mean one colour. A theme can define named roles under annotation.roles, and a mark picks one with its role prop — so a vol smile can place a green ATM baseline, a blue reference marker, and an amber zone at once, each still an annotation (shared depth ramp, distinct from data):

// `v` resolves each CSS var to a concrete colour at theme-build time — the
// value becomes an SVG `stroke`, which can't itself resolve `var()`.
const theme = cssVarTheme(defaultTheme, (v) => ({
annotation: {
roles: {
atm: { color: v('--green') },
ref: { color: v('--blue') },
span: { color: v('--amber'), fillOpacity: 0.15 }, // optional per-role fill
// optional per-role dash — a dashed mark reads as placed, not measured
target: { color: v('--slate'), dash: [6, 4] },
},
},
}));
// …
<Baseline value={atm} role="atm" />
<Marker at={ref} role="ref" />
<Region from={a} to={b} role="span" />

A mark resolves roles[role] ?? annotation — an unknown or unset role is the base annotation colour. Colour stays a theme concern (there is no per-mark colour prop): the theme owns the palette, the mark just names a role.

Roles carry color, optionally fillOpacity, and optionally dash (px on/off lengths for the mark's lines — fills are never dashed; dash can also be set once on the register to apply to every mark).

This is also how a scale is styled: a Zone set names one role per band (good, moderate, …) and the theme holds the whole palette in one place, rather than six colours spread across call sites.

Depth and brightness

Marks sort into three depth levels, so the one you're interacting with reads in front:

  • Level 1 (front) — the selected or editing mark, brightened.
  • Level 2 — normal interactive marks.
  • Level 3 (back) — inert context (selectable={false}), always drawn behind the data-facing marks.

You don't set levels directly; they follow from selected / editing / selectable (see Editing & creating).

Three interaction modes

Every annotation is in one of three modes, set by its props:

ModePropsBehaviour
Inertselectable={false}Background context — no hover, no select, no edit; drawn at level 3.
Selectabledefault (selectable omitted)Hover-highlights, click reports via onSelectAnnotation.
Editingediting (+ onChange)Draggable handles out; reports its new geometry as you drag.

Pills vs. flags — the indicator law

A mark has two kinds of label:

  • A near-line chip (a flag off the line) — its label, free text.
  • An on-axis pill — enabled with indicator, pinned to the axis edge like a tick.

The indicator law: an on-axis pill always shows the formatted axis coordinate, never a custom label. A name belongs on the near-line chip; the axis pill reads like a tick. This is why YAxisIndicator (a pure pill) has no label prop at all — see Axis indicators & live values.

Sharp edges

  • Annotations don't snap to data samples. Dragging snaps to other marks' guidelines (alignment), not to the nearest data point — see Editing & creating.
  • Selection is single. One annotation is selected at a time (as with data-mark selection).
  • Colour is the annotation register. Don't repaint a mark to a series hue.

See also