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.
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
vizroles). - Annotations use the theme's dedicated
annotationhue — 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:
| Mode | Props | Behaviour |
|---|---|---|
| Inert | selectable={false} | Background context — no hover, no select, no edit; drawn at level 3. |
| Selectable | default (selectable omitted) | Hover-highlights, click reports via onSelectAnnotation. |
| Editing | editing (+ 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
- Region · Baseline · Marker — the per-primitive props.
- Zone — the y-span band, and why its defaults invert the family's.
- Editing & creating annotations — the interactive surface.
- Axis indicators & live values — pinned pills and the live path.
- Learn charts, chapter 7 — the tutorial.