Skip to main content

Editing & creating annotations

Annotations go from static marks to an editable layer through props on the mark plus orchestration props on <ChartContainer>. This page is the interactive surface: selecting, hovering, editing one mark, and the create gesture. For the primitives' geometry props see Region · Baseline · Marker.

The container is in editAnnotations mode below, and all three marks are controlled (each onChange writes back to state). Drag the region's body or its edges, the marker's line, or the baseline up and down:

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

const STEP_MS = 60_000;

/** A fully interactive edit sandbox. `editAnnotations` puts the container in
* edit mode (the data cursor steps aside, editable marks show handles); each
* mark is **controlled** — its `onChange` reports new geometry and we feed it
* straight back to state. Drag the region's body or edges, the marker's line,
* the baseline up and down. */
export default function ChartsAnnotationEditing() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
const base = series.timeRange()!.begin();

const [region, setRegion] = useState({
from: base + 30 * STEP_MS,
to: base + 50 * STEP_MS,
});
const [markerAt, setMarkerAt] = useState(base + 65 * STEP_MS);
const [target, setTarget] = useState(0.43);

return (
<ChartContainer
range={series.timeRange()}
width={560}
theme={theme}
editAnnotations
>
<ChartRow height={220}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
<Region
from={region.from}
to={region.to}
label="drag me"
onChange={setRegion}
/>
<Marker at={markerAt} label="deploy" onChange={setMarkerAt} />
<Baseline
value={target}
axis="pct"
label="target"
onChange={setTarget}
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}

The shared interaction props

Every annotation (Region, Marker, Baseline) carries the same five interaction props:

PropTypeDefaultPurpose
idstringStable consumer id — a click reports it via onSelectAnnotation.
selectablebooleantrueWhether the mark responds to hover + selection. false ⇒ inert background (drawn behind, no interaction).
selectedbooleanfalseControlled selection — brightens to the front (level 1). Ignored if not selectable.
hoveredbooleanfalseControlled hover (OR'd with pointer hover) — light the mark from a legend row.
editingbooleanfalsePut this one mark into single-annotation edit — handles out, draggable, level 1 — while others stay static.

The geometry callback (onChange) is per-primitive (see the previous page): Region reports { from, to }, Marker reports at, Baseline reports value. Editing is controlled — wire the reported value back to the mark's geometry prop.

Container orchestration

Selection and hover are reported by the container, which knows which mark the pointer hit. You hold the state and echo it back to each mark's props:

PropTypeDefaultPurpose
onSelectAnnotation(id: string | null) => voidAn annotation was clicked (its id), the plot was clicked empty (null), or a region was double-clicked.
onHoverAnnotation(id: string | null) => voidThe pointer entered a mark (id) or left it (null) — mirror to a controlled hovered.
onEditAnnotation(id: string) => voidA mark was double-clicked — the request to edit just that one (set its editing).
editAnnotationsbooleanfalseGlobal edit mode — suppresses the data cursor and turns on editable marks' handles.
snapbooleantrueSnap a dragged mark to other marks' guidelines (alignment). See below.

The selection pattern is: hold selectedId, set each mark's selected={mark.id === selectedId}, and update it from onSelectAnnotation. Single-mark editing is the same shape with editingId and editing driven by onEditAnnotation (double-click). Global editAnnotations is the mode toggle that gets the data cursor out of the way and lights up every editable mark's affordances at once.

Creating annotations

Arm a creation tool and the plot captures the next gesture:

PropTypePurpose
creatingAnnotationKind | nullThe armed tool — 'region', 'marker', or 'baseline'; null = idle. Requires editAnnotations.
onCreate(spec: CreateSpec) => voidFires on gesture release with the new mark's geometry.

CreateSpec is a discriminated union matching the armed kind:

type AnnotationKind = 'region' | 'marker' | 'baseline';

type CreateSpec =
| { kind: 'marker'; at: number }
| { kind: 'baseline'; value: number; axis: string }
| { kind: 'region'; from: number; to: number };

The flow is spring-loaded: arm creating, the user drags a preview, onCreate fires, you add the mark to your list, disarm (creating={null}), and select the new mark. Keep creating set to place several in a row.

Snapping

snap (default true) snaps a dragged mark to other marks' guidelines — their x-positions, within a few pixels — so spans line up. Turn it off for free placement.

Snapping to the nearest data sample is not implemented — alignment is mark-to-mark only. An annotation lands where you drop it (or on a neighbour's guide), never auto-attached to a data point.

Sharp edges

  • Editing is controlled. onChange reports the new geometry; the mark doesn't move until you feed it back. Same for selected / editing.
  • Single-select, single-edit. One selected mark, one editing mark at a time — editing is per-mark but the double-click flow targets one.
  • Snap is guideline-only, not data-sample-aware (above).
  • Mouse-only — drag to move/resize, double-click to edit.

See also