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,
LineCursor,
} 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}>
<LineCursor />
<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 (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 role can set any of color, fillOpacity, 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) and depth (see below). Each field the role leaves out comes from the base register, and an unknown or unset role is the base register. Colour stays a theme concern (there is no per-mark colour prop): the theme owns the palette, the mark just names a role.

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.

Shipped roles: wash and argument​

How loud a mark should be at rest depends on what it is for. The built-in themes define two roles, named by intent:

RoleForResting alpha
washA tint behind data — a focus band behind a selected lap. Must not compete with the trace on top.0.2, fill 0.06
argumentA mark that is the point of the chart — the vaccine dates on a measles heat map.0.85
<Region from={lapStart} to={lapEnd} role="wash" selectable={false} />
<Marker at={licensed} label="1963 · vaccine licensed" role="argument" />

In defaultTheme neither sets a colour, so both take the register hue (and follow it if a theme recolours the register); estelaTheme's wash is a 7% tint of its foam data colour instead. A mark with no role keeps the register's own [1, 0.7, 0.4] ramp, which suits the usual case of marks secondary to a line. A theme can redefine either role, or add its own with a depth of its own.

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). How bright each level draws is the theme's annotation.depth ramp — or the mark's role's own depth, if it has one.

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​