Anomaly Time Series
freeArea chart that auto-detects anomalies via z-score and surfaces the top deviation as a plain-language narrative.
Editable — click an element to tune it
npx shadcn add @sextant/chart-anomaly-timeseriesMonthly recurring revenue
4,758,780
1 anomaly flagged at 2σ
- Revenue
- Anomalies
Revenue dropped to 3,950,000 on Feb 5, 2026, 2.6σ below a 4,500,741.717 baseline — driven by Stripe outage.
Acme · billing data · updated hourly
Generated code
<ChartAnomalyTimeseries />Source — this is the whole widget
"use client";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
ComposedChart,
Scatter,
XAxis,
YAxis,
} from "recharts";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import {
detectAnomalies,
explainTopAnomaly,
} from "@/registry/default/sextant-insights/stats";
import type { Insight } from "@/registry/default/sextant-insights/types";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { anomalySeriesSchema, anomalySeries, type AnomalyPoint } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { anomalySeriesSchema, type AnomalyPoint } from "./data";
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Ink", value: "var(--chart-2)" },
{ name: "Cool gray", value: "var(--chart-3)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Terracotta", value: "var(--chart-negative)" },
{ name: "Honey", value: "var(--chart-warning)" },
];
const THRESHOLD_OPTIONS = [
{ value: "1.5", label: "1.5σ — loose", sample: "more flags" },
{ value: "2", label: "2σ — standard", sample: "default" },
{ value: "3", label: "3σ — strict", sample: "rare events" },
];
const MARKER_OPTIONS = [
{ value: "circle", label: "Circle", sample: "●" },
{ value: "diamond", label: "Diamond", sample: "◆" },
{ value: "ring", label: "Ring", sample: "○" },
];
const ANOMALY_PLAN: SizePlan = {
lg: { shape: "full", height: "h-64", legend: "full", ticksX: 6 },
md: { shape: "full", height: "h-56", legend: "inline", ticksX: 4 },
sm: { shape: "spark" },
mobile: { shape: "full", height: "h-64", legend: "inline", ticksX: 4, stack: true },
};
/** A row of the main series with its anomaly value attached (null when normal). */
export type AnomalyChartDatum = AnomalyPoint & { anomaly: number | null };
/**
* The two encodings this widget draws: the `value` area/line and the `anomaly`
* markers riding on top of it. Each is independently configurable.
*/
export type AnomalyTimeseriesSeries = {
value?: SeriesConfig;
anomaly?: SeriesConfig;
};
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
data?: AnomalyPoint[];
// ── Card chrome ───────────────────────────────────────────────────────────
label?: string;
metric?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/narrative/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Per-series color / rename / hide: `series={{ anomaly: { color: "var(--brand)" } }}`. */
series?: AnomalyTimeseriesSeries;
/** Override number formatting everywhere (axis, hero, tooltip) — e.g. EUR/locale. */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
/** The widget's default value formatter — plain locale grouping. */
function fmtValue(n: number): string {
return n.toLocaleString();
}
/**
* Pure: flag anomalies ON the main series (keyed by date) so the markers share
* the chart's category axis and land on the line. Exported so the headless
* `<ChartAnomalyTimeseriesPlot>` and your own code merge the same way.
*/
export function buildAnomalyChartData(
data: AnomalyPoint[],
anomalies: Array<AnomalyPoint & { z: number }>,
): AnomalyChartDatum[] {
const flagged = new Map(anomalies.map((a) => [a.date, a.value]));
return data.map((d) => ({
...d,
anomaly: flagged.has(d.date) ? (flagged.get(d.date) as number) : null,
}));
}
export function ChartAnomalyTimeseries(props: Props) {
// Validate consumer data at the boundary: a bad shape shows the error
// state instead of crashing. The built-in demo data is trusted, so skip it.
const parsed =
props.data === undefined ? null : safeData(anomalySeriesSchema, props.data);
if (parsed && !parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
return <ChartAnomalyTimeseriesImpl {...props} />;
}
function ChartAnomalyTimeseriesImpl({
data = anomalySeries,
label,
metric = "Revenue",
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Monthly recurring revenue", {
label: "Chart title",
});
const threshold = studio.format("threshold", "2", {
label: "Anomaly threshold",
options: THRESHOLD_OPTIONS,
});
// `series.*.color` sets the default; Studio (when mounted) still edits on top.
const seriesColor = studio.color(
"seriesColor",
series?.value?.color ?? "var(--chart-3)",
{
label: "Series color",
presets: COLOR_PRESETS,
},
);
const markerColor = studio.color(
"markerColor",
series?.anomaly?.color ?? "var(--chart-1)",
{
label: "Marker color",
presets: COLOR_PRESETS,
},
);
const markerStyle = studio.format("markerStyle", "circle", {
label: "Marker style",
options: MARKER_OPTIONS,
});
const showCallout = studio.toggle("showCallout", true, {
label: "Show narrative callout",
onLabel: "Visible",
offLabel: "Hidden",
});
const attributionText = studio.text(
"attribution",
attribution ?? "Acme · billing data · updated hourly",
{ label: "Attribution" },
);
const valueLabel = series?.value?.label ?? metric;
const anomalyLabel = series?.anomaly?.label ?? "Anomalies";
const valueHidden = series?.value?.hidden ?? false;
const anomalyHidden = series?.anomaly?.hidden ?? false;
const fmt = valueFormatter ?? fmtValue;
const config = {
value: { label: valueLabel, color: seriesColor },
} satisfies ChartConfig;
const anomalies = React.useMemo(
() => detectAnomalies(data, (r) => r.value, parseFloat(threshold)),
[data, threshold],
);
// The single most extreme point drives the narrative — explained against the
// same global baseline the markers use, so the sentence states how many σ it
// sits *above/below the baseline mean* (not a noisy first-observation anchor).
const topAnomaly = React.useMemo(
() => explainTopAnomaly(data, (r) => r.value, parseFloat(threshold)),
[data, threshold],
);
const insight: Insight | undefined = topAnomaly
? {
kind: "anomaly",
metric,
sigma: topAnomaly.z,
date: format(parseISO(topAnomaly.point.date), "MMM d, yyyy"),
value: topAnomaly.value,
baseline: topAnomaly.baseline.mean,
driver: topAnomaly.point.driver,
}
: undefined;
const statusText =
anomalies.length === 0
? "No anomalies above threshold"
: `${anomalies.length} anomal${anomalies.length === 1 ? "y" : "ies"} flagged at ${threshold}σ`;
return (
<ChartCard.Root size={size} className={cn(className, classNames?.root)}>
<AnomalyInner
data={data}
anomalies={anomalies}
config={config}
insight={insight}
narrative={narrative}
showCallout={showCallout}
title={title}
metric={metric}
valueLabel={valueLabel}
anomalyLabel={anomalyLabel}
valueHidden={valueHidden}
anomalyHidden={anomalyHidden}
statusText={statusText}
seriesColor={seriesColor}
markerColor={markerColor}
markerStyle={markerStyle}
valueFormatter={valueFormatter}
fmt={fmt}
attributionText={attributionText}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: AnomalyPoint[];
anomalies: Array<AnomalyPoint & { z: number }>;
config: ChartConfig;
insight: Insight | undefined;
narrative: NarrativeProp | undefined;
showCallout: boolean;
title: string;
metric: string;
valueLabel: string;
anomalyLabel: string;
valueHidden: boolean;
anomalyHidden: boolean;
statusText: string;
seriesColor: string;
markerColor: string;
markerStyle: string;
valueFormatter?: (n: number) => string;
fmt: (n: number) => string;
attributionText: string;
classNames?: ChartClassNames;
};
function AnomalyInner({
data,
anomalies,
config,
insight,
narrative,
showCallout,
title,
valueLabel,
anomalyLabel,
valueHidden,
anomalyHidden,
statusText,
seriesColor,
markerColor,
markerStyle,
valueFormatter,
fmt,
attributionText,
classNames,
}: InnerProps) {
const spec = useSizePlan(ANOMALY_PLAN);
const last = data[data.length - 1]?.value;
const latest = last == null ? undefined : fmt(last);
// Resolve the narrative once so we can gate the StudioTarget on a real node:
// with defaults this is the auto-insight element (or null when there's no
// anomaly / `narrative={false}`), preserving the original `&& insight` gating.
const narrativeNode = resolveNarrative(narrative, insight, {
className: classNames?.narrative,
});
if (spec.shape === "spark") {
return (
<SparkTile
label={title}
value={latest}
chip={<ChartNarrative text={statusText} variant="chip" />}
spark={
<ChartContainer config={config} className="h-full w-full">
<AreaChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<Area
dataKey="value"
type="monotone"
stroke={seriesColor}
strokeWidth={1.5}
fill={seriesColor}
fillOpacity={0.12}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
</ChartContainer>
}
/>
);
}
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{latest}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">{statusText}</p>
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") && (
<ul className="flex flex-col gap-1.5 text-right group-data-[size=mobile]/chart-card:flex-row group-data-[size=mobile]/chart-card:gap-4 group-data-[size=mobile]/chart-card:text-left">
{!valueHidden && (
<li className="smallcaps inline-flex items-center justify-end gap-2">
<span
aria-hidden
className="size-2 rounded-sm"
style={{ backgroundColor: seriesColor }}
/>
{valueLabel}
</li>
)}
{!anomalyHidden && (
<li className="smallcaps inline-flex items-center justify-end gap-2">
<AnomalyGlyph color={markerColor} style={markerStyle} />
{anomalyLabel}
</li>
)}
</ul>
)}
</ChartCard.Header>
{showCallout && narrativeNode && (
<StudioTarget fieldKey="showCallout" variant="inline">
{narrativeNode}
</StudioTarget>
)}
<ChartCard.Body className={classNames?.body}>
<ChartAnomalyTimeseriesPlot
data={data}
anomalies={anomalies}
config={config}
heightClass={spec.height}
ticksX={spec.ticksX}
seriesColor={seriesColor}
markerColor={markerColor}
markerStyle={markerStyle}
valueFormatter={valueFormatter}
hidden={{ value: valueHidden, anomaly: anomalyHidden }}
/>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}
type PlotProps = {
/** The raw main series. */
data: AnomalyPoint[];
/** Detected anomalies (date + value + z) to flag onto the series. */
anomalies: Array<AnomalyPoint & { z: number }>;
/** Series color + label (a shadcn ChartConfig keyed by `value`). */
config: ChartConfig;
heightClass?: string;
ticksX?: number;
seriesColor?: string;
markerColor?: string;
markerStyle?: string;
/** Override axis + tooltip number formatting (e.g. EUR/locale). */
valueFormatter?: (n: number) => string;
/** Hide individual encodings (default: all shown). */
hidden?: { value?: boolean; anomaly?: boolean };
};
/**
* Just the chart-drawing piece — the value area plus the anomaly markers riding
* on it, with axes and tooltip but no card chrome, narrative, or states. Takes
* the raw series + the detected anomalies and merges them internally, so you can
* drop it into your own layout or compose several side by side.
*/
export function ChartAnomalyTimeseriesPlot({
data,
anomalies,
config,
heightClass = "h-64",
ticksX = 6,
seriesColor = "var(--chart-3)",
markerColor = "var(--chart-1)",
markerStyle = "circle",
valueFormatter,
hidden,
}: PlotProps) {
// Unique per instance so several Plots on one page don't share a gradient.
const gradientId = `at-fill-${React.useId().replace(/:/g, "")}`;
const chartData = React.useMemo(
() => buildAnomalyChartData(data, anomalies),
[data, anomalies],
);
// Default axis ticks: compact "k" above 1000. Overridden only when a custom
// valueFormatter is supplied (so the default render stays byte-identical).
const formatAxis = valueFormatter
? (v: number) => valueFormatter(v)
: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`);
// The tooltip's `formatter` replaces the whole row, so when overriding the
// value format we re-emit the dot + label to match the default look.
// Always format — even with no `valueFormatter` — so the tooltip matches the
// axis instead of falling back to the generic raw-number dump.
const formatTip =
valueFormatter ??
((v: number) => (v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`));
const tooltipFormatter: React.ComponentProps<
typeof ChartTooltipContent
>["formatter"] = (value, name, item) => (
<>
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
style={{ backgroundColor: (item as { color?: string }).color }}
/>
<span className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">
{config[name as string]?.label ?? String(name)}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{formatTip(Number(value))}
</span>
</span>
</>
);
return (
<ChartContainer config={config} className={cn(heightClass, "w-full")}>
<ComposedChart data={chartData} margin={{ left: 0, right: 8, top: 8 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={seriesColor} stopOpacity={0.32} />
<stop offset="95%" stopColor={seriesColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="var(--chart-grid)" />
<XAxis
dataKey="date"
tickFormatter={(v: string) => format(parseISO(v), "MMM d").toUpperCase()}
tickLine={false}
axisLine={false}
tickMargin={10}
fontSize={10}
minTickGap={ticksX >= 6 ? 32 : 48}
className="smallcaps"
/>
<YAxis
tickFormatter={(v: number) => formatAxis(v)}
tickLine={false}
axisLine={false}
width={36}
fontSize={10}
className="smallcaps tabular"
/>
<ChartTooltip
cursor={{
stroke: "var(--foreground)",
strokeOpacity: 0.25,
strokeDasharray: "2 4",
}}
content={
<ChartTooltipContent
labelFormatter={(v) => format(parseISO(String(v)), "MMM d, yyyy")}
formatter={tooltipFormatter}
/>
}
/>
{!hidden?.value && (
<Area
dataKey="value"
type="monotone"
stroke={seriesColor}
strokeWidth={1.5}
fill={`url(#${gradientId})`}
/>
)}
{/* Anomaly markers ride the `anomaly` field on the main series, so
they share the category axis and sit on the line at the right
dates (not bunched left by a separate-data Scatter's index). */}
{!hidden?.anomaly && (
<Scatter
dataKey="anomaly"
tooltipType="none"
fill={markerColor}
shape={(props: {
cx?: number;
cy?: number;
payload?: { anomaly?: number | null };
}) =>
// Non-anomaly rows carry a null `anomaly` and would otherwise
// paint a marker at cy=0 (a row of dots along the top); skip them.
props.payload?.anomaly == null || props.cy == null ? (
<g />
) : (
<AnomalyMarker
cx={props.cx ?? 0}
cy={props.cy}
color={markerColor}
style={markerStyle}
/>
)
}
isAnimationActive={false}
/>
)}
</ComposedChart>
</ChartContainer>
);
}
function AnomalyMarker({
cx,
cy,
color,
style,
}: {
cx: number;
cy: number;
color: string;
style: string;
}) {
if (style === "diamond") {
return (
<g transform={`translate(${cx} ${cy})`}>
<rect
x={-5}
y={-5}
width={10}
height={10}
transform="rotate(45)"
fill={color}
stroke="var(--background)"
strokeWidth={1.5}
/>
</g>
);
}
if (style === "ring") {
return (
<circle
cx={cx}
cy={cy}
r={5}
fill="var(--background)"
stroke={color}
strokeWidth={2}
/>
);
}
// circle (default)
return (
<circle
cx={cx}
cy={cy}
r={4}
fill={color}
stroke="var(--background)"
strokeWidth={1.5}
/>
);
}
function AnomalyGlyph({ color, style }: { color: string; style: string }) {
return (
<span
aria-hidden
className="inline-block size-2"
style={{
backgroundColor: style === "ring" ? "transparent" : color,
border: style === "ring" ? `1.5px solid ${color}` : "none",
borderRadius: style === "diamond" ? 0 : "1px",
transform: style === "diamond" ? "rotate(45deg)" : undefined,
}}
/>
);
}