Annotated Time Series
A time series that annotates its own peaks, dips, trend shifts and plan crossings, with an auto-generated headline. The self-explaining chart.
Acme · Monthly recurring revenue
$5680k
latest · Revenue
- Revenue
- Plan
Revenue peaked at $5.84M in Dec 2025.
| Month | Revenue | Plan | Note |
|---|---|---|---|
| Jan 2025 | 3,100,000 | 3,150,000 | Trough |
| Feb 2025 | 3,260,000 | 3,280,000 | |
| Mar 2025 | 3,460,000 | 3,420,000 | Beats plan |
| Apr 2025 | 3,680,000 | 3,560,000 | |
| May 2025 | 3,850,000 | 3,710,000 | |
| Jun 2025 | 4,020,000 | 3,860,000 | |
| Jul 2025 | 4,180,000 | 4,010,000 | |
| Aug 2025 | 4,340,000 | 4,160,000 | |
| Sep 2025 | 4,480,000 | 4,310,000 | |
| Oct 2025 | 4,620,000 | 4,460,000 | |
| Nov 2025 | 5,280,000 | 4,610,000 | |
| Dec 2025 | 5,840,000 | 4,760,000 | Peak |
| Jan 2026 | 5,420,000 | 4,910,000 | |
| Feb 2026 | 5,680,000 | 5,060,000 |
Acme analytics · updated hourly · anomalies auto-detected
Demo data is illustrative. Replace with your own typed data prop.
When to reach for it
Use the Annotated Time Series when a line would make a reader hunt for the story — the month growth crossed plan, the spike, the moment the trend turned. Instead of a bare series, the chart marks its own salient points and writes the headline for you. Reach for it in board decks and exec dashboards where the chart has to explain itself in a glance.
How it thinks
deriveAnnotations() runs entirely deterministically (no LLM) over your series and ranks the features it finds:
- Peak / trough — the global extremes, scored by how many σ they sit from the mean.
- Anomaly — points past a z-score threshold, labelled with their σ.
- Trend shift — the sharpest change in slope (largest second difference).
- Crosses plan — the first time actual moves above (beats plan) or below (misses plan) the comparison line.
- Last seen here — the most recent earlier point at today's level.
The top features become collision-avoided callouts on the plot, and the rank-0 feature is handed to summarize() for the one-sentence narrative above the chart — e.g. "Revenue peaked at $225k in Sep 2025."
Good to know
- Pass a
planvalue on each point to unlock the crosses-plan annotation; omit it and the engine simply works from the series alone. - Annotations are decorative SVG (
aria-hidden); the accessible representation is the data table, where each annotated month carries its note in the Note column. - Same data in → same annotations out. Nothing is sampled or random, so snapshots and screenshots are stable.
Use it with your data
Data contract
Passed via the series prop — an array of objects: validated at runtime, so a bad shape degrades to the error state rather than crashing.
| Field | Type | Required |
|---|---|---|
| date | string | yes |
| value | number | yes |
| plan | number | no |
Static props
import { ChartAnnotatedTimeseries } from "@/components/blocks/chart-annotated-timeseries/chart-annotated-timeseries";
<ChartAnnotatedTimeseries series={myData} />Client fetch (SWR)
const { data, isLoading, error } = useSWR("/api/metric", fetcher);
// route isLoading/error through <ChartStates>, then:
<ChartAnnotatedTimeseries series={data} />Server component (RSC)
const series = await fetch(url, {
next: { revalidate: 3600 },
}).then((r) => r.json());
<ChartAnnotatedTimeseries series={series} />Map arbitrary rows
import { mapRows } from "@/lib/sextant/column-map";
const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <ChartAnnotatedTimeseries series={out.data} />;Make it yours
Every customization below is an optional prop — omit them all and you get the defaults shown above. Recolor, reformat, restyle, or drop the card chrome without forking the component.
Recolor or rename a series (value, plan, annotation)
<ChartAnnotatedTimeseries
series={{ value: { color: "var(--chart-3)", label: "…" } }}
/>Format numbers (currency / locale)
const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
notation: "compact",
});
<ChartAnnotatedTimeseries valueFormatter={(n) => eur.format(n)} />Replace or hide the narrative
<ChartAnnotatedTimeseries narrative="Your own one-liner." />
// …or hide it entirely:
<ChartAnnotatedTimeseries narrative={false} />Restyle any region
<ChartAnnotatedTimeseries
className="max-w-xl"
classNames={{ body: "bg-muted/20" }}
/>Drop the card chrome (headless plot)
import { ChartAnnotatedTimeseriesPlot } from "@/components/blocks/chart-annotated-timeseries/chart-annotated-timeseries";
// Just the chart body — bring your own card/layout:
<ChartAnnotatedTimeseriesPlot series={series} config={config} />