Annotations
Declarative marks in data coordinates — drawn on top of the series and surviving zoom/resize. Interactive drawing is planned for future phases.
Modular build
When building with grafit-charts/core, annotations are a separate module: register(annotationsModule).
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Annotations' },
series: [{ type: 'line', xField: 'month', yField: 'price', name: 'Price' }],
annotations: [
{ type: 'horizontal-line', value: 180, stroke: '#e5484d', label: { text: 'resistance 180' } },
{ type: 'range', axis: 'x', range: ['Mar', 'Apr'], label: { text: 'correction' } },
{ type: 'line', start: { x: 'Jan', y: 140 }, end: { x: 'Aug', y: 196 }, stroke: '#21a06c', lineDash: [6, 4] },
{ type: 'text', x: 'Jun', y: 192, text: 'peak' },
],
legend: { enabled: false },
};
}export function getData() {
return [
{ month: 'Jan', price: 142 },
{ month: 'Feb', price: 149 },
{ month: 'Mar', price: 161 },
{ month: 'Apr', price: 155 },
{ month: 'May', price: 171 },
{ month: 'Jun', price: 188 },
{ month: 'Jul', price: 179 },
{ month: 'Aug', price: 195 },
];
}Horizontal and vertical lines can be dragged with the mouse (always enabled).
Types
| Type | Fields | Description |
|---|---|---|
horizontal-line | value, stroke?, lineDash?, label? | horizontal level |
vertical-line | value (category/date), … | vertical mark |
line | start: {x, y}, end: {x, y} | arbitrary segment (trend line) |
text | x, y, text, color?, fontSize? | label at a data point |
range | axis: 'x' | 'y', range: [a, b], fill?, label? | filled range |
Values the data decides
A line at "the average" should move when the data does, so value also takes the question instead of the answer — { stat, field }, recomputed on every update:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
// Reference lines that follow the data: the median and the p95 of the same field
// the histogram bins, recomputed on every update.
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Response time' },
subtitle: { text: 'median and p95 as computed annotations' },
series: [{ type: 'histogram', xField: 'response', name: 'Requests', binWidth: 25, fillOpacity: 0.7 }],
annotations: [
{
type: 'vertical-line',
value: { stat: 'median', field: 'response' },
stroke: '#21a06c',
label: { formatter: (value) => `median ${Math.round(value)} ms` },
},
{
type: 'vertical-line',
value: { stat: 'percentile', percentile: 95, field: 'response' },
stroke: '#e5484d',
label: { formatter: (value) => `p95 ${Math.round(value)} ms` },
},
],
legend: { enabled: false },
};
}export function getData() {
const values: Array<{ response: number }> = [];
for (let i = 0; i < 400; i++) {
const u = ((i * 9301 + 49297) % 233280) / 233280;
const v = ((i * 7621 + 1) % 233280) / 233280;
values.push({ response: Math.round(120 + 55 * (Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v))) });
}
return values.filter((d) => d.response > 0 && d.response < 320);
}annotations: [
{ type: 'vertical-line', value: { stat: 'median', field: 'response' } },
{
type: 'vertical-line',
value: { stat: 'percentile', percentile: 95, field: 'response' },
label: { formatter: (value) => `p95 ${Math.round(value)} ms` },
},
];stat is one of 'mean', 'median', 'min', 'max', 'sum', 'percentile' (with percentile: 0…100).
weightField names the column holding how many records a row stands for. Data that arrives pre-aggregated — one row per bucket with a count beside it — otherwise gets the statistic of the buckets rather than of the records: without weights the median of [{ms: 10, n: 900}, {ms: 900, n: 1}] is 455, with them it is 10. A weighted mean is Σ w·x / Σ w; a weighted median or percentile is the value at which the running weight crosses the mark, without interpolating between neighbours — the answer is a value that was actually recorded. Rows whose weight is missing, zero or negative are left out. Both ends of a range take the same descriptor, which is how a band between two percentiles is written:
{
type: 'range',
axis: 'y',
range: [
{ stat: 'percentile', percentile: 25, field: 'price' },
{ stat: 'percentile', percentile: 75, field: 'price' },
],
}label.formatter is handed the number the line landed on — that is the point of a computed level: the label says p95 208 ms without anyone typing 208. A computed line cannot be dragged (it would snap back on the next frame), and a statistic with no numeric rows behind it leaves its annotation undrawn.
Full list of options
| Option | Type | Default | Description |
|---|---|---|---|
strokeWidth | lines | 1 | annotation line width |
fillOpacity | range | 0.12 | range fill opacity |
label.text | string | — | line label (horizontal/vertical-line) |
label.formatter | (value: number) => string | — | label built from the line's value |
label.fontSize | Pixels | 11 | label font size |
label.color | ColorValue | line color | label color |
Coordinates are specified as data values: categories/dates for X, numbers for Y.
horizontal-line and vertical-line can be dragged with the mouse — the value updates along the scale (categorical lines snap to the nearest category).