# Chart Components

> Generated reference (regen-owned; see [index.md](index.md)). Binds only under `primitives: nurix`.

Components: Spark, Chart, ChartErrorCard, StatusTimeline, plus the chart kernel helpers

Four entry points, all resolving colour through the `--chart-*` tokens — no chart takes a hex literal as its colour path:

- **Tier 0 — marks only:** `Spark` (`element/spark`) — an axis-less inline trend.
- **Tier 1 — spec-native charts:** `Chart` (`block/chart`) — one component rendering fourteen families from a serializable `{ config, theme?, data? }` spec.
- **Status strip:** `StatusTimeline` (`compound/status-timeline`) — one stacked bar per day, no axis; not a bar chart.
- **Kernel:** `lib/chart-kernel` (+ `/arc`, `/colors`, `/line`) — the data contract, colour policy, formatters and geometry, for a bespoke mark neither `Chart` nor `Spark` draws.

## Spark

Tier-0 trend mark for a stat card or a caption — no axes, no legend, no tooltip, never a tab stop. Import from `@nurix/components/element/spark`. Install the `spark` peer family first: `@mantine/charts @mantine/core recharts`, plus `@mantine/hooks` (which `@mantine/core` peers on); no `MantineProvider` or Mantine stylesheet is needed.

**Props:** `values` (`(number | null)[]` — a `null` is a gap, never a zero), `family` [line|area|bar|pie] (default `line`), `width` / `height` (default `100`×`30` px; a class such as `w-full` overrides the width), `color` (any CSS colour, default `currentColor`), `strokeWidth` (line and area, default `1.5`), `gap` (bar, default `2`), `colors` (pie, per slice; default the `--chart-N` palette), `animate` (default `false`), `label`

- The props are a union on `family`: `strokeWidth` on bar, `gap` on line or area, `colors` off pie, and `width` or `color` on pie are type errors. A pie is a `height`×`height` square.
- It paints in `currentColor`, so a muted caption gives a muted spark; pass `color="var(--chart-1)"` for a token colour.
- It is `aria-hidden` until `label` names it (then `role="img"`) — a trend beside its number is decorative; a spark standing alone wants a label.
- A table's sparkline column is `block/table`'s `TableSparkline`, never a hand-embedded `Spark`.

```tsx
<Spark values={[4, 6, null, 9, 7, 12]} family="area" color="var(--chart-1)" className="w-full" />
```

## Chart

The spec-native chart. Import `Chart` from `@nurix/components/block/chart`. Install the `charts` and `animated-number` peer families first: `@visx/curve @visx/event @visx/grid @visx/group @visx/heatmap @visx/pattern @visx/responsive @visx/scale @visx/shape d3-array @number-flow/react`.

**Props:** `config` (what the chart means — `version`, `family`, `title`, `format`, `overlays`, `encode`, and `x` on cartesian families), `theme` (how it is presented — `context`, `motion`, `color`, `chrome`, `interaction`, `labels`), `data` (typed by `config.family`), `loading`, `onHoverChange`, `onRangeChange`, `pinnedIndex` / `defaultPinnedIndex` / `onPinnedIndexChange`, `onValidationIssue`, `replayNonce` (bump to replay enter motion), plus `ref`, `className` and any div prop, landing on the one root in every state.

- **Validation is internal and always on** — `withDefaultVersion` → `migrateSpec` → `validateChartSpec` → `diagnoseChart` → `normalize` on every render. An invalid spec never throws and never renders blank: it renders `ChartErrorCard` (`errors`, `labels`) naming the first violation's JSON Pointer. Warnings render the chart and report through `onValidationIssue`, once per distinct set.
- **`version` is `CURRENT_VERSION` (2).** A stored spec carries its own `version`; only the JSX boundary defaults a missing one. `migrateSpec` walks a spec forward only, so a spec newer than the installed renderer is rejected, never downgraded.
- **Every colour field takes a token slot, never a literal:** `chart-1`…`chart-5` (categorical, wrapping after five), `chart-positive`, `chart-negative`, `chart-diverging-mid`, `chart-scale-01`…`chart-scale-05`. `theme.color.palette` reorders the five categorical slots; an explicit series `color` wins over it.
- **Size comes from the parent.** `Chart` sets only `w-full`. The `cell`/`card` backend stretches into its parent's box; the `dashboard` backend renders nothing until it measures a non-zero ancestor — an unsized parent is a silently empty chart either way.
- **Emit-loop pre-flight:** `validateChartSpec(spec)` returns `{ ok, value | errors }`; `diagnoseChart(spec)` returns `{ errors, warnings }` over the data values (log scale over non-positive values, a palette wrap, date-like x on a category axis) — treat both as blocking before storing an artifact. `explainSpec(spec)` reports which default supplied each resolved value. `normalize` and `withDefaultVersion` are exported beside them.

### Context

`theme.context` is `cell`, `card` or `dashboard`; it picks density defaults, the capability envelope and the backend. An omitted context resolves from the host — `cell` inside a `data-mode="inline"` or `data-density="compact"` ancestor, otherwise `card`. **A host never implies `dashboard`**, so a dashboard-only family needs `theme: { context: "dashboard" }` or it fails validation with the recorded reason.

| Context | Chrome | Interaction | Motion (`auto`) | Families admitted |
| --- | --- | --- | --- | --- |
| `cell` | none | hover off unless turned on | none, whatever the request | line, area, bar, pie, progress, ring, gauge, stat |
| `card` | value labels and legend, off by default | hover on, pin off | enter | line, area, bar, pie, progress, ring, gauge, stat |
| `dashboard` | grid horizontal and both axes on; value labels and legend admissible | hover with a crosshair; `range` opt-in | full | line, area, bar, composed, scatter, funnel, heatmap-calendar, heatmap-matrix, radar |

### Families and their data

`config.family` is a closed enum, and each family takes exactly one data class. The cartesian families (`line`, `area`, `bar`, `composed`) require `x: { type: "time" | "category" | "linear", grain? }`; `scatter` requires `x: { type: "linear" | "log" | "time", grain? }`. `grain` is `hour`, `day`, `week`, `month`, `quarter` or `year`. `encode` is the one family-shaped key; an unknown key is a rejection naming the legal keys. Shared knobs keep one shape: `sort` is `"none" | "asc" | "desc"` or `{ by, order }`, `stack` is `"none" | "stacked" | "percent"`, `diverging` is `{ baseline?, positiveColor?, negativeColor? }`.

Series data is `{ series: [{ id, label, points: [{ x, y, size? }], color? }] }` — `y` may be `null`, `id` is unique and stable, series order is stacking and draw order.

- **`line`** — multi-series lines. `encode`: `curve` [linear|smooth|step|stepAfter|stepBefore], `nulls` [gap|bridge] (default `gap`), `markers`, `diverging`, `endpointLabels`.
- **`area`** — filled series. `encode`: `curve`, `nulls` [gap|bridge], `markers`, `diverging`, `stack`, `band: { lower, upper }` (series ids drawing a range band).
- **`bar`** — grouped or stacked bars. `encode`: `mode` [categorical|histogram|pareto], `stack`, `horizontal`, `sort` (by `value`, `label` or a series id), `diverging`, `bins`, `binWidth`. **Histogram:** `mode: "histogram"` bins each point's raw `y` samples, so already-binned counts are binned again; `stack` and `sort` are rejected. **Pareto:** fixed descending with a cumulative-percent line; `sort` is rejected.
- **`composed`** (dashboard) — mixed marks over one shared scale pair. `encode.series` maps each series id to `{ mark: "line" | "area" | "bar", axis?: "left" | "right" }`, at most two y axes.
- **`scatter`** (dashboard) — x/y point field. `encode`: `yScale` [linear|log], `bubble` (reads `size`), `colorByValue`, `trend` (least-squares line), `quadrant: { x?, y?, shade? }`.
- **`radar`** (dashboard) — series polygons over spider axes; spokes are the points' categorical `x`. `encode`: `scaling` [independent|shared], `maxValue`, `nulls` [gap|zero] (default `gap`).
- **`pie`** (cell, card) — data `{ parts: [{ label, value, color? }] }`. `encode`: `donut`, `half`, `sort`, `maxSlices`, `otherThreshold`, `otherLabel` (Other sorts last), `minAngle`, `center` [total|none].
- **`progress`** (cell, card) — stacked horizontal segments from `parts`, left to right; no `encode` keys; the goal tick is a `target` overlay.
- **`funnel`** (dashboard) — ordered stage bands from `parts`, top-down. `encode`: `variant` [halo|solid] (`halo` draws concentric rings at ramping opacity), `colorMode` [single|ramp|categorical], `sort` (`asc` renders a pyramid), `showValue`, `showPercent`, `showDrop`.
- **`gauge`** (cell, card) — data `{ value, min, max }`, the domain required. `encode`: `shape` [arc|linear|bullet], `fill` [solid|gradient|scale]. Threshold bands are `zone` overlays and the goal tick a `target` overlay — `shape: "bullet"` with zones and a target is the bullet chart. Hover is unavailable (one always-visible scalar).
- **`ring`** (cell, card) — concentric value-vs-max rings. Data `{ rings: [{ label, value, max, color? }] }`, innermost first. `encode`: `endAngle` (narrow it for an arc-gauge look).
- **`heatmap-calendar`** (dashboard) — contribution grid. Data `{ cells: [{ date, value }] }` with ISO dates. `encode`: `weekStartDay` (0–6), `levels`, `hideGhostCells`.
- **`heatmap-matrix`** (dashboard) — x-by-y cell matrix on the token ramp. Data `{ cells: [{ x, y, value }] }`. `encode`: `colorScaleType` [sequential|diverging], `divergingMidpoint`, `nulls` [empty|zero|hatch] (default `empty`), `xOrder` / `yOrder` (`none`, `asc`, `desc`, `{ by, order }` or an explicit list), `aggregate` [sum|mean|max|min|count], `highlight` [none|cell|cross], `showValues`.
- **`stat`** (cell, card) — a KPI. Data `{ value, delta?, history? }` (`history` is points). `encode.visual: { family: "line" | "bar" | "pie" | "gauge" | "ring" | "progress" }` draws a cell-scale mini-chart from stat's own data. A KPI laid out as a page unit is `Stat` (`element/stat`, see [layout](layout.md)).

```tsx
import { Chart } from "@nurix/components/block/chart";

<Chart
  config={{
    version: 2,
    family: "line",
    title: "Revenue",
    x: { type: "time", grain: "month" },
    format: { y: { kind: "currency", currency: "USD", compact: true } },
    encode: { curve: "smooth" },
    overlays: [{ type: "line", y: 50000, label: "Target", color: "chart-negative" }],
  }}
  theme={{ context: "dashboard", chrome: { legend: "bottom" } }}
  data={{ series: [{ id: "rev", label: "Revenue", points: [{ x: "2026-01-01", y: 42000 }, { x: "2026-02-01", y: 51000 }] }] }}
/>

<Chart
  config={{
    version: 2,
    family: "gauge",
    title: "Quota",
    encode: { shape: "bullet" },
    overlays: [{ type: "zone", from: 0, to: 60, color: "chart-scale-01" }, { type: "target", value: 80 }],
  }}
  data={{ value: 72, min: 0, max: 100 }}
/>
```

### Chrome, interaction, overlays and labels

The shared chart chrome — legend, tooltip, empty and error states — is set by the spec, never composed as children.

- **`theme.chrome`:** `grid` [none|horizontal|both], `xAxis` / `yAxis` / `yAxisRight` (`{ ticks, tickMode, format, largeNumbers, orientation }` or `false`), `legend` [none|right|bottom|inline], `valueLabels` (`true` or `{ position: "inside" | "outside", endpointOnly, total }`), `background` (a plot-area pattern). `legend` and `valueLabels` are illegal at `cell`; grid, axes and `background` are dashboard-only.
- **`theme.interaction`:** `hover`, `pin`, `range` [none|xband|box]. `pin` is legal on bar, pie, scatter, funnel and both heatmaps; `xband` on line, area, bar and composed; `box` on scatter only. Not every handler reaches the host from every renderer — dashboard hover reaches `onHoverChange` only from the two heatmaps, pin reaches `onPinnedIndexChange` only from `funnel`, and no renderer emits `onRangeChange`.
- **`config.overlays`:** `zone` (`from`, `to`, `color`, `label`), `target` (`value`), `line` (`x` or `y`), `dot` (`{ x, y }` or `{ resolve: "max" | "min" | "average", seriesId }`), `annotation` (`x`, `text`, `variant` [point|event]), `projection` (`seriesId`, `horizon`). Each is legal only on the families its envelope lists — `zone` on the cartesian families, scatter and gauge; `target` on line, area, bar, composed, gauge, progress and ring; `projection` on line and area.
- **`config.format`:** `x`, `y`, `value` channels, each `{ kind: "compact" | "int" | "percent" | "currency" | "unit" | "date", … }` with optional `prefix` / `suffix`.
- **`theme.motion`:** [none|enter|full|auto] (default `auto`, resolved by context).
- **`theme.labels`:** every string the chart writes — `empty` ("No data"), `invalid`, `invalidName`, `more`, `didYouMean`, `total`, `loading`, `retry`.

## StatusTimeline

An uptime/status strip: one stacked bar per day with a shared day card on hover, focus or pin. No axis — a quantitative time series is `Chart`, a single percentage is `element/progress`. Import `StatusTimeline` and `formatDateRange` from `@nurix/components/compound/status-timeline`.

**Props:** `data: StatusTimelineData[]`, `title` (a muted caption that also names the strip), `height` (px, default `50`), `spacing` [none|sm|md|lg] (default `sm`), `roundedCorners` [none|top|all] (default `top`), `isNormalized` (default `true`), `hasTrack`, `animate` (default `false`), `locale`, `labels`, `loading`, `emptyState`, `renderBar(segment, index, { day, total })`, `renderCard`, `renderEvent`, `render`

- One record per day: `{ day, summary?, bar: [{ intent, height }], card: [{ intent, value }], events: [{ id, name, type, from, to }] }`. `day` is an ISO date read in local time; `summary` is the card's muted header line; event `type` is `incident`, `report` or `maintenance`.
- `isNormalized` reads `bar[].height` as a percentage of 100 per day — pass raw counts with `isNormalized={false}`, or every bar mis-fills.
- Colour comes from `intent` (`neutral`, `info`, `success`, `warning`, `danger`) and the `--intent-*` tokens; retint by redeclaring the tokens, never a per-strip colour map.
- `loading` renders the skeleton; there is no separate skeleton member.
- `formatDateRange(from?, to?, { locale, labels })` formats an event's range, collapsing a same-day range.

```tsx
<StatusTimeline
  title="API uptime"
  data={[{
    day: "2026-08-07",
    summary: "1,204 requests",
    bar: [{ intent: "success", height: 92 }, { intent: "warning", height: 8 }],
    card: [{ intent: "success", value: "92%" }, { intent: "warning", value: "8%" }],
    events: [],
  }]}
/>
```

## Chart kernel

The coherence-bearing subset of the chart core, for bespoke visualization code that must match the `Chart` system. Reach for it only for a mark neither `Chart` nor `Spark` draws; needing decimation, scatter geometry, thresholds or interaction scheduling is the signal to use a chart family — those stay unpublished.

- **`lib/chart-kernel`:** the data contract types `ChartSeries` (`{ id, label, points, color? }`), `ChartPoint` (`{ x, y }`), `ChartSlice`, `ColorableSeries`; the colour policy `resolveSeriesColors(series, { useTokens })` / `resolveSeriesColor(index, explicitColor?, { useTokens })`; `buildColorScale(min, max, kind)` (`sequential` by default over `--chart-scale-01..05`, or `diverging` over `--chart-negative` / `--chart-diverging-mid` / `--chart-positive`); the formatters `formatCompactNumber`, `formatPercent(ratio, precision)`, `intFmt`, `shortDateFmt`, `weekdayDateFmt`, `monthLongFmt`, `monthShortFmt`, `weekdayLongFmt`, `hmsTimeFmt`; and `isChartDataEmpty(series)`.
- **`lib/chart-kernel/colors`:** `resolveSeriesColors`, `resolveSeriesColor` at a narrower entry.
- **`lib/chart-kernel/arc`:** `computeSliceAngles(values, startAngle, totalAngle)`, `arcCentroid`, `buildArcPath({ cx, cy, innerRadius, outerRadius, startAngle, endAngle, padAngle?, cornerRadius? })` (wedge and ring paths), `isFullArc(startAngle, endAngle)`.
- **`lib/chart-kernel/line`:** `resolveLineDomain(values)`, `createLineScales(config)`, `buildPolylinePoints`, `buildAreaPoints`, `buildLinePath(points, scales, curve, connectNulls)`, `buildAreaPath`, `buildBandAreaPath` — `curve` is `linear`, `smooth`, `step`, `stepAfter` or `stepBefore`.
- Colour resolves explicit override → `var(--chart-1..5)` → self-contained `oklch` fallbacks; pass `{ useTokens: false }` only for a shell with no theme wiring, or the marks stop re-branding with the host stylesheet.
- The formatter singletons are fixed to `en-US` at module scope and never follow the runtime locale — a localized axis builds its own `Intl` instance.

```tsx
import { resolveSeriesColors } from "@nurix/components/lib/chart-kernel";
import { computeSliceAngles, buildArcPath } from "@nurix/components/lib/chart-kernel/arc";

const colors = resolveSeriesColors(slices);
const angles = computeSliceAngles(slices.map((s) => s.value), 0, Math.PI * 2);
```
