---
id: "packages/active-charts/api"
title: "API reference"
description: "The public contract of active-charts - BaseChartProps, the archetype mixins, the ChartHandle, ChartDefaults, axes, labels, data preparation and styling."
url: "/packages/active-charts/api"
locale: "en"
revision: "f1439d32f42f3019"
---

# API reference

> [!NOTE]
> **The package's own reference**
>
> The text below is the package's `docs/API.md`, included unchanged. Every
> export, generated from the source, is on [All
> exports](/packages/active-charts/all-exports).

Reference for the public contract of `@workspace/active-charts`. This document explains the *shape* of the API; full per-prop details live on the exported TypeScript types. Every chart component is typed, every field is documented inline, and the compiler is authoritative. See `CHANGELOG.md` for the per-release history.

Read this document when you need to:

- Understand why a given chart accepts (or rejects) a prop
- Know what the package exports and what stays internal
- Find where the authoring rules for a new chart family live (§ 7: ADDING-A-CHART-FAMILY.md)

---

## Public surface

The package has **one entry point**: `@workspace/active-charts` (`src/index.ts`). There are no subpath exports. It exports:

- **Chart components** and their props types: `Chart`, `LineChart`, `AreaChart`, `BarChart`, `Histogram`, `PieChart`, `ScatterChart`, `HeatmapChart`, `CandlestickChart`, `WaterfallChart`, `GaugeChart`, `RadarChart`, `TreemapChart`, `SunburstChart`, `SankeyChart`, `BoxPlotChart`, `FunnelChart`, `LinearGauge`, `BulletChart`, `OhlcChart`, `RangeAreaChart`, `RangeBarChart`, `DumbbellChart`, `LollipopChart`, `NightingaleChart`, `OrgChart`, `TimelineChart`, `RadialBarChart`, `Sparkline`.
- **Composition components:** `ChartErrorBoundary`, `ChartDefaults`, `PeriodSelector` (with the `Period` type), `SyncGroup`.
- **Bar sizing and label roles (ADR-078):** the types `BarSizingOptions`, `LabelFormatterContext`, `PieCalloutLabelOptions`, `PieSectorLabelOptions` (§ 10.2–10.4).
- **Labels and stacks (ADR-091):** the types `EndpointLabelOptions`, `StackTotalOptions` and `StackTotalFormatterContext` (§ 10.8).
- **Paint and quantitative colour (ADR-092, ADR-098):** the one quantitative colour scale `QuantitativeColorScale` and its forms `LinearColorScale`, `StopsColorScale` (`ColorStop`) and `ClassesColorScale` (`ColorClass`), with `ColorLegendOptions` and `ColorMeasureOptions` (§ 10.5, § 10.9, § 10.13), and the per-datum paint types `DatumPaintResolver`, `DatumPaintContext`, `DatumFill` and `DatumPaint` (§ 10.9).
- **Heatmap axes and labels (ADR-098, ADR-106):** `HeatmapAxisConfig`, the row and column axes of a heatmap (§ 10.13), and `HeatmapLabelOptions`, its cell labels.
- **Histogram, funnel, timeline and bar labels (ADR-103):** `BarLabelOptions` and `BarLabelPosition` (the bar's `'inside-end'` and `'inside-base'`), `FunnelDataLabelOptions` and `FunnelLabelFormatterContext`, `HistogramBinRuleOptions` and `HistogramAmountOptions` (`binWidth`, `areaProportional`), `TimelineTimeAxis` and `TimelineTime` (§ 10.16).
- **Compound series (ADR-101):** `RangeBarChartSeriesConfig`, `RangeAreaChartSeriesConfig`, `BoxPlotChartSeriesConfig` and `DumbbellChartSeriesConfig`, one series of the wrapper's `series` (§ 10.15).
- **Diagnostics (ADR-076):** the types `ChartDiagnostic`, `ChartDiagnosticLevel` and `ChartDiagnosticSink` of the `ChartDefaults` `diagnostics` sink (§ 5).
- **The options contract:** `ChartOptions` and all option, series, axis, tooltip (with `ChartTooltipContext`, what a renderer knows about the axes), legend, accessibility (`AccessibilityOptions`, with `ChartAnnouncementCategory` for `announcements.suppress`) and animation types (`AnimationOptions`, `ChartMotionProfile`, `ChartMotionEasing`, `EntranceMotionOptions`, `UpdateMotionOptions`, `ExitMotionOptions`, `InteractionMotionOptions`, `ReplayMotionOptions`, `AnimationType`, `AnimationCompleteEvent`, `ExitAnimationType`, `SeriesAnimationOptions`, `SeriesEntranceMotionOptions`, `ZoomAnimationOptions`; § 2.4), including the axis-formatter types `AxisFormatter`, `AxisTickValue`, `TimeAxisTickValue`, `NumberAxisTickValue`, `CategoryAxisTickValue` and `TickValues`. `ChartOptions.series` takes `SeriesOptions`, the thirteen Cartesian series `<Chart>` renders, and `SeriesType` is exactly their `type` (`SeriesOptions['type']`: a standalone family or a scalar is no series, ADR-106); `PieSeriesOptions` stays named for its own wrapper (`PieChart`) and is not a `<Chart>` series (ADR-077); a heatmap's renderer options are internal (its contract is `HeatmapChartProps`, ADR-098).
- **The archetype prop types** `BaseChartProps`, `CartesianChartProps`, `CategoricalChartProps`, `HierarchicalChartProps`, `GraphChartProps`, `ScalarChartProps`, plus `ChartExportHandle`, `ChartHandle`, `ChartEvents` and the context-menu element types.
- **Context menu (ADR-087):** `ChartContextMenuOptions`, `ChartContextMenuItem`, `ChartContextMenuBuilder`, `ChartContextMenuContext`, `ChartContextMenuTarget`, `ChartContextMenuFeatures`, `ChartContextMenuExportFeatures`; the copy outcome `ChartCopyError` (a class), `ChartCopyErrorCode`, `ChartCopyAction` (ADR-116) (§ 2.3).
- **Export result (ADR-105):** `ChartExportResult`, `ChartExportFormat` and `ChartExportWarning`, what every export resolves with (§ 4).
- **Headless legend (ADR-105):** the hook `useChartLegend` and the types `ChartLegend`, `ChartLegendLink`, `ChartLegendEntry`, `ChartLegendTarget`, `ChartLegendKey`, `ChartLegendAction`, `ChartLegendChange`, `ChartInteractionSource`, and the export legend types `ChartExportLegendItem` and `ChartExportLegendOptions` (§ 2.6, § 4).
- **Item selection and activation (ADR-042, ADR-102):** `ChartItem`, `ChartItemKey`, `ChartItemRef`, `ChartSelectionChange`, `ChartSelectionEvents`, `ChartSelectionOptions`, `ChartSelectionProps`, `ChartSelectionState`, and the four forms of an item and its reference: `ChartSeriesItem`, `ChartSeriesItemRef`, `ChartCellItem`, `ChartCellItemRef`, `ChartNodeItem`, `ChartNodeItemRef`, `ChartLinkItem`, `ChartLinkItemRef` (§2.1).
- **Presentation and translation:** `ChartPresentation`, `createChartAxisFormatters`, `useChartAxisFormatters` (`ChartAxisFormatters`), the translation contract `ChartTranslateFn`, `ChartTranslationKey`, `ChartTranslationVariables`, and the package's English translator `englishChartTranslator`.
- **Utilities:** `getGroupMeta` + `GROUP_META_KEY`, the standalone export functions `exportChartAsPng`, `exportChartAsSvg`, `exportChartAsPdf`, `exportChartAsCsv` (with their options types, §4), `generateAriaLabel`, `applyIndicators`, `invalidateChartStyles` (§13).

Everything else is **internal** and not published: renderers and series primitives, axis and grid components, overlays (legend, crosshairs, navigator, tooltip content, the screen-reader data table), `ResponsiveContainer`, all internal hooks (`useChartHandle`, `useChartDefaults`, `useScales`, `useLayout`, the keyboard-navigation hooks, …), scale and layout helpers, downsampling, data validation, grouping internals, theme constants and other constants. Locale-aware formatting helpers (`formatNumber`, `formatCurrency`, `formatDate`, `formatDateOnly`, …) come from `@workspace/active-format`, never from this package.

The exact export list is pinned by `src/__tests__/public-api.test.ts`; widening it is a contract change (see `DECISIONS.md` ADR-029).

The package stylesheet is not an export: the entry loads it itself (§ 13). The styling contract a host works with is the set of CSS tokens in § 13.

---

## 1. Design Principles

1. **One shared contract, five prop mixins.** Every chart except `Sparkline` (§12) extends `BaseChartProps`. The data shape is dictated by the prop mixin (cartesian / categorical / hierarchical / graph / scalar, § 3). Behaviour follows eight archetypes (ADR-111): the five, plus matrix (`HeatmapChart`, on the Cartesian mixin), temporal (`TimelineChart`) and micro (`Sparkline`); the keyboard table below is by behaviour.
2. **Feature toggles are always `T | boolean`.** `tooltip`, `legend`, `animation`, `contextMenu`, `zoom`, `navigator`, `sync`, `crosshairs`, `colorLegend` — all follow the same pattern: `true` = defaults, `false` = disabled, object = explicit configuration.
3. **Refs are imperative, props are declarative.** All side-effectful operations (export, zoom) live on the handle ref (`ChartExportHandle`, or `ChartHandle` where the chart zooms). Props describe visual state; refs drive it.
4. **Consistent naming.** One key-access name per axis role:

   - **Cartesian** charts use `xKey` for the x-axis field. The y-axis field is available as top-level `yKey` on single-series wrappers (`<LineChart data={...} xKey="date" yKey="value" />`) and as `series[i].yKey` for multi-series (`<LineChart data={...} xKey="date" series={[{yKey: 'open'}, {yKey: 'close'}]} />`).
   - **Categorical** and **Radar** charts use `valueKey` (numeric value) and `categoryKey` (slice/segment label).
   - **Hierarchical** and **Graph** charts carry their data via typed nodes (`HierarchyNode.label`/`value`, `NodeDatum.id`/`label`/`value`) — no string keys.
   - **Scalar** charts take a raw `value: number`.

   There is no `angleKey`, `labelKey`, `nameKey`, `variableKey` or bare `key`.
5. **No compatibility shims.** Each prop has one name and one shape; there are no deprecated aliases.

### Keyboard navigation coverage

Keyboard navigation is archetype-shaped and documented in detail in `accessibility/ACCESSIBILITY.md`. Per archetype:

| Archetype | Charts | Model |
| --- | --- | --- |
| Cartesian | `<Chart>` (any mix of series), Line, Area, Bar, Scatter, Histogram, Candlestick, OHLC, RangeArea, RangeBar, BoxPlot, Waterfall, Dumbbell, Lollipop | Reading-order scan over the data points; a compound glyph is one item |
| Categorical | Pie, Funnel, Nightingale, RadialBar, Radar | 1D index-based, active-descendant |
| Hierarchical | Treemap, Sunburst, OrgChart | Tree node-id based, drill-down/up |
| Graph | Sankey | Dual-mode node ↔ link |
| Matrix | Heatmap | 2D axis-aligned grid cursor |
| Temporal | Timeline | Linear event stepping (time order with `timeAxis`) + multi-track |
| Scalar | Gauge, LinearGauge, Bullet | SVG tab-stop, no stepping (W6); `aria-label` and data table |
| Micro | Sparkline | None: decorative, no tab stop (§ 12) |

### Information paths (W5, W6, ADR-036)

Every supported family exposes what it draws without sight or pointer. The support statement and its evidence are in `accessibility/ACCESSIBILITY.md`; it makes no certification claim.

- **Compound glyphs are one item.** A candle, an OHLC bar, a box, a range bar and a range-area point are one hover target and one keyboard stop. The default tooltip lists every value role with its host word (`charts.role.open`, `high`, `low`, `close`, `min`, `q1`, `median`, `q3`, `max`), the keyboard announcement states every role (`charts.accessibility.roleValue`), and the data table has one column per role (`charts.accessibility.seriesRole`). A custom tooltip renderer receives the roles as `SingleTooltipData.roles` (`ReadonlyArray<{role: ChartValueRole; value: number}>`); `ohlc` stays for candlestick and OHLC.
- **Waterfalls** list each bar's drawn amount, its kind (change, subtotal, total) and the running balance in the table and announce them; a zoomed waterfall keeps the running balance and the subtotal anchor of the steps before the window (§ 10.12).
- **Instants** (`temporalSemantic: 'instant'`) are read with their date and time in the tooltip, crosshair, table and announcement; a calendar date stays a day.
- **Standalone families** render a data table with their own structure, next to their keyboard model: category, value and share (pie, donut, nightingale; funnel and radial bar without share), variable by series (radar), a row-by-column matrix (heatmap), path and summed value (treemap, sunburst), source, target and value (sankey), name, parent and title (org chart), event, date text and track (timeline), value, range, target and zones (gauge, linear gauge, bullet). `accessibility.dataTable` (`false` or `{enabled: false}`) turns it off, and `{caption}` replaces its caption, as on the Cartesian table. New host keys: `charts.dataTable.*`.
- **Standalone announcements** (categorical, heatmap, treemap, sunburst, sankey) read the value in the same notation as the table (the label formatter when set, else the chart presentation's number); before, they read the raw number.
- **Legend changes** are announced with the name the legend item shows.
- **Scope (W6).** Scalar charts do not step; the sparkline has no table, is decorative unless the host names it (`accessibility.label`, § 12), and a host that gives it meaning states it in the surrounding text or names it.
- **Truthful names and values (CH-10, ADR-093).** A scalar's name reads its range: "{value} of {max}" when `min` is 0, else "{value} in the range {min} to {max}" (host key `charts.accessibility.scalarValueInRange`). A missing value of a pie, funnel, nightingale or radial bar reads "(no value)" in its announcement, value cell and share cell, never 0 or 0 %. A Cartesian table cell whose host row carries no value (`null`, `undefined`, a non-finite number, an absent field) reads "(no value)" as the tooltip does (ADR-111); a hidden percentage-stack member's share cells stay empty (§ 3.1). A treemap or sunburst node without a value (none on a leaf, none below a group) reads "(no value)" in the tooltip, the announcement and the table, never 0; a real 0 stays 0, and a group reads the sum of the values below it, as the table does (ADR-111). A hierarchy node's identity is its host `id`, else its position among its siblings along its path, never its label, so two siblings with one name are two reachable nodes; siblings that repeat an `id` stay two nodes, addressed by their order among those siblings, and are reported once in development (ADR-111). A DOM id built from a host id (a hierarchy node's, a sankey node's) encodes it injectively to safe characters (CH-32); the host id in data, callbacks and the table is unchanged. An empty or blank `accessibility.label` is no name: the chart keeps its derived name.
- **A finger is touch (ADR-111).** A browser follows a tap with compatibility mouse events; they are never a hover. A Cartesian tap's card stays, then leaves on its own timer, and a real mouse afterwards hovers as before. A tap selects, activates and pins once. A finger sliding over a standalone family names the mark under it, as the pointer does, never the one the slide started on.
- **Paths in both directions (CH-11, ADR-093).** A treemap, sunburst or org-chart path isolates each label, joins them with `→` (ltr) or `←` (rtl), and opens with a direction mark, in the tooltip and the table alike; the treemap breadcrumb separator points to the inline end in both directions.
- **Pinned tooltips (CH-8, ADR-093).** A pinned tooltip belongs to the host row it was pinned on: it stays on that row across zoom, pan, reduction, grouping and rerender, and clears when the row leaves the window, is no longer drawn as itself, its series is hidden, or the data changes.

### Readable presentation (W7, W8, ADR-038)

No prop changed; what a chart does by default did. The budgets and their measurements are in `QUALITY-BUDGETS.md`.

- **Text floors.** Responsive scaling never renders ticks or in-chart labels below 10 px, legend names below 12 px, or hierarchy text below 10 px at the initial fit. A `fontSize` the host sets below a floor stays as set. When text does not fit, the chart shows fewer ticks, leaves out labels that do not fit (the value stays in tooltip and table), folds the legend, or starts a hierarchy at a readable scale on its root.
- **Palette and roles.** The default categorical palette (`--chart-1..10`) is hue-distinct, with its own dark values. The sequential scale of the heatmap reads `--chart-sequential-min/-max`; `--chart-diverging-*` and `--chart-on-fill-*` complete the roles (§ 13). `colors` and per-series colours still win.
- **Second encoding.** A line series from the seventh palette slot on, or whose colour repeats, draws a dash pattern that follows its identity; `lineDash` wins. The same trigger gives a point series (line or area markers, scatter, lollipop) a marker shape; an explicit shape wins (§ 10.7, ADR-090).
- **Text on marks.** An inside label takes the on-fill colour that reads against its fill; a label that neither reads at 4.5:1 moves outside a free-standing bar or is left out on a stack segment, funnel, arc, tile or cell. An explicit label `color` wins.

  - *Measured paint (ADR-079):* the contrast is measured on what the browser paints, in the chart's own scope: any colour notation (hex, `rgb()`, `hsl()`, names, `oklch()`, a token, an alias chain, a token inside `color-mix()`), translucent paint composed over the chart's background, and on a label background only its fill (a border alone is no surface). A colour the browser cannot resolve is unknown paint and never carries an inside label. An explicit label `color` is the host's: it is not measured, and the package promises no contrast for it.
- **Dense data.** Bars on a category axis denser than 4 px per category are never narrower than 1 px; a glyph series on a continuous x axis without an explicit domain gets half a glyph of room at both ends.
- **Legend.** `legend.maxHeight` now folds the legend behind a "Show N more" button (`charts.legend.showMore`, `charts.legend.showFewer`, host words) instead of scrolling it. Its default is 25 % of the chart height for a legend above or below the chart, and the chart height for a legend beside it (ADR-083); an explicit value wins on every side. The same on `<Chart>` and on every standalone family with a series or category legend (`PieChart`, `FunnelChart`, `NightingaleChart`, `RadarChart`, `RadialBarChart`). A side legend that would leave the chart too narrow stands below it (ADR-090), a stack is read from the segment the eye meets first, and `legend.align` places the entries along the side (§ 10.7).
- **Motion.** The default entrance is 400 ms with 30 ms stagger per series, capped at 200 ms (the `calm` profile); `animation.entrance.duration` and `animation.entrance.stagger` set their own (§ 2.4).

---

## 2. The `BaseChartProps` Contract

The members below are the common contract as `<Chart>` and the Cartesian wrappers take it. A family takes a member only where it reads it (W10, ADR-082, ADR-085): the standalone families take a narrower `tooltip`, `animation` and `accessibility` (§ 3.2 to § 3.5), the scalars have no `colors` or `tooltip` and a `contextMenu` that exports (and, on a gauge with `replay`, replays), the waterfall and the financial charts no `colors`, and `Sparkline` is a carve-out (§ 12). The type of each component is the authority.

| Member | Type | Purpose |
| --- | --- | --- |
| `height` | `number` | Container height in px. Width is always responsive. |
| `width` | `number?` | Fixed width. When omitted, fills the parent. |
| `className` | `string?` | Applied to the outer container. |
| `dir` | `'ltr' \| 'rtl'?` | The chart's UI direction (ADR-056): its HTML text and chrome (tooltip, legend, context menu under `<body>`, screen-reader table). Left out, inherited from the page (nearest `dir` attribute, followed when it changes), else `ltr`. Never inferred from `locale`; never reverses an axis or reorders data (`reversed` stays an explicit axis choice). Under `rtl` the chrome mirrors (legend flow and arrow keys, tooltip side, context menu and submenu with ArrowLeft/ArrowRight mirrored, exported legend and report text); geometric labels (ticks, annotations, slice and bar labels) keep their physical side; plot arrow keys stay spatial (ADR-057). Any other value throws a `RangeError`. |
| `locale` | `ChartPresentation?` | A locale code, or a presentation-context object (`{languageLocale, dateFormat?, timeFormat?, numberFormat?, currencyDisplay?}`) carrying language and notation independently; forwarded untouched to the central `@workspace/active-i18n` formatters. When omitted, `ChartDefaults` supplies it; with neither, the chart throws a configuration error — there is no `'en'` fallback (ADR-026). Object inputs must be referentially stable. |
| `t` | `ChartTranslateFn?` | The host translator for every text the chart shows or announces (visible copy, accessible names, descriptions, keyboard announcements), keyed by `ChartTranslationKey`. When omitted, `ChartDefaults` supplies it; with neither, the chart throws a configuration error: no English fallback and no reach into the active-i18n provider (ADR-028). |
| `accessibility` | `AccessibilityOptions?` | ARIA label/description, screen-reader data table, live-region config. |
| `colors` | `string[]?` | The chart's own categorical palette, replacing the host palette (`--chart-1` … `--chart-10`) in the colour assignment of this chart only. A data encoding, not a theme: surfaces, text and semantic roles stay with the CSS tokens (§ 13), and an explicit series, datum or annotation colour wins over it. |
| `tooltip` | `FeatureOption<TooltipOptions>` | Feature toggle. |
| `contextMenu` | `FeatureOption<ChartContextMenuOptions>` | The context menu (§ 2.3). |
| `animation` | `FeatureOption<AnimationOptions>` | Feature toggle. |
| `events` | `ChartEvents?` | Bundled form of the individual `on*` props. |
| `ref` | `Ref<ChartHandle>?` | Imperative handle (see §4); `Ref<ChartExportHandle>` on a family without a data-range zoom. |

### Events

All chart-level callbacks live on `ChartEvents`:

| Event | Signature |
| --- | --- |
| `onReady` | `() => void` — fires exactly once per chart instance after the first successful mount, before the entrance animation. Empty datasets still fire (the "no data" surface is mounted). Fires again only after a new React `key` creates a fresh instance. |
| `onAnimationComplete` | `(event: AnimationCompleteEvent) => void` — `'load'` or `'update'`. `'load'` fires once, when the entrance has ended: after a staggered entrance (funnel, treemap, sunburst) when its last mark has, counted from the first frame the marks are drawn in (ADR-107); at once without motion. |
| `onExportComplete` | `(result: ChartExportResult) => void` — once per produced file, from the handle and the menu, downloaded or not; the same object the export resolves with (§ 4). |
| `onExportError` | `(format: ChartExportFormat, error: unknown) => void` — fires when an export could not produce its file (W11); without it a failed context-menu export is reported as an `error` diagnostic (§ 5, Diagnostics). |
| `onZoomChange` | `(startFraction, endFraction, yStartFraction?, yEndFraction?) => void` — zoomable charts. |
| `onLegendChange` | `(change: ChartLegendChange) => void` — once per applied legend change (hide, show, isolate, show-all), from every route (§ 2.6), on the families with a discrete legend. |

Callbacks can be passed individually (spread onto the component) or bundled under `events={{... }}`. When both forms are present, the individual props win.

On `<Chart>` and the thirteen Cartesian wrappers that render through it, `events` also takes the selection events `onSelectionChange` and `onItemActivate` (§2.1).

**No room (RS3, ADR-107).** Below the size at which a family can draw its marks truthfully, it draws none of them and says so, in a caption on the chart and in its description (`charts.insufficientSpace`, `charts.scalar.insufficientSpace`): never a negative size, an inverted range or a mark outside the chart. The accessible name, the data table and a scalar's readout stay; a family with keyboard items has none without room (one image, no tab stop, no `aria-activedescendant`) and gets them back, with no focused position, when the room returns (CH-63). The floors are each family's own and below the lab reference widths (390 and 320 px): a timeline lane lower than a marker, a sankey without room for two columns or for its tallest column, a heatmap cell under 2 px, a radar web under a 40 px radius, a linear gauge or bullet track under 24 px; the radial bar and the gauge had theirs before (W-EC-2, ADR-072). A timeline narrower than its track pans across it instead (LAB-AT22).

### 2.1 Item selection and activation (ADR-042)

`<Chart>` and the Cartesian wrappers `LineChart`, `AreaChart`, `BarChart`, `Histogram`, `ScatterChart`, `CandlestickChart`, `OhlcChart`, `WaterfallChart`, `BoxPlotChart`, `RangeAreaChart`, `RangeBarChart`, `DumbbellChart` and `LollipopChart` let a reader select the items they draw, and tell the host. The nine standalone families `PieChart` (and the donut), `FunnelChart`, `RadialBarChart`, `NightingaleChart`, `RadarChart`, `HeatmapChart`, `TreemapChart`, `SunburstChart` and `SankeyChart` take the same props with the same meaning (§2.1.1, ADR-102). `OrgChart`, `TimelineChart`, the gauges, `LinearGauge`, `BulletChart` and `Sparkline` do not.

| Prop | Type | Meaning |
| --- | --- | --- |
| `selection` | `FeatureOption<ChartSelectionOptions>` | Turns selection on. `mode: 'single'` (default; choosing the selected item clears it) or `'multiple'`; `itemKey`: the row field that names an item (default the x key). |
| `selectedItems` | `ChartItemRef[]?` | Controlled: the host owns the selection and the chart only requests. The chart never changes it, and draws, lists and announces only what the host passes (ADR-105). |
| `defaultSelectedItems` | `ChartItemRef[]?` | Uncontrolled: the initial selection. |
| `onSelectionChange` | `(selection: ChartItemRef[], change: ChartSelectionChange) => void` | The selection the reader asked for. `change.source` is a `ChartInteractionSource` (`'pointer'`, `'touch'` or `'keyboard'`, with `item` and `selected`), or `'update'` (with `removed`) when an uncontrolled selection loses items the data no longer holds. `change.requested` is always present (below). |
| `onItemActivate` | `(item: ChartItem, source: ChartInteractionSource) => void` | A click or tap on an item (`'pointer'` for a mouse or pen, `'touch'` for a finger), or Enter on the focused item (`'keyboard'`). |

**Identity.** `ChartItemRef` is one union of four forms (ADR-102): an item of a series (`ChartSeriesItemRef`, below), a heatmap cell, a node and a sankey link (§2.1.1). A chart resolves the forms it draws and ignores the others, so one host selection can be given to several charts; narrow on `kind` (absent on an item of a series) or on `'seriesId' in ref`. An item of a series is `{seriesId, key, interval?}`: the series by its identity and the row by the value of its item key. `seriesId` is the series identity of §3.1 in one of three disjoint namespaces: `id:<id>` for a series that declares an `id` (`id: 'revenue'` is `'id:revenue'`), else `type:key` (`line:price`, `candlestick:close`), else `position:<index>`; a repeated identity carries `#2`, `#3`, … in series order. No two series of a chart share a `seriesId`, whatever their ids spell (ADR-049). Store the `seriesId` the chart reports rather than composing it. A `Date` and a number with the same time name the same item; an ISO string names a string. A grouped bucket is reported with its `interval` (`ChartItem.interval` also carries the row count) and, stored that way, names exactly that bucket. An item keeps its identity when the host reorders, adds or removes rows or series.

**Controlled truth (ADR-105).** With `selectedItems` THE HOST PROP IS THE TRUTH. A reader's change is a request: `onSelectionChange(proposal, change)` with `change.requested: true`, where the proposal is computed from the current `selectedItems` (the ones of the last render React committed, which the reader sees: a render that does not commit, such as a suspended transition, never changes a request), never from an earlier request (two requests before the host answers both start from its selection; a single-mode click on A that the host ignores, then a second click on A, asks to select A twice). The chart draws, lists and announces nothing of a request until the host passes it back; then it announces the change once, on the commit that applies it. A request the host ignores is never announced; a request the host answers with something else is announced only for what it applied of the request (selecting C instead of B says nothing about B), and the host's own changes are not announced. The latest request is the one that counts; any change of the host's selection ends it. Applying the host's prop is not a change and fires no `onSelectionChange`. Uncontrolled, the chart applies the change at once and reports it with `requested: false`; `source: 'update'` is always `requested: false`.

**State.** A selected item that is not drawn (its series hidden, outside the zoom, or a bucket of another grouping) stays in the selection but is not shown, announced or listed as selected until it is drawn again. An uncontrolled selection drops items whose rows are gone and reports it with `source: 'update'`.

**Reading and keyboard.** A selected item carries a filled caret beside it, never an outline (W-FV-1); its cells in the screen-reader table read "…, selected"; a change is announced. Arrow keys move focus only. With selection on, Space toggles the focused item; with `onItemActivate`, Enter activates it; Shift+F10 and the ContextMenu key open the context menu on the keyboard's current item, with or without selection or activation (without them, Space and Enter open it as before).

```tsx
import {useState} from 'react';
import {BarChart, type ChartItemRef} from '@workspace/active-charts';

declare function openQuarter(quarter: string): void;

// The series declares `id: 'revenue'`, so the chart names it `'id:revenue'`.
const initial: ChartItemRef[] = [{seriesId: 'id:revenue', key: 'Q1'}];

export function RevenueByQuarter() {
  const [selected, setSelected] = useState<ChartItemRef[]>(initial);
  return (
    <BarChart
      data={rows}
      xKey="quarter"
      xAxis="category"
      series={[{yKey: 'revenue', id: 'revenue', name: 'Revenue'}]}
      selection
      selectedItems={selected}
      onSelectionChange={(next) => setSelected(next)}
      onItemActivate={(item) => openQuarter(String(item.key))}
      height={320}
    />
  );
}
```

### 2.1.1 The standalone families (ADR-102)

The props, the events, the gestures and the state rules are those above. What a family names as an item, and how its reference is spelled:

| Family | The item | Its reference | Keyboard |
| --- | --- | --- | --- |
| `PieChart`, `FunnelChart`, `RadialBarChart`, `NightingaleChart` | A slice, stage, bar or sector: an item of the chart's one series | `{seriesId: 'pie' \| 'funnel' \| 'radial-bar' \| 'nightingale', key}`, `key` the row's `categoryKey` value unless `selection.itemKey` names another field | Space selects, Enter activates |
| `RadarChart` | A vertex: one series at one variable | `{seriesId, key}`, `seriesId` `id:<id>` for a series with an `id`, else `radar:<valueKey>`; `key` the row's `categoryKey` value | With selection or activation on, the keyboard is on one vertex: the arrows step the variables, PageUp and PageDown the visible series at it |
| `HeatmapChart` | A cell with a row (an empty intersection is none; a row without a colour value is one) | `{kind: 'cell', x, y}`: the row's `xKey` and `yKey` values, never the gutter text | Space selects, Enter activates |
| `TreemapChart`, `SunburstChart` | A leaf; a group header or a parent arc keeps drilling | `{kind: 'node', id}`: the leaf's `HierarchyNode.id` | Space and Enter on a leaf; a parent keeps its drill |
| `SankeyChart` | A node or a link | A node `{kind: 'node', id}` (`NodeDatum.id`); a link `{kind: 'link', id?, source, target}`: its `LinkDatum.id` when it has one, else its source and target node ids | Space selects; Enter still moves between nodes and links; Shift+Enter activates |

- **No id, no selection.** A treemap or sunburst leaf without an `id` can be activated (its `ChartNodeItem` carries the node and its path and no `id`: the chart never invents one) but not selected; the chart reports it once in development. Parallel sankey links without ids are one item: they select and activate together, and the chart reports once that each needs an `id` to be addressed on its own. A repeated id or category selects its items together and is reported once.
- **Not drawn is not removed.** A slice the legend hides, a radar series that is hidden or has no value at the variable, and a leaf outside the drilled subtree are not shown, announced, listed or counted as selected, and stay in the selection.
- **No new behaviour without the props.** A family without `selection` and `onItemActivate` takes no click, draws no caret and keeps its Enter and Space (the menu, the drill, the sankey traversal) exactly as before.
- **The caret** is the Cartesian one, placed on the item: inside a cell, leaf, stage or sankey node at its top edge; inside an arc at its outer edge; at the value end of a radial bar; above a radar vertex; on the middle of a sankey link. It never changes the item's fill or size, reserves no room, takes `Highlight` under forced colours, and never reaches an export or a print.

```tsx
import {useState} from 'react';
import {
  SankeyChart,
  type ChartItemRef,
  type LinkDatum,
  type NodeDatum,
} from '@workspace/active-charts';

declare const nodes: NodeDatum[];
declare function openFlow(id: string): void;

// Two contributions into the fund: each link has its own id, so each is an
// item of its own.
const links: LinkDatum[] = [
  {id: 'employer', source: 'contrib', target: 'fund', value: 60},
  {id: 'member', source: 'contrib', target: 'fund', value: 40},
];

export function Flows() {
  const [selected, setSelected] = useState<ChartItemRef[]>([]);
  return (
    <SankeyChart
      nodes={nodes}
      links={links}
      selection={{mode: 'multiple'}}
      selectedItems={selected}
      onSelectionChange={(next) => setSelected(next)}
      onItemActivate={(item) => {
        if (item.kind === 'link' && item.id) openFlow(item.id);
      }}
      height={320}
    />
  );
}
```

### 2.2 Linked views (`<SyncGroup>`, ADR-042)

Charts inside a `<SyncGroup>` that set `sync` share the x VALUE the pointer or keyboard focus is on, and with `sync: {zoom: true}` the x RANGE they are zoomed to. Each member shows them over its own rows and in its own size: the same date, category or number, never the same pixel or row position; y ranges are not shared.

- A member follows a value only when it reads x the same way: category with category, number with number, time with time of the same `temporalSemantic`. Otherwise it ignores it.
- A member that has no row at a hovered value marks nothing; a grouped bucket marks the band of the rows it covers.
- A chart outside the group, or in another group, is never affected; a chart that unmounts leaves the group and withdraws what it shared.
- Selection is not linked. Give two charts the same `selectedItems` where the items mean the same thing.

---

### 2.3 Context menu (ADR-087)

Every chart except the Sparkline has one context menu, the same in every family and built on the Base UI menu that the Active Grid also uses. It is on by default (`contextMenu: false` switches it off, also on `<ChartDefaults>`). Its vocabulary is the Active Grid's: an item has the fields and meanings of a grid row-menu item, and both products resolve their menus through the same pipeline (`@workspace/active-helpers/menu-items`).

```ts
contextMenu?: ChartContextMenuOptions<TElement, TFeature> | boolean;

interface ChartContextMenuOptions<TElement, TFeature> {
  enabled?: boolean; // default true
  items?: ChartContextMenuBuilder<TElement>; // the host's items, first
  features?: Pick<ChartContextMenuFeatures, TFeature>; // own groups, export formats
  pngScale?: number; // the menu's PNG export, default 2
  trigger?: 'none' | 'button'; // default 'none'
  onCopyError?: (error: ChartCopyError) => void; // a copy that did not reach the clipboard (ADR-116)
}
type ChartContextMenuBuilder<TElement> = (
  context: ChartContextMenuContext<TElement>,
) => ChartContextMenuItem<TElement>[];
interface ChartContextMenuFeatures {
  copy?: boolean;
  zoom?: boolean;
  series?: boolean;
  export?: ChartContextMenuExportFeatures; // default true
  replay?: boolean; // GaugeChart only
}
type ChartContextMenuExportFeatures =
  | boolean
  | {png?: boolean; svg?: boolean; csv?: boolean; pdf?: boolean};
```

`TElement` is the family's element on every public prop: `CartesianPoint<TDatum>` on `<Chart>` and the thirteen Cartesian wrappers, the family's own shape elsewhere, `unknown` on the scalars. `BaseChartProps.contextMenu` and `ChartOptions.contextMenu` are element-agnostic with `ChartContextMenuOptions<never>` (the element only flows into host callbacks, so every family's options fit it); no public signature carries `any` (CH-40).

**The context.** The menu builds one `ChartContextMenuContext` when it opens and freezes it while it stays open; the builder and every `action` and `visible` see the same object. `target` is what the menu was opened on: `{kind: 'element', element}` with the family's element shape (`CartesianPoint`, `CategoricalSlice`, `RadarPoint`, `HeatmapCell`, `HierarchyElement`, `SankeyElement`, `TimelineElement`), `{kind: 'legendItem', entry: ChartLegendEntry}` (the entry exactly as `useChartLegend()` reports it at the opening, § 2.6) or `{kind: 'chart'}`. `source` is a `ChartInteractionSource`: `'pointer'`, `'keyboard'` or `'touch'` (a long press); the menu's legend actions report it in their `onLegendChange`; `selectedItemCount` counts the selected items (§ 2.1); `isZoomed` is `true` while a chart with a data range shows a narrowed range.

**An item.** `ChartContextMenuItem` is `'separator'` or `{label, action?, icon?, variant?, disabled?, disabledReason?, shortcut?, keepOpen?, visible?, separatorBefore?, children?}`. `children` is a submenu, to any depth. `disabled` keeps an action that exists but cannot run now, with `disabledReason` read by assistive technology and shown on hover; `visible(context)` returning `false` removes an item that is irrelevant for this opening. Separators collapse: never first, last or doubled, and a `variant: 'destructive'` tail gets one separator above it. The grid's `permission` and `isLoading` have no chart counterpart: a host decides authorisation in its builder.

**The order.** The host's items come first, then the chart's own groups, each a group under a separator:

| Opened on | Copy | Zoom | Series | Export |
| --- | --- | --- | --- | --- |
| An element | Copy value (Copy values on a compound item) | – | – | Export ▸ PNG, SVG, CSV, PDF |
| A legend entry | – | – | Hide or Show, Show only this, Show all | – |
| The chart (no element) | Copy as table | Reset zoom (disabled with a reason while not zoomed) | – | Export ▸ PNG, SVG, CSV, PDF |
| A scalar chart | – | – (a gauge with `replay`: Replay animation) | – | Export ▸ PNG, SVG, CSV, PDF |

A group the family does not have is absent, and the `features` type of a family names only the groups it has (ADR-082): `copy`, `zoom`, `series` and `export` on `<Chart>` and the Cartesian wrappers; `copy`, `series` and `export` on the categorical families; `copy` and `export` on heatmap, the hierarchies, sankey and timeline; only `export` on the scalars, `export` and `replay` on `GaugeChart` (ADR-100: Replay animation sits between the host's items and Export while the gauge's `replay` is on; `features.replay: false` leaves it out, the gauge itself still replays, and a gauge without `replay` has no such item whatever its features say). Copy value writes one line of machine text, `label: value`, in the machine form of the CSV boundary (W4): on `<Chart>` the x value as its axis reads it and the y value; on a compound item (candlestick, box plot, range) Copy values writes its row as a one-row tab-separated table of x and every role. An element without a value (a sparse heatmap cell) disables it with a reason. A treemap or sunburst node copies the value its tooltip and table write: its resolved measure (§ 3.3, ADR-112). Copy as table writes the chart's raw rows of the visible range as tab-separated text (§ 4, W4). Hide is disabled on the last visible entry; Show all is disabled while every entry is shown; Show only this is absent on the only visible entry. Zoom here and Pan here do not exist; Reset zoom stays. The Export formats are the ones the chart's composed export work writes, run as the handle runs them (§ 4).

**Copy outcome (ADR-116).** A copy is never silent. When the menu opens, a clipboard the browser cannot offer here (no `navigator.clipboard.writeText`, an insecure context) disables Copy value, Copy values and Copy as table with the reason `charts.contextMenu.copyUnavailable`; nothing is attempted and nothing is reported (a disabled item is a state, not a failure). Otherwise the copy is attempted and its outcome is announced in the chart's live region after the "menu closed" announcement, so it is the last thing said: `charts.contextMenu.copied` when the write resolves, `charts.contextMenu.copyFailed` when the browser rejects it (a denied permission, a managed policy, an unfocused document) or the clipboard is gone by the time the reader chooses. A failure reaches the host once: through `contextMenu.onCopyError(error)` when it listens, else as an `error` diagnostic (`message: 'Copy failed'`, `data: {action, code, errorName}`) in every environment; never both. `ChartCopyError` (`name: 'ChartCopyError'`) carries `code` (`ChartCopyErrorCode`: `'COPY_REJECTED'` or `'COPY_UNAVAILABLE'`), `action` (`ChartCopyAction`: `'copyValue'`, `'copyValues'` or `'copyAsTable'`) and the browser error's `errorName` (`NotAllowedError`), never its message and never the copied text; it has no `cause`. A copy that settles after the chart unmounted is announced nowhere and still reaches `onCopyError` (or the diagnostic). The package has no toast and no `execCommand` fallback: a host that wants a visible notice or a manual fallback builds it on `onCopyError`. A host item (`items`) is the host's own action; the package neither wraps nor observes it. `onCopyError` follows the `FeatureOption` merge of § 5 like `pngScale` and `trigger`.

**Export formats (ADR-105).** `features.export` chooses which standard formats the Export submenu offers. A format appears only where the host allows it **and** the chart can write it: the host narrows, it never adds.

```tsx
contextMenu={{features: {export: true}}}   // every format the chart writes (the default)
contextMenu={{features: {export: false}}}  // no Export submenu
contextMenu={{features: {export: {png: true, svg: true, csv: false, pdf: false}}}}
```

- `true`, absent, or an empty object: every format the chart can write. `false`: no submenu.
- An object: a format set to `false` is hidden; a format left out or set to `true` is offered where the chart can write it (absent inherits the default, on).
- Support wins: PNG, SVG and PDF on every chart with an export; CSV only where the chart has rows (`data` present). `csv: true` on a chart without rows offers no CSV, and no item is ever shown disabled for it.
- No empty submenu: when no format remains, the Export item and its separator are absent, as with `export: false`.
- The order stays PNG, SVG, CSV, PDF, and every item runs the one export route of § 4 (one file, a download by default, `onExportComplete(result)` once).
- `<ChartDefaults contextMenu>` follows the `FeatureOption` merge of § 5, which stops at the top level of `contextMenu`: `features` is one field, so a chart's own `features` replaces the provider's as a whole (provider `{export: {csv: false}}` and chart `{export: {pdf: false}}` give PNG, SVG, CSV), while a chart that sets only `pngScale` or `trigger` keeps the provider's `features`. `contextMenu={false}` on the chart wins over any provider.
- A key that is not a format (from a JavaScript caller) is ignored.

**Opening.** Right click, Shift+F10, the ContextMenu key (on a focused item, legend entry or the chart) and a long press (500 ms, within 10 px) open the menu. A keyboard opening focuses the first item; a pointer opening focuses the popup. Arrow keys, Home, End and typeahead move; ArrowRight (ArrowLeft in `rtl`) opens a submenu; Escape closes one level; Tab closes the menu. Closing returns the focus to where the menu was opened. The opening is announced in the chart's live region (category `contextMenu`).

**The visible button.** `trigger: 'button'` adds a small menu button at the chart's top inline-end corner, named `charts.contextMenu.label` and opening the menu for the chart (`target: {kind: 'chart'}`), for hosts whose users do not expect a right click. It is HTML beside the SVG, so no export contains it, and print hides it.

**Touch.** Under a coarse pointer every item is at least 44 px high (W-CM-9), as in the grid. Elsewhere the menu has the grid's density.

**Not in this contract** (deferred, ADR-087): checked items, axis and annotation targets, an imperative `openContextMenu()`, replacing a default item. The Sparkline has no menu (§ 12).

### 2.4 Motion (ADR-104)

`animation` describes how a chart moves: one **profile** and six semantic **classes**. It is plain, serialisable data (no callback, no free CSS curve), so a central policy can live in `ChartDefaults`, in a stored configuration or in JSON.

```ts
import type {
  EntranceMotionOptions,
  ExitMotionOptions,
  InteractionMotionOptions,
  ReplayMotionOptions,
  UpdateMotionOptions,
} from '@workspace/active-charts';

interface AnimationOptions {
  enabled?: boolean; // false: every interpolation off (§ below)
  profile?: 'calm' | 'responsive'; // default 'calm'
  entrance?: boolean | EntranceMotionOptions; // {enabled, duration, easing, type, stagger, layers}
  update?: boolean | UpdateMotionOptions; // {enabled, duration, easing}; on by default
  exit?: boolean | ExitMotionOptions; // {enabled, type, duration, easing}; off unless asked for
  tooltip?: boolean | InteractionMotionOptions; // {enabled, duration}
  attention?: boolean | InteractionMotionOptions; // {enabled, duration}
  replay?: ReplayMotionOptions; // {duration}; the scalars' type, applied by the gauge
}
type ChartMotionEasing =
  'calm' | 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
```

**Profiles.** A profile is a set of defaults, never a behaviour: it changes durations and the stagger, never an entrance kind, a curve family or what an interaction shows. There is no `none` profile: `animation: false` says it. The stagger is every staggered entrance's, on the chosen profile: the series', a funnel's segments (one step each) and a treemap's or sunburst's depths (two steps each), each up to the cap (ADR-107).

| Class | `calm` (default) | `responsive` | Easing | Bound |
| --- | --- | --- | --- | --- |
| entrance | 400 ms, stagger 30 ≤ 200 | 250 ms, stagger 20 ≤ 120 | `calm` (cubic ease-out, no overshoot) | 3,000 ms |
| update (on by default) | 300 ms | 200 ms | `calm` | 3,000 ms |
| exit (opt-in) | 200 ms | 130 ms | `ease-in` | 3,000 ms |
| tooltip | 130 ms | 80 ms | the product's (no option) | 600 ms |
| attention (swap; release) | 140 ms; 250 ms | 90 ms; 160 ms | the product's (no option) | 600 ms |
| replay (scalars) | 1,000 ms | 600 ms | ease-in-out legs (no option) | 3,000 ms |

The attention release (the context returning with nothing to move to) is 1.8 times the swap, rounded to 10 ms; one `attention.duration` sets both through that ratio.

**Where each class applies.** `<Chart>` and the Cartesian wrappers take every class but `replay`; the compound wrappers (range bar, range area, box plot, dumbbell, lollipop) also no `update` (LAB-AT10): their glyphs redraw on new data, and their grouped slots move on the profile's update timing (below). A standalone family takes `enabled`, `profile`, one `entrance` (`{enabled, duration, easing}`, plus `type` where it has more than one kind), `tooltip` and `attention` (`StandaloneAnimationOption`). The three scalars take `enabled`, `profile` and `entrance` (`ScalarAnimationOption`), and `GaugeChart` also `replay` (`ScalarAnimationOption<'replay'>`, ADR-107). `SankeyChart` and `OrgChart` have no entrance and take `enabled`, `profile`, `tooltip` and `attention` (`InteractionAnimationOption`). A series (`series[i].animation`, `SeriesAnimationOptions`) owns only its `entrance` (`{enabled, duration, easing, type, delay}`) and its `exit`; tooltip, attention, update and replay are the chart's.

**Precedence.** profile → `ChartDefaults.animation` → the chart's `animation` → a series' `entrance` and `exit` → reduced motion. Each level merges **class by class, field by field** with the one above: `ChartDefaults animation={{profile: 'responsive', tooltip: {duration: 60}}}` and a chart with `animation={{exit: true}}` give a responsive chart with a 60 ms tooltip, a 200 ms update and a 130 ms exit. `false` at a level replaces what is above it; `true` keeps it. A series cannot switch motion back on that its chart or the reader has off.

**`animation: false`** (or `{enabled: false}`) makes every interpolation instant: entrance, update, exit, the grouped slots, tooltip, attention, replay, and a discrete zoom (whose own timing is `zoom.animation`). Nothing else changes: the tooltip, the crosshair, selection, the keyboard, announcements, the context menu and the replay action (which then has nothing to move) work as before. A single class switched off (`tooltip: false`) is instant; the others keep moving.

**`zoom.animation`** (`ZoomAnimationOptions`) times a discrete zoom (reset, brush release, drag-select): `enabled` (default `true`), `duration` (ms, 0 to 3,000, default 300) and `easing`, a `ChartMotionEasing` name (default `'calm'`, the cubic ease-out). It is plain data like the rest of the motion description, so it survives `JSON.stringify`; there is no function form. A duration outside the bounds or an unknown easing falls back to the default with one development diagnostic. Zoom is navigation, not a motion class: the profile does not time it, but `animation: false` and reduced motion make it a jump, and continuous gestures (wheel, pan, pinch) never glide.

**Reduced motion is absolute.** Under `prefers-reduced-motion: reduce` the resolved motion is the same as `animation: false`, whatever the host describes; there is no option to override it.

**Validation.** A duration that is not a finite number of at least 0 falls back to the profile's value, an unknown profile to `calm`, an unknown easing to `calm`; a tooltip or attention duration above 600 ms and a geometry duration, stagger or delay above 3,000 ms are held at the bound. Each case is reported once per description as a development `warn` diagnostic (`ChartDefaults.diagnostics`). Zero is a legitimate duration.

**Export.** An image export never captures a motion mid-way, however long it is configured: it waits until the chart is still **and** until the last motion the chart declared has ended, within the chart's motion budget plus one second (at least four seconds; § 4 Animation).

**What each class moves.** The meaning of an interaction never waits: the current item, the tooltip's content, its accessible text, the crosshair, the cues and the colour-key value change in the frame of the event. Only presentation moves:

- `tooltip`: the card fades in from the first frame it is drawn (already with its content), travels between the marks it describes within one side of them, and fades out where it was, its content frozen; a return while it fades reverses from where it is. A card that changes side, follows the pointer or a finger, or was not shown is placed directly. The heatmap's colour-key marker travels to a new value on the same timing. The crosshair and the cues are never smoothed: they appear at once and leave together with the card.
- `attention`: the context steps back on the swap duration when attention moves from one item to another (both on one clock) and returns on the release (1.8 × the swap) when attention ends; only opacity moves.
- `update`: new values move from what is drawn on every update family; a newer update continues from there. When a grouped series (bar, range bar, box plot, dumbbell) is hidden or shown, the remaining glyphs move to their new slots on the same timing, after the exit if one is configured. A change of orientation is placed at once.
- `replay` (gauge): back for 35 % of the duration, up for 65 %, on an ease-in-out; the value and the accessible name stay the resting value.

```tsx
// A Datyx-wide policy, stored as data:
<ChartDefaults animation={{profile: 'responsive', tooltip: {duration: 100}}}>
  <main />
</ChartDefaults>
```

```json
{"profile": "calm", "entrance": {"duration": 300}, "attention": false}
```

### 2.5 Host composition

What a host can build on, and where the package's responsibility ends. The package owns every action, its state, its words through `t`, its ARIA, keyboard and focus; the host owns its data, its words and notation, the layout around the chart, persistence and its domain rules. Ownership follows the source, not the place on the page: a package component the host places is still the package's (a PACKAGE COMPONENT), and what the host renders itself from this API is HOST COMPOSITION (COMPETITIVE-ANALYSIS § 1). No seam below is a free renderer of package internals. The package lab shows the seams as host examples under "What the host adds" and the package components under Composition (active-ui ADR-012, extension "Active-family lab convention").

| Seam | The package owns | The host owns |
| --- | --- | --- |
| `<ChartDefaults>` (§ 5) | The merge (a nearer value refines a farther one; a chart prop wins), the fields it takes, no fallback for `locale` and `t` | Where it mounts, its presentation and translator, the app-wide values; instance-only values (colours, menu items) stay on the chart |
| `<SyncGroup>` with `sync` (§ 2.2) | Sharing the hovered x value and, with `sync: {zoom: true}`, the x range, by value; which members follow; leaving on unmount | Which charts form a group and where they stand; linking their selection, which the group never links |
| `<PeriodSelector>` | The period buttons and their words (through `t`), filtering the rows by date | Where it stands and the rows it filters |
| `<ChartErrorBoundary>` | Catching a chart's render failure and its default fallback | Where it stands, and its own fallback (a node or a function) |
| Controlled selection: `selection`, `selectedItems`, `onSelectionChange`, `onItemActivate` (§ 2.1) | Item identity (`ChartItemRef`), the marks, the keys, the announcements and the data table; with `selectedItems` it only asks (`requested: true`) and draws and announces only what the host passes back (org collapse with `collapsedIds` alike) | Holding the selection and its rule (accepting, declining or changing a request), what it shows beside the chart, clearing it, and what an activation opens |
| Context menu: `contextMenu.items(context)`, `contextMenu.features` (§ 2.3) | The menu (opening by pointer, keyboard, long press), focus, the frozen context, its own groups and their order, and which export formats the chart can write | Its items' words, when they are disabled and why (`disabledReason`), what they do, its authorisation, and which groups and formats to offer |
| The handle: `exportAsPng`, `exportAsSvg`, `exportAsCsv`, `exportAsPdf` (§ 4) | One export route (the menu runs the same work), the capture moment, the legend, fonts and rows, the one produced file (`ChartExportResult`, downloaded unless `download: false`), the typed failure (`ChartExportError`) | Its own toolbar, the `report` words, the file name, the export fonts it supplies, what a failure says to the user, and where a file kept with `download: false` goes (an upload, an attachment) |
| The handle: `setZoom` (§ 4), `onZoomChange` | The zoom state, its glide (`zoom.animation`, § 2.4) and its limits on `<Chart>` and the Cartesian wrappers | Its own range controls, if it wants more than the navigator, the period selector and the wheel |
| The handle: `getSvgElement`, `getContainerElement` (§ 4) | Nothing beyond handing out the elements: an escape hatch, not a styling or rendering contract | Whatever it does with them, at its own risk across releases |
| Headless legend: `useChartLegend()` with `legendLink`, `onLegendChange` (§ 2.6) | The entries (identity, label, key, visibility, available actions), the one legend route, its guards, announcements and event | Its own legend markup, roles, names, focus and layout, and its words around the entries |

### 2.6 Headless legend (ADR-105)

A host that renders its own legend reads the chart's resolved legend and acts through the chart's own legend route:

```tsx
const legend = useChartLegend();

<PieChart data={rows} valueKey="count" categoryKey="status" legend={false} legendLink={legend.link} height={160} />
<ul>
  {legend.entries.map((entry) => (
    <li key={entry.id}>
      <button
        type="button"
        aria-pressed={entry.visible}
        disabled={!entry.available.toggle}
        onClick={() => legend.activate(entry.id)}
      >
        {entry.label}
      </button>
    </li>
  ))}
</ul>
```

- **`useChartLegend(): ChartLegend`** returns `{entries, connected, activate, showAll, link}`. `link` is stable and opaque (`ChartLegendLink`); give it to exactly one chart as `legendLink`. Until that chart has mounted, and on the server, `entries` is empty and `connected` is `false`; on unmount the link reads empty again. A link given to a second chart is refused with one development diagnostic: the first chart keeps it, and the two never mix entries.
- **`legendLink`** exists on the families with a discrete legend: `<Chart>`, the thirteen Cartesian wrappers, `PieChart`, `FunnelChart`, `NightingaleChart`, `RadialBarChart` and `RadarChart`. It works with `legend={false}`: nothing is painted, and the entries, actions, announcements and events are all there. Quantitative keys (the heatmap, treemap and sunburst colour keys, the bubble size key) are not entries.
- **`ChartLegendEntry`** = `{id, target, label, key, visible, available: {toggle, isolate, showAll}}`, a frozen snapshot, in the built-in legend's reading order (a stack lists its members as the eye meets them). `id` is unique within the legend; store it as reported, never compose it. `target` is `{kind: 'series', seriesId}` in the selection namespace (`ChartSeriesItemRef.seriesId`, the radar included) or `{kind: 'category', key}` with the `categoryKey` value (the row position without one). Identity is never the shown text and never a position: two series with one name are two entries, a repeated category is kept apart by `#n`, and a lone dumbbell's two ends are two entries (`@low:` and `@high:` before its series id) with one target.
- **`ChartLegendKey`** says what the built-in legend paints: `tile` (`color`), `line` (`color`, `dash` as dash lengths, `marker`), `marker` (`color`, `shape`), `pair` (`start`, `end`, `shape`) or `neutral` (a waterfall, candlestick or OHLC series, which paints by role). `color` is the colour the package paints with: the host's own colour, or a token reference such as `var(--chart-3)`; never a slot number, never resolved to RGB. A token override scoped to the chart's own container does not reach a legend rendered outside it: a host that scopes tokens to the chart scopes its legend too.
- **`activate(entryId, action = 'toggle')`** and **`showAll()`** are the built-in click (`'toggle'` hides a visible target and shows a hidden one), the double click (`'isolate'`; isolating the entry that is already the only one shown shows all) and the menu's Show all. Both return whether anything changed. Refused, returning `false` and doing nothing: an unknown id, a disconnected link, `legend.interactive: false` (behaviour, not presentation: the host's calls are refused too), isolation with `legend.isolate: false`, hiding the last visible target, and Show all while everything is shown. `available` is the same rule the route and the menu apply.
- **`onLegendChange(change)`** fires once per applied change, from every route: the built-in click, key, double click and double tap, the legend-entry menu and the hook. `ChartLegendChange` = `{action: 'hide' | 'show' | 'isolate' | 'show-all', entryId, target, hidden, source}`; `hidden` lists every hidden target in legend order; `source` is the `ChartInteractionSource` (`'pointer'` for a mouse or pen, `'touch'` for a finger, `'keyboard'`), for a menu action the modality the menu was opened with, and `'api'` for the hook. The host's `showAll()` reports `entryId` and `target` as `null`; the menu's Show all reports the entry it was opened on.
- **Ownership.** The chart announces every applied change in its live region with the entry's label, on every family (`accessibility.announcements.suppress: ['legend']` silences it on `<Chart>` and the Cartesian wrappers), and keeps a hidden target out of the plot, the table and the keyboard order; the host owns its legend's markup, roles (`aria-pressed`, a checkbox list), names, focus and keys. Visibility stays the chart's own state: there is no controlled visibility and no persistence API.

## 3. Archetype Mixins

Charts extend **one** archetype in addition to `BaseChartProps`. The archetype determines the data shape. Every Cartesian wrapper derives its props from `CartesianChartProps` as `Omit<CartesianChartProps<TDatum>, 'xKey' | …>` plus `ChartSelectionProps`: the discriminated `xKey`/`xAxis` pair replaces the plain `xKey`, and a family leaves out only what it does not read (ADR-106). `<Chart>` takes `ChartProps<TDatum>`: its `data` is `TDatum[]` and its context menu hands the host the `CartesianPoint<TDatum>` of a row.

> **Contract compliance.** Every chart wrapper except `Sparkline` resolves its props through `ChartDefaults`, exposes its handle ref (§ 4), and renders a `<div data-chart-scope>` container around its SVG (it carries the consumer's `className`, and a scoped host override set on it or above it reaches the chart). There is no per-wrapper variation in that shape. `Sparkline` is a deliberate carve-out (§12).

### 3.1 Cartesian — `CartesianChartProps<TDatum>`

Flat array of rows keyed by field name. One row per x-position.

**Members added:** `data: TDatum[]`, `xKey: string` (the x-axis field).
**Y-axis fields live on the chart-specific series config.** Single-series wrappers expose a top-level `yKey` shorthand (`<LineChart data={...} xKey="date" yKey="value" />`); multi-series usage goes via `series: [{yKey: 'open'}, {yKey: 'close'}]`.
**Series config and axis config live on the specific component** (`LineChart` has `series: LineChartSeriesConfig[]`, `BarChart` has `series: BarChartSeriesConfig[]`, etc.).

**Plot padding is not a wrapper prop.** `ChartOptions.padding` (`{top?, right?, bottom?, left?}`, CSS px) insets the plot of a low-level `<Chart>`; like the axis titles, it gives way when the plot would drop below its minimum size. The Cartesian wrappers take no `padding` prop, and no other wrapper prop is a general plot inset: room around a wrapper is the host's layout (a margin or padding on the container, for instance through `className`). `TreemapChart`'s `padding` is the spacing between tiles, not a plot inset.

**Series identity and dynamic series lists** (ADR-031, ADR-032). Every series type and every wrapper series configuration accepts an optional `id: string`. The identity of a series is `id:<id>` when it declares an `id`, or else its type plus its primary value key (`line:price`; `closeKey` for candlestick and OHLC, `medianKey` for a box plot, the low/high pair for a range), or else `position:<index>` for a series with no value key. The three namespaces are disjoint, so a declared `id` never takes the identity of a derived series, even when it is spelled like one (ADR-049). The identity is also the `seriesId` of selection (§2.1). It drives these rules:

- A series the host adds is drawn.
- A user's legend hide stays with its series when the host adds, removes or reorders series.
- A series the host removes and adds back is visible again.
- Colours, exit animations and React keys follow the series, not its position.
- Two series with the same identity stay two series: the first keeps it, each repeat gets the lowest free `#n` suffix (`line:price#2`, `id:fund#2`) in series order, and the chart reports the repeat in development. A repeat is therefore named by its order, not by itself; give such series distinct `id`s.
- `visible: false` makes a series start hidden. It is not drawn, stacked, hit or counted until the user shows it in the legend.
- The categorical legends (`PieChart`, `FunnelChart`, `NightingaleChart`, `RadialBarChart`) follow each item's `categoryKey` value the same way, read as selection reads an item key (`ChartItemKey`): a number or a `Date` and a text that spells it are two categories (`1` and `"1"`) in any row order, and a `Date` is its time, never its zone-dependent text (ADR-111); a boolean is its text (ADR-118). A value outside the item-key domain (`null`, `NaN`, an infinity, an invalid `Date`, an object) keeps its row as the empty category, because dropping a valued row would change every share: it is one item, selectable and listed under the key `""`, as a `null` category always was. Entry ids stay opaque. `RadarChart` follows each series' `id` or `valueKey`.

**Automatic value domains.** The automatic value domain of a y axis, or of the x axis for horizontal series, is built from the series bound to it:

- **Hidden series** (W17). A hidden series never contributes to an automatic domain, stacked or not: hiding a line or a bar rescales the axis to the series still drawn, and showing it again restores the axis. An explicit `min` or `max` is not affected.
- **Stacks.** A stack contributes its per-row sums over its **visible** members, always including zero. Hiding a stacked series restacks the rest and shrinks the domain.
- **Separate stacks.** Bars and areas are separate stacks, as are different axes.
- **Non-stacked series.** A non-stacked series contributes its own values, never a share of a stack.
- **Compound series.** A candlestick or OHLC contributes open, high, low and close; a box plot its whiskers, quartiles and median **and every outlier** of `outliersKey`, so a drawn outlier is never outside the plot (ADR-080); a range or dumbbell both ends.
- **Zero baseline.** Bar, histogram, lollipop and waterfall marks put zero in the domain, so bar lengths read from zero. A log axis never receives zero.
- **Error bounds** (ADR-096). A line, scatter or bar series with `errorBars` contributes its bounds, so a drawn whisker is never cut by the automatic domain; a scatter's x bounds span its numeric x axis. A stacked series has no error bars and contributes none.
- **Soft bounds** (ADR-096). `softMin`/`softMax` widen the extent of the contributions above, never narrow it: data beyond a soft bound stays in view (§ 10.11).
- **Explicit bounds.** An explicit `min` or `max` wins on its side, also where it widens the domain. A **lone** bound at or beyond every value (a `min` at or above every value without a `max`, or a `max` at or below every value without a `min`) is ignored with a diagnostic, so the axis spans the data and never turns around; `reversed` flips an axis (CH-12, ADR-093). "Every value" is the extent of the contributions, never one a soft bound widened. Two bounds are the host's whole domain. A non-finite `min` or `max` (`NaN`, an infinity, an invalid date) is contract-invalid and shows the unavailable state with a diagnostic (W12).
- **Order** (ADR-096). Contributions (marks, compound roles, error bounds, stacks of the visible members, waterfall positions, zero for zero-based marks) → soft bounds → explicit bounds → room for the labels beyond the data (measured from the data end; a soft bound that already leaves the room adds none) → `nice` → zoom. Labels, zones, annotations and cross lines contribute nothing. An x zoom recomputes the window's contributions and applies the soft bounds again; a y zoom acts on the finished domain.

**Category position.** On a category axis, point-like marks sit at the centre of their category, where the tick label is. That covers lines, areas, points, candlestick, OHLC and box-plot glyphs, lollipops and dumbbells, the hover points, annotations and vertical reference lines. Bars span their column, centred in it.

**Category identity (A6, ADR-106).** A category is the value of the x field as `ChartItemKey` reads it, never its display text: the number `1` and the text `"1"` are two categories with two bands, two marks, two table rows and two selectable items, in any row order, as selection and the categorical legends keep them. A `Date` is its time. Formatting does not define identity: two categories may show the same tick label, and both stay. A reference value given to the axis (an annotation's `x`, a cross line's `value`, `tickValues`, a linked range in a `SyncGroup`) names a category by the same rule, so it is given in the type the data has.

**The item-key domain (ADR-118).** One reader decides what an item key is, for every family and surface (the band, selection, the categorical legend, a heatmap coordinate, a `SyncGroup` range): a text, a finite number and a valid `Date` are keys as they are; a boolean is its text, so `true` and `"true"` are one key everywhere; `NaN`, an infinity, an invalid `Date`, `null`, `undefined` and any other type (an object, an array, a bigint, a symbol, a function) name no item. On a band such a row draws no mark, the keyboard and selection do not reach it, and a linked range whose end names no item names no range; the data table and the CSV keep the raw row, as for a `null` category. A drawn mark is always selectable and navigable under its own identity.

**Charts:** `LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `Histogram`, `CandlestickChart`, `OhlcChart`, `RangeAreaChart`, `RangeBarChart`, `DumbbellChart`, `LollipopChart`, `BoxPlotChart`, `WaterfallChart`, `HeatmapChart`. All delegate rendering to `<Chart>`, except `HeatmapChart`, which renders standalone because it has no series layer. `TimelineChart` is not Cartesian: its props extend `BaseChartProps` directly (no `onZoomChange` or `onLegendChange`; its own narrowed `tooltip`, `animation`, `accessibility` and `contextMenu`), it takes `data: TimelineEvent[]` and no axes, and it renders standalone. `HeatmapChart`'s `animation` takes `enabled`, `duration`, `easing` and `type` (`'wipe'`, `'fade'`, `'none'`, default none), and its `tooltip` the standalone fields of § 3.2 (ADR-082).

**`DumbbellChart`** — before/after comparison per category. `xKey + yLowKey + yHighKey` data shape; a typical use is "score in Q1 vs Q2 per region". Two markers per row connected by a thin line. Keyboard navigation steps low → high → next low → next high, matching the natural before/after reading order. Several series through `series: DumbbellChartSeriesConfig[]` stand side by side in each category; each is one legend entry with a paired key of its two end colours, and every end is read with its series first ("Plan: 2024", § 10.15). `xAxis` is optional (category is the typed default via `CartesianXKeyCategoryDefault`); the tick `formatter` value is typed `string`.

**`LollipopChart`** — sparser alternative to `BarChart` for rankings and event-count timelines. Single y-value per row rendered as a thin stick from the value-axis baseline (zero) to a marker. Multi-series via `series: LollipopChartSeriesConfig[]` mirrors `BarChart`, with the same keyboard navigation, hover and tooltip behaviour as any other y-keyed cartesian series. `xAxis` is optional (category is the typed default); the tick `formatter` value is typed `string`.

**Markers.** A line or area series draws a mark at each defined point when `marker.enabled` is set: `visible` is `'hover'` by default (the markers show while their series is emphasised), `'always'` or `'never'`; `size` (default 4), `fill` (default the series stroke), `stroke` and `shape` set it. A scatter or bubble point is its own mark: its `marker` takes `size` (default 5, unless `sizeKey` makes it a bubble) and `shape`, and its paint is the series `fill` and `stroke` (ADR-077). `LollipopChart` and `DumbbellChart` take `markerShape` beside `markerSize`. The shapes are one bounded vocabulary (§ 10.7).

**What the wrappers take from `<Chart>` (ADR-089).** A wrapper passes on the `<Chart>` options its series render, unchanged:

- **Point labels:** `label` (`PointLabelOptions`) on `LineChart`, `ScatterChart` and `Histogram`, for every series of the chart: the value above each point or bin, hidden where it collides; `formatter` receives the row and the series as its second argument (a histogram bin has no row and receives the number). `BarChart` and `WaterfallChart` take `label` too. `AreaChart` and `LollipopChart` take `label` (`PointLabelOptions`) and `RangeBarChart`, `RangeAreaChart` and `DumbbellChart` take `label` (`EndpointLabelOptions`), off by default (§ 10.8, ADR-091). A box or a financial glyph draws no value label, so `BoxPlotChart`, `CandlestickChart` and `OhlcChart` take none.
- **Series visibility:** `visible` and `showInLegend` on every wrapper series config (`LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `LollipopChart`). `visible: false` starts a series hidden (the legend brings it back); `showInLegend: false` leaves it out of the legend, on screen and in an exported image, while it is still drawn and stays in the tooltip and the table. A single-series wrapper has no series to start hidden.
- **Annotations:** `annotations` (`AnnotationOptions[]`) on every Cartesian wrapper. An annotation sits at an x value on the horizontal axis and a value on the first value axis, so `BarChart`, `BoxPlotChart`, `DumbbellChart`, `LollipopChart` and `RangeBarChart` take it only while upright: with `horizontal: true` the type refuses it.

**Tooltip modes, crosshair and value axis (ADR-085).** `'shared'` and `'split'` list one row per series at the addressed x, read by each series' own x key: a scatter series without a point at that x is not listed. A range area, range bar, box plot or histogram item has no single value for such a row, so those wrappers take `tooltip.mode: 'single'` only, and a `<Chart>` whose visible series are all of that kind shows its single card. Split cards take `tooltip.style` and `valueFormatter` like the other modes. The crosshair guides always follow the addressed point (there is no `snap`); the value readout uses the series value semantics, the notation of the tooltip. Each guide passes through what its readout names (ADR-093): the value readout stands on the value axis and the category readout on the category axis, so a horizontal series reads its value on x and its category on y; on a compound glyph (range area, range bar, box plot, candlestick, OHLC) the value guide crosses the value the readout names (the high end, the median, the close), not the middle of the glyph; a histogram bin reads as its interval ("30 to under 40", the tooltip's words) with its guide in the bin. A stacked bar or area segment and a waterfall step are addressed at their end, so their value readout names the value the axis reads there: the stack's sum through the segment, the step's running balance (CH-29); on a reversed value axis the guide stands at that end too. On line, area, scatter, histogram, range area, box plot, waterfall, candlestick and OHLC the value axis is upright, so `yAxis` takes no `autoRotate` or `parentLevel`. With series on several `yAxisId`s, the `yAxis` of `LineChart`, `AreaChart` and `ScatterChart` configures the first axis; every further axis is a plain number axis on the opposite side, scaled to its own series (a second configured axis is `<Chart>`'s `axes`). `LollipopChart` has one value axis and its series take no `yAxisId`.

**Family limits (ADR-085).** `WaterfallChart`, `CandlestickChart` and `OhlcChart` paint by role (increase, decrease, and subtotal and total sharing the summary paint; up and down), take no `colors`, and their legend key is neutral. `DumbbellChart` and `WaterfallChart` take no `navigator` (no overview line; a bridge's strip would draw raw steps), nor does a `LollipopChart`, `RangeBarChart` or `BoxPlotChart` with `horizontal: true`; a counting `Histogram`'s navigator is a brush over an empty strip (a count has no row value). `animation.update` moves line, area, scatter, bar, histogram, waterfall, candlestick and OHLC series to their new values on `duration` and `easing`; `DumbbellChart`, `LollipopChart`, `BoxPlotChart`, `RangeBarChart` and `RangeAreaChart` redraw and take no `update`, and a range area enters with `fade` (default) or `slide-in-x`. `Histogram` items are bins keyed by their lower bound (no `selection.itemKey`), its x axis is numeric, `aggregation: 'sum' | 'mean'` needs `yKey`, explicit `yAxis.min`/`max` are honoured, one bin rule applies (`binCount`, `binWidth` or `bins`), `bins` are consecutive (each runs to the next bin's lower bound), and `areaProportional` draws unequal bins by area (§ 10.16). Line, scatter and histogram labels have one place, above the point or bin, so their `label` takes no `position`.

**Percentage stacking (`stackedPercentage`, W9)** — on `BarChart` and `AreaChart` (and per series on `ChartOptions` for `bar` and `area`). Pass **raw values**: the chart draws each value as its signed share of the **visible** members of its stack at that row, `v / Σ|v| × 100` percentage points, so every column (or every x of an area) fills 0 to 100 %; hiding a series restacks the rest to a full stack, and a hidden member has no share (its table cells are empty) until it is shown again. Mixed signs keep their direction (ADR-020): positives stack upwards and negatives downwards, together 100 points. The value axis, tooltip, screen-reader table, keyboard announcement and segment labels read percentage points (axis `integer`, single values `decimal-1`), whatever unit the raw values have; CSV export and copy-as-table carry the raw values (W4). The wrappers declare the value axis as percentage points unless `yAxis` declares otherwise; on a low-level `<Chart>` an undeclared value axis whose series are all percentage stacks reads percentage points. Implies stacking; `grouped: true` is ignored with a development warning; error bars are not drawn on a stack. There is no `stackTotal`: the whole is always 100 %. Recipe: `recipes/PERCENTAGE-STACKS.md`.

**`TooltipOptions.valueFormatter`** — per-value formatter applied by the default tooltip renderer to every numeric y-value (single, shared and split modes, plus waterfall, OHLC and scatter size; ADR-085). Ignored when a custom `renderer` is supplied. Signature `(value: number, params?: TooltipRendererParams) => string`, so a formatter can read `params.datum` and other context (on `<Chart>` and the Cartesian wrappers `params.context` is a `ChartTooltipContext`: the x axis's type and temporal semantic, the y axis's type, the host's `valueFormatter` and the series' value semantics, so a custom renderer formats as the axis does; absent on the standalone families) (for example, a host value formatter writes the value and, where the series has error bars, its bounds too: `value (low–high)` is never silenced by it, ADR-096).

**Error bars** — `errorBars: {lowKey, highKey}` on line, scatter and bar series (scatter also `xLowKey`/`xHighKey`), on `<Chart>` and the three wrappers: see § 10.11 (ADR-096). The Bar's former `stroke`, `strokeWidth` and `capLength` are removed (migration notes).

### 3.2 Categorical — `CategoricalChartProps<TDatum>`

Flat array of rows, one row per slice/segment/spoke.

**Members added:** `data: TDatum[]`, `valueKey: string`, `categoryKey: string`.

**Charts:** `PieChart`, `NightingaleChart`, `RadialBarChart`, `FunnelChart`, `RadarChart`. All five render standalone (no `<Chart>` delegation). All five run an entrance animation that `animation` configures and `animation={false}` disables:

- `PieChart` — a scale-and-fade entrance (default duration 400 ms). Its `animation.entrance` takes `enabled`, `duration` and `easing` only: there is no other entrance type and no series to stagger, update, layer or exit (ADR-082, § 2.4).

Every standalone family renders its own tooltip card, which reads `enabled`, `renderer`, `valueFormatter` and `style`; its `tooltip` type leaves out the `<Chart>` modes (`mode`), snapping (`snapDistance`) and pinning (`pin`) (ADR-082, ADR-085; `OrgChart` and `TimelineChart` also `valueFormatter`, their cards show text rows). Its `animation` takes `enabled`, `profile`, `tooltip`, `attention` and one `entrance` of `enabled`, `duration`, `easing` and, where it has more than one entrance, `type`: no series to stagger, update, layer or exit (§ 2.4). Its `accessibility` has no `announcements`: that switch governs `<Chart>`'s interaction announcements, which a standalone family does not make; its keyboard announcements follow `keyboardNavigation`. `PieChart` draws its legend on any of the four sides; `left` and `right` share one row with the pie, which takes the width the legend leaves, and under `rtl` that row mirrors with the rest of the chrome. Its image export carries the legend on the same side (ADR-083): the same entries in the same order, each slice's drawn colour, a hidden slice dimmed, every entry also when the painted legend is folded, and no preview. `FunnelChart`, `RadarChart`, `RadialBarChart` and `NightingaleChart` do the same since ADR-085: a legend on any of the four sides, a double click isolates an entry, and the image export carries the legend on its side. `RadialBarChart` shows its legend only with `dataLabels: false` (its name column names the rings otherwise); `RadarChart` keeps its `height` for the plot and a legend above or below adds its own measured rows, as on the other categorical families (CH-20, ADR-093). A standalone family's context menu runs the same exports as its handle (§ 4): PNG at `contextMenu.pngScale`, SVG, CSV and PDF, with the same legend.

- `FunnelChart` — `stagger-segments` (default; sequential per-segment fade and slide), `fade`, `none`.
- `NightingaleChart`, `RadialBarChart`, `RadarChart` — `sweep` (default), `fade`, `none`. The sweep starts at the resolved `startAngle` on the one radial clock (degrees clockwise from 12 o'clock, § 10.10) and turns through the domain's own sweep over the whole duration; the radial bar's name, key and value columns stand from the first frame. A value past `max` fills its track and keeps its true number in its label, tooltip and table.

**Value semantics (ADR-089).** Every numeric standalone family takes `valueSemantics` (`ChartValueSemantics`): pie, funnel, nightingale, radial bar, radar, heatmap, treemap, sunburst, sankey and the three scalars (§ 10.6).

`RadarChart` is the one variant: it also takes `series: RadarChartSeriesConfig[]` because each polygon is a separate series overlaid on the same variable axes. Each series has its own `valueKey`.

**Radar props and labels, by design.** `RadarChartProps` extends `BaseChartPropsWithout<…>` rather than `CategoricalChartProps`: there is no top-level `valueKey`, since each series names its own. A radar has **no data labels**: every series has a vertex on every spoke, so a value label per vertex would stack one label per series at the same angle over the web. The rings read the scale, and a vertex's value is in the tooltip, the data table and the announcement (ADR-036).

**Radar values (ADR-093).** Without `min` or `max` the scale follows the data: it includes zero, steps in round values over its rings, never finer than what the values can mean (a count rings at whole numbers, the semantic quantum of `valueSemantics`, ADR-108), and reads only the visible series (hiding a series rescales the web, as on the radial bar); without any value it keeps a neutral 0 to 10 grid. An explicit bound wins on its side and the other side extends from it in round steps. A value beyond an explicit bound is drawn at the bound and keeps its true value in the tooltip, the table and the announcement; the outer ring reads `≥ max` (and a `≤ min` label stands at the centre) and the description says so (W-CF-3). A **missing** or non-finite value is a gap, never 0: no vertex, no edge to it and no fill over its two sectors; the table, the announcement and the keyboard readout say "(no value)". There is no option that connects across a gap.

### 3.3 Hierarchical — `HierarchicalChartProps<TExtra>`

Single root `HierarchyNode<TExtra>` with arbitrarily nested `children`.

```ts
interface HierarchyNode<TExtra> {
  label: string; // display + a11y
  value?: number; // leaves: required; parents: derived from children (ADR-112)
  children?: HierarchyNode<TExtra>[];
  color?: string;
  id?: string; // for stable animations/selection
  meta?: TExtra; // carried to context-menu handlers; not to the tooltip renderer
}
```

**Charts:** `TreemapChart`, `SunburstChart`, `OrgChart`. All three render standalone and take `HierarchyNode` directly; there are no per-chart node types. Treemap and sunburst size by `value`; the org chart lays out by structure and does not read it, and reads its card lines from `meta` by key (`cardFields`, by default `meta.title` as a card's second line; § 10.14). A node's `id` is its identity for the keyboard, a drill path and an org chart's collapse (§ 10.14). The tooltip renderer receives a node's name, value and share (org chart: name and title), never `meta` (ADR-085).

**The resolved measure (ADR-112).** Treemap and sunburst read ONE number per node, for the layout and for every reader alike (tooltip, data table, keyboard, announcement, share, second label line, Copy value and CSV):

- a leaf: its own `value`; a leaf without one is "no value" (never 0; it weighs nothing in the layout);
- a parent with at least one measured child: the sum of its children's measures; its own `value` is not read;
- a parent without a measured descendant: its own finite `value` (a summary whose detail carries no values); otherwise no value.

A share is a node's measure over the shown root's (a drilled root re-bases it). A parent `value` beside a measured child must equal the sum of its children (relative tolerance 1e-9): an equal one is redundant and silent; a different one contradicts the input, and the chart shows its unavailable state with a development diagnostic that counts the parents and names no label, value or path (contract-invalid input, W12), as a negative or non-finite `value` already was.

**Hierarchy data is a snapshot (ADR-114).** Treemap and sunburst prepare a tree once per root object: its structure and its measures, which the drawing and every reader then share. When hierarchy content changes, pass a new root (data) object; structural sharing is fine, so unchanged subtrees and nodes may be reused, and nothing has to be deep-copied or frozen. Changing a node's `value` or a `children` array in place under the SAME root is not an update: the chart keeps showing what it prepared, consistently on every surface (a resize or a drill relays out that same preparation), until it receives a new root.

**Depth (ADR-115).** No walk of a hierarchy takes a call frame per level, so depth alone never throws, on any of the three families. Treemap and sunburst write each node's full path into their table, CSV and selection index, so their truthful output grows with nodes × depth; they draw a tree whose deepest node lies at most **139 levels** below the root (PROVISIONAL: the largest depth whose single chain writes at most the dense tier's 10,000 path segments, (D + 1)(D + 2) / 2 ≤ 10,000; ET-4 replaces it with a measured boundary, PERFORMANCE § Hierarchy depth). A deeper tree shows the workload-unavailable state (`charts.unavailableWorkload`, `data-chart-unavailable="workload"`) with a `warn` diagnostic in every environment that names the depth and the limit and nothing else, and offers no CSV; no truncated or partial hierarchy is drawn. The org chart writes no path; its boundary was derived from its card identity keys naming the whole line of managers (O(nodes × depth), CH-56), which since ADR-122 are fixed-length, so its ground is gone, but it keeps its PROVISIONAL boundary (ET2-OD-5, ADR-119) until the ET-4 closeout decides it: **139 levels** below the root, the same segment budget applied to its keys, counted over the whole tree with collapsed branches; deeper is the same workload-unavailable state, diagnostic and CSV refusal. ET-4 measures, revises or removes it together with CH-56.

- `TreemapChart` — `morph-in` (default), `fade`, `none`.
- `SunburstChart` — `sweep` (default), `morph-in`, `fade`, `none`.
- `OrgChart` — no entrance animation; its `animation` takes only `enabled`, `profile`, `tooltip` and `attention` (`InteractionAnimationOption`, § 2.4); `onAnimationComplete({type: 'load'})` fires once on mount. The wheel zooms while the zoom controls show, and only the chart (the page does not scroll along); with `zoomControls={false}` the wheel scrolls the page and `+`, `-`, `0` still zoom. The zoom level is a menu button for the presets and Fit (ADR-107): opening it moves focus to the checked preset, the arrows, Home and End move through the menu, and Escape, Tab or a choice close it and return focus to the level; a preset is a `menuitemradio` checked at the current level, and the level's name starts with the level it shows. The toolbar keeps full strength while it holds focus, and its hover changes stop when the chart's motion is off or the reader prefers less (ADR-107). A finger drags the view as the mouse does: the chart takes the gesture (`touch-action: none`), and one the browser cancels ends the pan (ADR-107). Shift+F10 and the ContextMenu key open the focused card's menu on every card, collapsible or not (ADR-107). An image or PDF export of a partial view carries the partial-view note in its caption, after the host's own, and of a collapsed view (`collapsible`, § 10.14) the note of what is collapsed.

### 3.4 Graph — `GraphChartProps<TNodeExtra, TLinkExtra>`

Separate `nodes` and `links` arrays. **Id-based, never index-based.**

```ts
interface NodeDatum<TExtra> {
  id: string; // unique within the graph
  label?: string; // defaults to id
  value?: number; // fixed node size; the larger flow sum when omitted
  color?: string;
  meta?: TExtra; // carried to context-menu handlers
}

interface LinkDatum<TExtra> {
  id?: string; // the link's identity for selection, activation and attention (ADR-102); else source + target
  source: string; // NodeDatum.id
  target: string; // NodeDatum.id
  value: number;
  color?: string;
  meta?: TExtra;
}
```

**Charts:** `SankeyChart`. Renders standalone. No entrance animation: its `animation` takes only `enabled`, `profile`, `tooltip` and `attention` (`InteractionAnimationOption`, § 2.4), and it has no `onZoomChange` or `onLegendChange` prop (W10); `onAnimationComplete({type: 'load'})` fires once on mount. The column rule, the flow colour and the order within a column are `nodeAlignment`, `linkColor` and `nodeOrder` (§ 10.14).

**Labels and their room.** Sources label outside on the left, sinks outside on the right, middle columns in the gap to the next column. `labelPadding` is the room reserved on each side for the outer labels; when it is not set it is measured from those labels, at most a quarter of the chart's width per side (ADR-065). Hovering or focusing a node keeps its links and the nodes at their far end at full strength and lets the rest of the flow step back; a link keeps its two endpoints.

**Invalid graphs show the unavailable state (W12).** A duplicate node id, a link whose `source` or `target` names an unknown node id, a link value that is negative or not finite, or a set of links that forms a cycle is contract-invalid input: `SankeyChart` renders the translated unavailable state (`charts.unavailable`, `role="img"`) in its space and reports an English development diagnostic (§ 5, Diagnostics). Nothing throws; `ChartErrorBoundary` stays the host's wrapper for unexpected errors.

**Depth and size.** The cycle check walks the graph with an explicit stack (ADR-115): a deep acyclic chain validates and draws, or shows the no-room caption where its columns do not fit, and never throws. There is no limit on graph depth, fan-out, nodes or links; ET-4 measures their cost.

### 3.5 Scalar — `ScalarChartProps`

Single numeric value with optional bounds — no array data, no keys.

**Members added:** `value: number`, `max?: number`, `min?: number` (default `0`).
`max` is optional — the individual chart component supplies a default when omitted (`GaugeChart` defaults to `100`). A range whose minimum is not below its maximum, or a value, minimum or maximum that is not finite, is contract-invalid input: the chart shows the translated unavailable state with a development diagnostic (W12).

**Charts:** `GaugeChart`, `LinearGauge`, `BulletChart`. All render standalone. A scalar chart has no `colors`, `tooltip`, `onZoomChange` or `onLegendChange` prop (W10): zones (`GaugeZone`) carry their own explicit colours, and there is no series palette, tooltip, zoom or legend. Its `contextMenu` exports (`ChartContextMenuOptions<unknown, 'export'>`, ADR-087, W-CM-6): host items and the Export submenu, no copy; `GaugeChart` also takes `features.replay` (below). The three scalars default to the `count-up` animation (numeric interpolation of the displayed value, plus the needle swing on `GaugeChart`, the progress fill on `LinearGauge` and the bar on `BulletChart`), counting from `min` on the entrance curve, `calm` by default (ADR-084, ADR-085, ADR-104); `sweep`, `fade` and `none` are also supported. On `LinearGauge` and `BulletChart`, `sweep` is a wipe along the straight track; there is no separate `wipe` type. `animation.entrance.easing` shapes every kind, the count-up included; no kind overshoots by default. `animation.replay.duration` is the replay's length (§ 2.4: back 35 %, up 65 %); only `GaugeChart` takes it, since `LinearGauge` and `BulletChart` have no replay and their `animation` has no `replay` (ADR-107). Below a 24 px track `LinearGauge` and `BulletChart` keep their readout, draw no track, zone, bar or target, and say they cannot show the scale, as the gauge does (ADR-107). **At rest the readout is the value** (ADR-085): the geometry stops at the scale's ends, while the readout and the accessible name show `value` itself, with its full precision, in every animation mode (on, off, reduced motion, zero duration). A scalar's description promises no arrow-key navigation: it has one value and nothing to step to.

**The readout is essential.** The value readout is always drawn (ADR-072). `dataLabels` on a scalar chart sets only its notation, `formatter` or `format`, and an optional `background`; it is not a feature toggle, and the readout's size and colour are the family's (ADR-077).

**The scale (ADR-100).** `scale?: boolean | {ticks?: 'auto' | number[]}` (`ScalarScaleOption`) on all three. Absent or `false`: the two labelled ends. `true` or `{ticks: 'auto'}`: ticks at round values (the nice-number family of the tick engine), as many as the scale's drawn length gives room for (the arc's length on the gauge, the track's on the others), never finer than `valueSemantics` allows; zero is a tick where the range spans it and the step allows it. `{ticks: [...]}`: exactly these values, sorted, once; a value on an end is that end; a value outside the range or not finite is not drawn and is reported once to the host's diagnostics. The ends are always labelled. Labels use the readout's notation (no tick formatter). Where labels would meet, the precedence is: ends, the target word, explicit values, automatic values (thinned every other one before any is dropped); a hidden label keeps its tick, and no font shrinks. Ticks are drawing only: never in the accessible name, the table, an announcement or the CSV.

**The target.** `target?: number | {value: number; label?: string}` (`ScalarTarget`) on all three: a `ScalarChartProps` member, so `GaugeChart` takes it too (the prop type widened; see the migration notes). One target. Its word is "Target {value}", or `{label} {value}` where the host named it (a blank label is no label); the value is always written, in the readout's notation. Beyond the scale its mark stops at the nearer end while its word, the accessible name and the table keep the true number (name and table add "above the scale" or "below the scale"). A target that is not finite is neither drawn nor stated and is reported to the diagnostics; the chart stays available. `0` is a target. The CSV writes the number, never the label. The family's mark: a band-crossing mark above the needle or bar (gauge), a dashed line across the track (linear gauge), a short tick across the value bar (bullet).

**Off-scale truth in the name.** A value beyond an end reads "{value}, above the range {min} to {max}" (or below), without a share of the range. The target clause states the target whenever there is one; its share ("80% of target") only where a share means something: a positive target, a value of at least 0 and a range from 0 or above.

**Gauge indicator.** `indicator?: 'needle' | 'bar'` on `GaugeChart`, default `'needle'`. `'bar'`: no needle and no hub; a concentric arc 60 % of the band's width, centred in it, from `startAngle` (the resolved radial domain, § 10.10) to the clamped value, in the on-fill token that contrasts most with the fills beneath it (as the bullet's value bar); the zones or the muted track keep the full band; readout, target, name, table and CSV are those of needle mode. `needleStyle` is read in needle mode only (a diagnostic otherwise). The two are never drawn together.

**Gauge replay.** `replay?: boolean` on `GaugeChart`, default `false`. While on: an invisible hit region centred on the pivot (at least 28 px across, no icon, ring, hover reveal or cursor, transient, yielding to the readout's text) replays on a click or tap, and the chart menu offers Replay animation where the host keeps it (`features.replay`). A replay runs the presentation back to the entrance's start state and then up again, to the current value, on the replay's own timing (`animation.replay`, § 2.4; never the entrance's): count-up returns the needle (or bar) and the readout to `min` and counts up; `sweep` closes the dial and sweeps it open; `fade` fades out and in. A request while the gauge moves (its entrance, a move to a new value, a replay) is ignored; under reduced motion, with `animation={false}`, type `'none'` or a zero duration nothing moves and the menu item is disabled with its reason. A replay changes no value, target, zone, range or state, calls no callback (`onAnimationComplete` does not fire again) and announces nothing; the accessible name and the table never change. `contextMenu={false}` removes only the menu route. There is no tab stop and no key of its own: the keyboard route is the menu (Shift+F10). An export waits for the chart to rest, so one taken during a replay writes the resting value; the hit region never reaches an export.

---

## 4. The `ChartHandle` Ref

Every chart exposes a handle via `forwardRef` (ADR-086). `ChartExportHandle` is the handle of every family: exports and element access. `ChartHandle` extends it with `setZoom` and is the handle of a chart with a data-range zoom: `<Chart>` and the Cartesian wrappers. Every other family (pie, heatmap, the categorical, hierarchical, graph and scalar families, timeline, `Sparkline`) exposes a `ChartExportHandle`. Obtain with `useRef<ChartHandle>(null)` on a zooming chart and `useRef<ChartExportHandle>(null)` elsewhere; a `ChartHandle` ref also fits a chart that takes a `ChartExportHandle`.

```ts
interface ChartExportHandle {
  exportAsPng: (
    filename?: string,
    scale?: number, // default 2
    options?: {
      backgroundColor?: string;
      includeLegend?: boolean;
      report?: ChartExportReport;
      fonts?: ChartExportFonts | 'inherit'; // default: ChartDefaults exportFonts
      download?: boolean; // default true; false only returns the file
    },
  ) => Promise<ChartExportResult>;
  exportAsSvg: (
    filename?: string,
    options?: {
      includeLegend?: boolean;
      report?: ChartExportReport;
      fonts?: ChartExportFonts | 'inherit'; // default: ChartDefaults exportFonts
      download?: boolean; // default true; false only returns the file
    },
  ) => Promise<ChartExportResult>;
  exportAsCsv: (
    filename?: string,
    columns?: string[],
    options?: {
      download?: boolean; // default true; false only returns the file
    },
  ) => Promise<ChartExportResult>;
  exportAsPdf: (
    filename?: string,
    options?: {
      pageSize?: 'a4' | 'letter' | [number, number]; // default 'a4'
      orientation?: 'portrait' | 'landscape'; // default 'landscape'
      scale?: number; // default 1
      backgroundColor?: string; // default: the chart's background
      includeLegend?: boolean;
      margin?: number; // default 24 pt
      report?: ChartExportReport;
      fonts?: ChartExportFonts | 'inherit'; // default: ChartDefaults exportFonts
      download?: boolean; // default true; false only returns the file
    },
  ) => Promise<ChartExportResult>;
  getSvgElement: () => SVGSVGElement | null;
  getContainerElement: () => HTMLDivElement | null;
}

interface ChartHandle extends ChartExportHandle {
  setZoom: (
    start: number,
    end: number,
    animated?: boolean,
    yStart?: number,
    yEnd?: number,
  ) => void;
}
```

**Zoom fractions (CH-7, ADR-093).** `start` and `end` (and the first two arguments of `onZoomChange`) are row fractions of the full data, 0 to 1. `yStart` and `yEnd` are positions on the plot measured from its bottom edge (0 bottom, 1 top), read through the y scale: on a linear axis that is not reversed this is a linear share of the domain; on a log or reversed axis it is the band the plot shows. Drag selection, wheel, pan, pinch and the navigator read pixels through the x scale, so a reversed axis zooms the part under the pointer and a drag on an uneven continuous axis selects exactly the rows inside it.

**Optional dependencies.** `jspdf` and `svg2pdf.js` are optional
dependencies, loaded only by a PDF export. Without them rendering and every
other export work, and a PDF export rejects with `ChartExportError`
(`code: 'EXPORT_DEPENDENCY_MISSING'`, `missingModule: 'jspdf'` or
`'svg2pdf.js'`). `ChartExportError` is exported from the package root; every
export failure the package raises itself is one, with a stable `code` (see
**Export errors** below). A host that does not install them declares them
external to its bundler (Vite/Rollup: `build.rollupOptions.external`): a
bundler refuses an import it cannot resolve at build time, so without that
the host's build fails instead of the export (measured in a clean-room
consumer build).

**The export result (ADR-105).** Every export, from the handle, the menu or a standalone function, produces one file and resolves with it:

```ts
type ChartExportFormat = 'png' | 'svg' | 'csv' | 'pdf';

interface ChartExportResult {
  readonly format: ChartExportFormat;
  readonly filename: string; // as written, with its extension: 'report' → 'report.pdf'
  readonly blob: Blob; // the file; blob.type is its media type
  readonly downloadStarted: boolean;
  readonly warnings: readonly ChartExportWarning[];
}

interface ChartExportWarning {
  readonly code: 'font-fallback';
  readonly family: string;
}
```

- **One capture per call.** The result is the file the call produced at its capture moment (ADR-053), after the chart came to rest (ADR-104): the file that was downloaded, or that would have been. A host never recaptures to get the bytes.
- **`download`** (default `true`) hands that same Blob to the browser, under `result.filename`. `download: false` produces the same file the same way and only returns it, so a host can upload it: `const {blob, filename} = await chart.current.exportAsPdf('report', {download: false})`. `downloadStarted` says the package handed the file to the browser's download mechanism; it does not say the file was saved (that is the browser's and the user's). A download that cannot start fails the export.
- **Media types:** PNG `image/png`, SVG `image/svg+xml;charset=utf-8`, CSV `text/csv;charset=utf-8;`, PDF `application/pdf`.
- **Warnings** are non-fatal disclosures about a file that exists; the vocabulary is closed. `font-fallback`: an inherit-mode PNG (no export font set) in which a web font of the live font stack got no embedded face (its stylesheet is cross-origin, or its file could not be fetched), so its glyphs draw in a browser default font; one warning per family, in the order of the font stack. A `local()` face or an installed font is not a fallback. SVG, CSV and PDF carry no warning: an SVG naming its fonts is its documented tier, and a PDF without export fonts refuses instead of degrading.
- **Completion and failure (W11).** `onExportComplete(result)` fires once per produced file, with the very object the promise resolves with, for handle and menu exports, downloaded or not; a menu export is therefore observable and capturable by the host without a second route. An export rejects with an error when no file could be produced (the chart is not drawn, it was removed during the export, the image could not be decoded, it never came to rest, or the PDF refuses its text); `onExportError(format, error)` reports the same failure, and there is no result, no partial file and no download. Without `onExportError` a failed context-menu export is reported as an `error` diagnostic, in every environment, and never swallowed; with it the package reports nothing itself.

**Animation (W11, ADR-104).** An image export waits until the chart is still: two animation frames without a change to its SVG, without a running web animation, and past the end of every script-driven motion the chart declared (an update, a path morph, a count-up or replay, a grouped slot reflow, a discrete zoom: their pixel-snapped tails can leave quiet frames before they end). An export started during any of them therefore captures the final frame. A tooltip, a crosshair, the cues, attention states and the colour-key marker are transient and never part of an export. A chart that never comes to rest within its motion budget plus one second, and at least four seconds, rejects the export.

**Report text (W11).** `report: {title?, subtitle?, caption?}` adds the host's title and subtitle above the chart and its caption below it, wrapped to the chart width, in the host font and the chart's text colours. These are the host's words; the chart writes none of them.

**One export, every route (ADR-085).** The context menu runs the same exports as the handle: the same legend on the same side, the same fonts, the same rows; the menu PNG uses `contextMenu.pngScale` (default 2) and every menu offers CSV. Closing the menu returns focus to the chart.

**Not applicable.** `setZoom` sets a data range and exists only on `ChartHandle`, the handle of `<Chart>` and the Cartesian wrappers; a family without a data range has no such method (ADR-086). The org chart's zoom is a view magnification (its buttons, wheel and keys), not a data range, and is part of no handle. `includeLegend` applies wherever the chart has a discrete legend: the Cartesian charts, `PieChart` (ADR-083), `FunnelChart`, `RadarChart`, `RadialBarChart` and `NightingaleChart` (ADR-085); the other families have no legend to include. `getContainerElement()` is `null` on `Sparkline`, whose root is its bare `<svg>` (§ 12).

**Legend inlining.** `includeLegend` on `exportAsPng`/`exportAsSvg`/`exportAsPdf` defaults to `true` when the chart paints its legend and `false` otherwise; `includeLegend: true` includes the legend's entries also when the chart paints none (`legend={false}`, ADR-105). With `includeLegend: false` the exported image contains only the chart itself. The image always shows every entry: a fold is a control, and an image has none. Every chart places its exported legend on the side it is drawn (ADR-083, ADR-085): a `<Chart>` side legend stands beside the plot. The exported entries are the painted legend's: a lone dumbbell's two endpoints, or one paired key per series where there are several (§ 10.15), a tile for a bar-like series, a stroke for a line, the value key as the name where `name` is absent, a neutral key for a waterfall or financial series. The standalone export functions take `legendItems: readonly ChartExportLegendItem[]` (or a function read at the capture moment), where `ChartExportLegendItem` is a legend entry's `label`, `key` and `visible`, so `useChartLegend().entries` passes as is; a token reference in a key's colour is resolved at the capture moment. `ChartExportLegendOptions` takes `position` (`top`, `bottom`, `left`, `right`; `left` and `right` stand the entries in one column beside the chart and are chrome sides, mirrored under `rtl`) and `align`; the type size and spacing are the package's.

**Fonts.** Without an export font set (inherit mode), SVG keeps the live chart's font by name, and PNG embeds the host's web font when its `@font-face` files are readable (same-origin stylesheets); otherwise the image falls back to a system font. With a set, SVG, PNG and PDF use it (below). `exportAsPdf` loads `jspdf` + `svg2pdf.js` via dynamic `import()`, so neither library adds to the main bundle until the first call.

**What each format guarantees per script** (tiers L0 to L3b, the route to take, the host recipes) is EXPORT-GUARANTEES.md (ADR-060).

**PDF text and export fonts (ADR-058).** A PDF draws text only in a font it embeds, and a browser never hands its web fonts to a script, so the host supplies its fonts as data. The host owns the choice, the bytes and the right to embed them; the package knows no font names, fetches nothing and registers nothing globally.

```ts
import type {ChartExportFonts} from '@workspace/active-charts';

declare function loadCorporateBold(): Promise<ArrayBuffer>;
declare const corporateRegular: Uint8Array;
declare const notoSansCjk: ArrayBuffer;

const exportFonts: ChartExportFonts = {
  families: [
    {
      family: 'Corporate Sans',
      faces: [
        {source: corporateRegular}, // weight 400, style normal
        {source: loadCorporateBold, weight: 700}, // loaded when an export needs it
      ],
    },
    {family: 'Noto Sans CJK', faces: [{source: notoSansCjk}]},
  ],
};
```

- **Source.** A face's `source` is an `ArrayBuffer`, a `Uint8Array`, or a loader `() => Promise<ArrayBuffer | Uint8Array>` the package calls once per face object, before the capture moment. Concurrent exports share one call; a failed load is forgotten, so the next export calls the loader again. The loader owns the network, timeout and retries. There is no URL form and no base64 form.
- **Format.** PDF embeds TrueType (`glyf`) fonts only. WOFF, WOFF2, CFF-flavoured OpenType (`OTTO`) and collections are refused (`EXPORT_FONT_FORMAT_UNSUPPORTED`, with `detected`); nothing is converted. A variable font draws in its default instance, so supply static faces.
- **Weights and styles.** Every family needs a regular face (weight below 600, style `normal`). Bold (weight 600 and up), italic and bold italic are optional. A missing one falls back in a fixed order (the same style in the other weight, then the same weight in the other style, then regular) and is never synthesised: a family without bold draws bold text in its regular face.
- **Which family.** Each text element (tick label, legend entry, report line) draws in the **first** family, in the host's order, that covers all of its characters in the style each run asks for. Coverage is read from the font with jsPDF's own parser. One element is one unit: there is no per-character fallback, so a label mixing two scripts needs one family that covers both.
- **What is refused.** The PDF backend neither shapes nor reorders text and encodes UTF-16 code units, so the PDF refuses (`EXPORT_TEXT_UNSUPPORTED`) text in a script that needs shaping or right-to-left layout (Arabic, Hebrew, Syriac, Thaana, N'Ko, Mongolian, Tibetan, Thai, Lao, Khmer, Myanmar and the Indic scripts, among others), any combining mark (`normalizable` says whether Unicode NFC would compose it away; the drawn text is never normalised), ZWJ, ZWNJ and variation selectors, emoji, and any character beyond the Basic Multilingual Plane. Embedding a font that maps such characters does not change this: coverage is not shaping. Text no family covers is refused as `EXPORT_GLYPH_COVERAGE`, with the families tried.
- **Without export fonts.** The PDF draws in Helvetica and accepts WinAnsi text only (ASCII, Latin-1 and the Windows-1252 additions such as `€` and curly quotes). Any other character is refused as `EXPORT_FONT_REQUIRED` instead of being drawn broken. This includes the narrow no-break space that `Intl` writes in French and other number formats.
- **Which set.** An explicit `fonts` on the call wins, then the nearest `<ChartDefaults exportFonts>`; `fonts: 'inherit'` skips the defaults for one call. The context menu's exports use the defaults, as the handle's do. A standalone `exportChartAsPdf` reads no defaults and takes the set it is given.
- **Metrics.** The chart is laid out in its live font and the export does not lay it out again. An export font with other metrics can shift text; put the live family first when fidelity matters.
- **SVG and PNG** consume the same set (below).

For text the PDF refuses, the shaped routes are the host's: export SVG or PNG (the browser shapes every script), embed that image in a PDF of the host's own, or print the page from a browser.

**SVG and PNG export fonts (ADR-059).** The same `ChartExportFonts` object, resolved the same way (the call's `fonts`, else the nearest `<ChartDefaults exportFonts>`, else none; `fonts: 'inherit'` skips the defaults for one call; the context menu's PNG and SVG use the defaults), serves the image formats. The browser, not the package, picks a face and falls back per glyph.

| Mode | SVG | PNG |
| --- | --- | --- |
| No set (inherit) | unchanged: the live chart's fonts by name, nothing embedded | unchanged: the page's same-origin web fonts of the live stack, embedded best effort; a glyph outside them draws in a browser default font (CH-6 stays here) |
| A set, `embedInSvg` absent or `false` | every text names the set's families in the host's order, then its own live stack; no face is loaded or embedded | every face of every family is embedded as `@font-face` data; the text names the families as in SVG; the page's fonts are not read |
| A set, `embedInSvg: true` | as above, plus one `@font-face` rule per face (family, `font-weight`, `font-style`, the unchanged bytes as a data URL, `format()`) | as above (`embedInSvg` does not change a PNG) |

- **Family names.** Written verbatim and always quoted, with quotes, backslashes and control characters escaped, so a name cannot end the declaration. A name is never interpreted: a family called `sans-serif` is that family, not the generic one. A family listed twice is written once; a live family with the same name as a set family is not repeated.
- **Formats.** SVG and PNG embed TrueType, CFF-flavoured OpenType (`OTTO`), WOFF and WOFF2, each with the media type and `format()` its signature names; nothing is converted. A collection (`ttcf`) and unknown bytes are refused (`EXPORT_FONT_FORMAT_UNSUPPORTED`, with `detected`). A PDF still embeds TrueType only.
- **Loading.** An SVG that only names the families loads no face. A PNG, and an SVG with `embedInSvg`, load every face before the capture moment through the same load cache as the PDF: a loader runs once per face object, concurrent exports share it, a failed load is forgotten. The package fetches nothing; a PNG with a set does not read the page's font files either.
- **Fallback (CH-6).** A PNG with a set rasterises in its embedded faces: a glyph the primary lacks draws in the first supplied family that has it, and only a glyph no supplied family has reaches a system font. The browser shapes and falls back per glyph; the package orders the families and does nothing per glyph. Without a set CH-6 stays as documented: inherit mode is best effort.
- **Viewers.** Embedded faces in an SVG are honoured by browsers. Design tools and operating-system previewers may ignore them and use their own fonts; the package promises nothing there.
- **Metrics.** As for PDF: no relayout, so an export font with other metrics can shift or clip text.

**Export errors.** `ChartExportError.code` is stable across message wording:

| Code | When | Metadata |
| --- | --- | --- |
| `EXPORT_DEPENDENCY_MISSING` | `jspdf` or `svg2pdf.js` could not be loaded, or the installed `jspdf` has no TrueType parser | `format`, `missingModule` |
| `EXPORT_CAPTURE_FAILED` | the chart is not drawn, was removed, never came to rest, kept changing style, or its image could not be decoded | `format` where format-specific |
| `EXPORT_NO_DATA` | a CSV export found no rows | `format` |
| `EXPORT_FONT_REQUIRED` | PDF text outside WinAnsi without export fonts | `format`, `codePoint` |
| `EXPORT_FONT_FORMAT_UNSUPPORTED` | a face is not a format the export embeds (PDF: TrueType; SVG and PNG: TrueType, OpenType CFF, WOFF, WOFF2) | `format`, `family`, `detected` |
| `EXPORT_FONT_FACE_MISSING` | the set has no family, or a family has no regular face | `format`, `family` |
| `EXPORT_FONT_SOURCE_INVALID` | empty bytes, a loader result that is not bytes, unreadable TrueType, an invalid weight, style or name | `format`, `family`, `cause` |
| `EXPORT_FONT_LOAD_FAILED` | a host loader rejected | `format`, `family`, `cause` |
| `EXPORT_TEXT_UNSUPPORTED` | text the PDF backend cannot draw | `format`, `reason`, `codePoint`, `script`, `normalizable` |
| `EXPORT_GLYPH_COVERAGE` | no family covers a text element | `format`, `codePoint`, `families` |

An error carries at most one code point and a script name, never the exported text, so a host can log it.

**Data (W4, W11).** `exportAsCsv` writes the host's raw rows. A standalone family writes its own data model: categorical families their data rows; treemap and sunburst every node's `path` and its resolved measure as `value` (§ 3.3, ADR-112: a parent's derived value, `null` for no value; and its raw `colorValue` under `colorMeasure`); sankey its links (`source`, `target`, `value`, by node label); the organisation chart `name`, `parent`, `title`; the timeline `date`, `label`, `category`, `description` (and `time`, the machine form, on a time axis); gauge, linear gauge and bullet `value`, `min`, `max` (and the target's number, never its label or the ticks); the sparkline its points. Raw rows whose columns nothing declares (the categorical families, the heatmap) export every field any row carries, once each, in first-seen order: a field the first row leaves out is never dropped (ADR-111). Explicit `columns` win, and copy-as-table writes the same columns as the CSV. A chart without rows rejects the export. Recipe: `recipes/EXPORT.md`.

### Standalone export functions

The same exports are available as plain functions for a host that composes them itself (for example, exporting an SVG obtained through `getSvgElement()`):

```ts
exportChartAsPng(svg: SVGSVGElement, filename: string, scale = 2, options?: PngExportOptions): Promise<ChartExportResult>;
exportChartAsSvg(svg: SVGSVGElement, filename: string, options?: SvgExportOptions): Promise<ChartExportResult>;
exportChartAsPdf(svg: SVGSVGElement, filename: string, options?: ExportPdfOptions): Promise<ChartExportResult>;
exportChartAsCsv(data: ChartDatum[], columns: string[], filename: string, options?: CsvExportOptions): Promise<ChartExportResult>;
```

- `PngExportOptions`: `backgroundColor?`, `legendItems?` (`readonly ChartExportLegendItem[]`, or a function read at the capture moment; `useChartLegend().entries` passes as is), `legendOptions?` (`ChartExportLegendOptions`: `position`, `align`), `report?`, `fonts?` (`ChartExportFonts`).
- `SvgExportOptions`: `legendItems?`, `legendOptions?`, `report?`, `fonts?` (`ChartExportFonts`, with `embedInSvg` to embed). A standalone image function, like `exportChartAsPdf`, reads no `ChartDefaults`: it takes the resolved set or none.
- `ExportPdfOptions`: `pageSize?` (`PdfPageSize`), `orientation?`, `scale?`, `backgroundColor?`, `margin?`, `legendItems?`, `legendOptions?`, `report?`, `fonts?` (`ChartExportFonts`; a standalone function reads no `ChartDefaults`, so it takes the resolved set or none) — same defaults as `ChartHandle.exportAsPdf`.

The image functions wait for the chart to come to rest like the handle does (a detached SVG is taken as it is).

Every option type takes `download?` (default `true`), and every function resolves with the `ChartExportResult` of the handle, the CSV one included (it is asynchronous like the others).

- `CsvExportOptions`: `columnSemantics?` (`CsvColumnSemantics`, a map from column to `CsvValueSemantic`), `download?`. Nothing temporal is inferred without it: an undeclared `Date` is written losslessly as an instant.

PNG, SVG and PDF capture one moment of the chart (ADR-044): every colour an element declares is resolved in that element's own scope and written as `rgb()`/`rgba()`, so the file renders standalone and a PDF can read it; when the page styles change during the export it starts again, and after three attempts it rejects. PNG and PDF take the chart's own background unless `backgroundColor` names another. `exportChartAsCsv` appends `.csv` to the filename when it is missing.

**CSV rules.** These apply to both `ChartHandle.exportAsCsv` and `exportChartAsCsv`:

- String values go through the shared delimited-text boundary of `@workspace/active-helpers/serialization`: always quoted, formula-neutralised, and safe for CR, LF and tab.
- Numbers, booleans, instants and declared calendar days are written as unquoted literals.
- The header row is encoded like data.
- Columns derived from a chart's series include the data keys of candlestick, range and box-plot series.
- **Raw rows** (W4). `ChartHandle.exportAsCsv` and the context menu's CSV export write the chart's full `data`, never the reduced or grouped rows the chart displays, and ignore the zoom. The context menu's copy-as-table writes the raw rows of the visible (zoomed) range.

### Export boundary (ADR-053)

**The capture moment.** An image export waits until the chart is still and the fonts are loaded, then clones the chart and reads its legend, report text and colours in one synchronous step: the capture moment. The file shows that moment and nothing else.

- **Style change.** A style generation change before the capture moment (while the chart settles) delays it: the capture composes once, after the change. A change during encoding (font embedding, rasterising, writing the PDF) discards that attempt and captures again. After three attempts the export rejects with a `ChartExportError`. No file mixes two generations.
- **Data change.** Point-in-time, not transactional. A change the host makes before the capture moment re-renders the chart, the export waits for it to settle, and the file shows the new state: the drawing and the legend come from the same commit (the handle reads its legend at the capture moment). A change after the capture moment does not reach the file and does not restart the export. `exportAsCsv` writes the rows of the latest commit at the moment it is called; it does not wait.
- **Standalone functions.** `legendItems` may be a function; it is called at the capture moment. An array is taken as given.

**What the export carries.** The capture materialises what the chart element itself declares; it is not a general CSS serialiser.

| Style | Class | Notes |
| --- | --- | --- |
| `fill`, `stroke`, `stop-color`, `flood-color`, `lighting-color` an element declares (attribute or inline) | Supported (materialised) | resolved in that element's own scope and written as `rgb()`/`rgba()`; `currentColor`, `color-mix()`, `oklch()` included |
| `opacity`, `fill-opacity`, `stroke-opacity`, `stop-opacity` an element declares | Supported (materialised) | as computed for the element |
| `font-family` an element declares | Supported (materialised) | `var()` resolved; kept by name in SVG and PNG, after the export font set's families when there is one (ADR-059); PDF names, per text element, the first export font family that covers it, or Helvetica (ADR-058) |
| Host tokens and scoped overrides (`--chart-*`, a scoped CSS override, dark mode, an accent) | Supported (materialised) | resolved where the element sits, so a scoped override exports as it renders |
| Chart background | Supported (materialised) | the chart's surface token, read at the capture moment: the page colour of PNG and PDF unless `backgroundColor` names another |
| Legend and report text colours | Supported (materialised) | written as `rgb()`/`rgba()` like the chart's own paint (ADR-053) |
| Geometry, text content in any script, other attributes and inline styles the chart writes | Natural (the clone keeps them) | `stroke-width`, `font-size`, `font-weight`, dash arrays and the like travel as written |
| Host web fonts in PNG, without an export font set | Not guaranteed | same-origin `@font-face` rules with a `url()` source of the families the chart uses are embedded; glyphs outside them draw in the browser's default font (CH-6) |
| An export font set in PNG | Supported | every face embedded; per-glyph fallback across the supplied families before any system font (ADR-059) |
| An SVG file opened outside a browser page | Not guaranteed | the file names the host font (or the set's families); the viewer uses them if installed, else its own fallback; embedded faces may be ignored |
| Properties that only a stylesheet rule gives (a class rule, `text-transform`, a filter) | Not guaranteed | not copied: the export shows the chart's declared paint |
| Interaction state: hover, recede, inactive, keyboard cues, selection marks, legend preview, crosshairs, nodes (ADR-064, ADR-070); `:focus-visible` and other focus-only CSS; arbitrary selectors | Unsupported (by design) | the export shows the chart at rest: the state is stylesheet paint, which is never copied, and the transient layer and state attributes are left out of the clone |
| Print-only CSS (`@media print`) | Unsupported | the capture reads the screen media |
| Cross-origin fonts; `@font-face` in an `@import`ed or adopted stylesheet; `local()` fallback faces | Unsupported | not embedded in a PNG; the tokens such sheets set still resolve through the computed style |

**Fonts per format.**

| Format | Font handling | Non-Latin text |
| --- | --- | --- |
| SVG | the host font by name; nothing embedded | kept as UTF-8 text; renders wherever the viewer has a font for it |
| PNG | same-origin `url()` web fonts embedded; other glyphs in the browser's default font | rasterised by the browser; proven for Cyrillic, Greek, Arabic, Japanese and Korean (Chromium) |
| PDF | per text element, the first family of the export fonts that covers it (bold and italic faces optional, never synthesised), else Helvetica | Helvetica draws WinAnsi only and refuses the rest; with export fonts, exactly the characters they cover; complex scripts, combining marks, emoji and supplementary-plane characters are refused (ADR-058) |

Every format is produced in a browser. The headless path is a browser too (ARCHITECTURE § 10.3); measured, a headless and a visible Chromium produced the same files, sizes and text (PERFORMANCE.md, Rapportage en export: headless tegenover zichtbaar).

**Family words.** active-grid uses the same five concepts: data export (CSV, the raw rows), presentation export (SVG, PNG, PDF), completion (`onExportComplete`, the file was produced, and handed to the browser unless `download: false`), failure (`onExportError`, no file) and scope (CSV: all raw rows, zoom ignored; images: what is drawn). See active-grid ADR-046.

---

## 5. `ChartDefaults` Context

Application-wide defaults. Any prop explicitly set on a chart overrides the context.

```tsx
<ChartDefaults
  locale={locale}
  t={t}
  accessibility={{dataTable: {enabled: true}}}
  animation={{profile: 'calm'}}
>
  <AppShell />
</ChartDefaults>
```

Keys available on the provider: `locale`, `t`, `accessibility`, `tooltip`, `contextMenu`, `animation`, `exportFonts`, `diagnostics`. `<Chart>` merges them as every wrapper does: its own `options.tooltip` and `options.accessibility` over the provider's, field by field (ADR-107). No archetype-specific fields — those are per chart. **No visual field**: the palette, surfaces and text come from the host's CSS tokens (§ 13); a chart-level `colors` palette is set on the chart and never inherited (ADR-045).

`locale` is a `ChartPresentation`, an alias of the active-format `ValuePresentation` (locale code or presentation-context object). A nearer value replaces a farther one whole; a presentation object is never merged per field. A host application that mounts `<ChartDefaults locale={presentation}>` makes every chart below it — axes, tooltips, crosshairs, data labels and screen-reader output — follow that presentation without per-chart props.

**Presentation ownership (ADR-026).** A chart's presentation comes from its `locale` prop or from `<ChartDefaults locale>`, and from nowhere else: no `'en'` fallback, no browser locale, no reach into the active-i18n provider. A chart, `<Chart>` or `useChartAxisFormatters` without either throws `Error('active-charts: … has no presentation. …')` — a configuration error of the host, not a user-data condition.

**Translation ownership (ADR-028).** A chart's words follow the same rule: its `t` prop or `<ChartDefaults t>`, and nothing else. The package owns the keys (`ChartTranslationKey`) and never falls back: no implicit English, no key-segment fallback, no reach into the active-i18n provider. `englishChartTranslator` is the package's English (complete by type), a value a standalone host, fixture or example passes on purpose (`t={englishChartTranslator}` or `<ChartDefaults t={englishChartTranslator}>`); the package never installs it itself (ADR-051). A chart, `<Chart>`, `PeriodSelector` and the default fallback of `ChartErrorBoundary` without a translator throw `Error('active-charts: … has no translator. …')`. Accessible names, `<desc>` descriptions, keyboard announcements and the screen-reader table use the same translator as the visible copy. The host supplies the translator, typically once on `<ChartDefaults locale={presentation} t={translate}>`, and can prove against `ChartTranslationKey` that its translations cover every key of the contract. `generateAriaLabel(options, t)` and `applyIndicators(data, indicators, t, sourceKey?)` take the translator as an argument.

**Nesting refines.** A `<ChartDefaults>` inside another one merges its own values over the enclosing provider with the same per-field rules a chart prop follows over the context; `locale` and `t` are replaced whole. Sibling providers share nothing. The grid's counterpart is `GridDefaults` of active-grid; a host mounts both from one adapter (ADR-051). A host sets `locale` once; a feature scope such as `<ChartDefaults accessibility={...}>` keeps that presentation instead of discarding it.

**Export fonts (ADR-058, ADR-059).** `exportFonts` (`ChartExportFonts`) is the font set the PNG, SVG and PDF exports of every chart below use, from the handle and from the context menu (§ 4, PDF text and export fonts; SVG and PNG export fonts). It is export input, never a theme: the live chart keeps the font its CSS gives it. A nested provider's set replaces the enclosing one whole; an explicit `fonts` on an export call wins over it, and `fonts: 'inherit'` skips it for one call. There is no per-chart prop: a nested `<ChartDefaults exportFonts>` scopes a set to part of the tree.

### Diagnostics

```typescript
import {
  ChartDefaults,
  type ChartDiagnostic,
  type ChartDiagnosticLevel,
  type ChartDiagnosticSink,
} from '@workspace/active-charts';

type ChartDiagnosticLevel = 'debug' | 'warn' | 'error';

interface ChartDiagnostic {
  readonly level: ChartDiagnosticLevel;
  readonly source: string;
  readonly message: string;
  readonly data?: Readonly<Record<string, unknown>>;
}

type ChartDiagnosticSink = (diagnostic: ChartDiagnostic) => void;
```

`diagnostics` receives every diagnostic of the charts below it as a `ChartDiagnostic`, with a caught error under `data.error` and `source` naming the part (`ActiveCharts`, `ActiveCharts.BarChart`, `ActiveCharts.SankeyChart`). Like `t`, it is atomic: a nearer provider replaces it whole, and a provider that sets none keeps the enclosing one. Without one, `warn` and `error` go to the console and `debug` is dropped. An inline arrow is safe; the charts forward to the sink of the latest render without re-creating their callbacks. There is no per-chart prop. The charts import no logging package; a host routes the sink to its own logging, for instance by passing its logger's structured-diagnostic function as the sink. The shape and the provider prop are the same as active-grid's `GridDiagnostic` on `<GridDefaults diagnostics>` (ADR-076).

What a chart reports:

- **Development diagnostics** (`warn`): input or options a chart cannot honour in full — contract-invalid input behind the unavailable state (W12), a series bound to an undeclared axis, repeated series identities or categories, a `format` shadowed by a `formatter`, an invalid format string, an unsupported `animation.type`, unreadable explicit ticks, a `Date` on a category axis, a missing data key, options that combine into a layout the chart cannot draw (`grouped` with `stackedPercentage`, `errorBars` on a stack or on a family without error bars, y zones on a stacked area, x zones on a category axis), error bounds whose low lies above their high, zone ranges that cannot be read or overlap, soft bounds that are ignored (non-finite, crossed, shadowed by `min`/`max`, at or below 0 on a log axis, on a non-numeric axis), a chart that stays ungrouped because a series has error bars, selection props without `selection`. They are reported outside production only, once per ground (the condition plus the input object it is about); a re-render, Strict Mode or a recompute with the same input does not repeat them. The ground is the object's identity, not its content: an `options` object, a `series` array or a `data` array created anew on every render is a new input each time, so a diagnostic about it can repeat on every render. Memoise such inputs (`useMemo`) or hoist constant ones out of the component for one report per cause. Where a condition is about a value with no object of its own (a format string, a series or animation type, an explicit tick interval), its ground is the chart that met it: each chart is told once, and no chart ever silences another (ADR-108). A diagnostic's `source` is the part that emitted it: `ActiveCharts`, or `ActiveCharts.<Component>` where a component reports about its own props. To observe or test diagnostics, pass a `diagnostics` sink rather than spying on the console. A message names structure (field names, positions, depths, counts, configuration) and never host data: no category value, label, display name or amount.
- **An unsupported workload** (`warn`, ADR-115 F3): a supported shape beyond a structural limit, today a treemap or sunburst deeper than its provisional depth boundary (§ 3.3). Reported in every environment, once per ground, with structural counts only (the depth and the limit): it describes production data volume an operator must see.
- **An operational failure** (`error`): a context-menu export that failed without `onExportError` (`message: 'Export failed'`, `data: {error, context: format}`), and a context-menu copy that failed without `contextMenu.onCopyError` (`message: 'Copy failed'`, `data: {action, code, errorName}`, never the copied text; § 2.3), in every environment.

**The unavailable state says why (ADR-115).** The placeholder carries `data-chart-state="unavailable"` and `data-chart-unavailable="input"` for input that breaks the chart's contract (`charts.unavailable`) or `"workload"` for a supported shape beyond a structural limit (`charts.unavailableWorkload`); its text is the host's words and never a diagnostic. An unavailable chart offers no CSV (the CSV route rejects with `EXPORT_NO_DATA`); PNG and SVG capture the placeholder as drawn. `ChartErrorBoundary` is for unexpected errors only (F5): invalid input, an unsupported workload and a failed action each have their own outcome and need no boundary.

Diagnostics never change what a chart draws, and a thrown configuration error (missing presentation or translator, `ChartExportError`) stays a throw.

### `useChartAxisFormatters(presentation?)`

Shared axis-formatter factories for the common label shapes, resolving their presentation from the explicit argument, then `ChartDefaults.locale`; with neither, the hook throws a configuration error (ADR-026). Returns `ChartAxisFormatters`:

| Factory | Produces |
| --- | --- |
| `number(preset?)` | numeric axis labels via `formatNumber` (default `'decimal'`) |
| `currency(currency, options?)` | money labels via `formatCurrency` — the ISO 4217 identity is a required argument (data semantics, never derived from language) |
| `fractionAsPercent(preset?)` | percent labels for 0-1 data |
| `percentagePoints(preset?)` | percent labels for 0-100 data |
| `date(semantic, preset?)` | date labels read by the axis's `temporalSemantic` (default preset `'day-month'`; a calendar date takes a calendar-day preset) |

Each factory returns `(value: unknown) => string`, directly usable as `axis.label.formatter`. Non-numeric (resp. non-date) values pass through as `String(value)`. The non-hook `createChartAxisFormatters(presentation)` variant exists for non-component code.

---

## 6. Feature-Toggle Pattern

`FeatureOption<T> = T | boolean` is the **only** feature-toggle shape. The semantics are uniform:

| Value | Meaning |
| --- | --- |
| `true` | Enable with the chart's default configuration. |
| `false` | Disable entirely. |
| object | Enable with the explicit configuration; merged over defaults. |

Applies to: `tooltip`, `contextMenu`, `animation`, `legend`, `crosshairs`, `zoom`, `navigator`, `sync`, `selection`, `colorLegend`, `dataLabels` (except on a scalar chart, whose readout is always drawn, §3.5).

**One tri-state, on purpose.** `NightingaleChart`'s `dataLabels.connector` is `boolean | 'auto' | NightingaleConnectorOptions`: `true` draws every connector, `false` none, `'auto'` (the default) only where the gap from the sector to its label reads as a line, and the object form draws every connector with its own stroke (`enabled: false` draws none). A boolean cannot say "where it reads", so the option is not a `FeatureOption` (ADR-107).

**Plain booleans, on purpose.** `drilldown`, `sizeLegend`, `zoomControls` and `GaugeChart.replay` are `boolean`, not `FeatureOption`s: there is nothing to configure beyond on or off, and an object form would be an empty promise. Inside `contextMenu.features`, `copy`, `zoom`, `series` and `replay` are switches and `export` is `ChartContextMenuExportFeatures` (§ 2.3).

---

## 7. Adding a chart family

Adding a chart family, series or variant inside the package follows **ADDING-A-CHART-FAMILY.md**, the one authoring guide: whether it is a family at all, its archetype, the shared routes it must use, the family register, the UI Lab page, the evidence and the Definition of Done. Read it first. This reference stays the owner of the public contract the guide points to (§§ 1–6, § 10, § Public surface); it holds no authoring procedure of its own.

---

## 8. Upgrading

Consumer upgrade steps (renamed props, removed props, changed shapes) are listed per release in `migration/MIGRATION-NOTES.md` and `CHANGELOG.md`. This reference describes only the current contract.

---

## 9. Axis Configuration

Every Cartesian chart exposes `xAxis` and `yAxis`. Both accept either a type-string shorthand (`xAxis="category"`, `"number"`, `"log"`, `"sqrt"`) or an `AxisConfig` object. A temporal axis (`time`, `ordinal-time`) has no shorthand: it is always an object that declares its `temporalSemantic` (§9.1).

### 9.1 Axis Types

| `AxisType` | D3 scale | When to use |
| --- | --- | --- |
| `number` | `scaleLinear` | Continuous numeric values. Default for Y-axis. |
| `log` | `scaleLog` | Positive values with exponential spread (prices, populations). |
| `sqrt` | `scaleSqrt` | Wide-range values where area proportionality matters (bubble sizes, wealth distribution). Mild compression at the top, stretching at the bottom — permits zero in the domain, unlike `log`. |
| `time` | `scaleTime` | Continuous time. Tick positions reflect real calendar spacing. |
| `ordinal-time` | `scalePoint` | Dates rendered evenly, regardless of calendar gaps (daily trading data). |
| `category` | `scaleBand` | Discrete labels. Default when bar/histogram/waterfall/box-plot is detected. |

#### Temporal semantic — required on every time axis

A `time` or `ordinal-time` axis declares what its values **are** (ADR-027). There is no default, because nothing about a value tells: the ordinal-time scale turns the calendar day `2026-01-01` into `2026-01-01T00:00:00.000Z`, which is also what a login at midnight UTC looks like.

| `temporalSemantic` | The value is | How every surface reads it |
| --- | --- | --- |
| `'calendarDate'` | a calendar day: a trading day, a reporting date, a month | The day of the value itself (the date part of a string, the UTC fields of a `Date` or timestamp), formatted without a zone. `2026-01-01` is 1 January in every zone. Language and notation follow the presentation. |
| `'instant'` | a point in time: a page view, a login, a minute or hour bucket | In the zone of the chart presentation. The same value can fall on another day in another zone. |

- **Who declares:** the consumer that knows the data, on the axis: `xAxis={{temporalSemantic: 'calendarDate'}}` on a wrapper, `{position: 'bottom', type: 'ordinal-time', temporalSemantic: 'instant'}` on `<Chart>`. On `LineChart`, `AreaChart`, `RangeAreaChart`, `CandlestickChart` and `OhlcChart` the default x axis is temporal, so `xAxis` is required.
- **Where it applies:** tick labels (auto-formatter and fallback), the parent row, the tooltip, the crosshair, the screen-reader table, the keyboard announcement, `useChartAxisFormatters().date` and the CSV / clipboard export. No surface reads a temporal value any other way.
- **What fails:** a missing declaration does not compile, and an untyped one (a cast, a `<Chart>` without axes) throws a configuration error when the chart renders.
- **A custom `label.formatter`** receives the raw value and must read it the same way: `formatDateOnly` for a calendar date, `formatDate` for an instant.
- **A value on any other axis is a label** and is never guessed to be a date.

#### Default x-axis per chart

When you omit `xAxis` (or pass an `xAxis` object without a `type`), each wrapper resolves a default scale. **Bar-class charts default to `category`** — this is the single most common source of date-axis bugs (see the warning below).

| Wrapper(s) | Default x-axis | D3 scale |
| --- | --- | --- |
| `LineChart`, `AreaChart`, `RangeAreaChart` (declare `temporalSemantic`) | `ordinal-time` | `scalePoint` |
| `CandlestickChart`, `OhlcChart` (time-only; declare `temporalSemantic`) | `ordinal-time` | `scalePoint` |
| `BarChart`, `Histogram`, `WaterfallChart`, `BoxPlotChart`, `RangeBarChart` | `category` | `scaleBand` |
| `DumbbellChart`, `LollipopChart` | `category` | `scaleBand` |
| `ScatterChart` | `number` | `scaleLinear` |

#### Formatter `value` runtime type per axis type

A tick `formatter` receives a value whose runtime type depends on the resolved scale, not on what you passed as `xKey`. Each wrapper narrows the `formatter` value to its default scale **at compile time**, so the table below is enforced rather than advisory: on a category-default wrapper `v` is `string`, so `formatDate(v as Date, …)` / `v as Date` do not compile. The narrowing is uniform per wrapper (an explicit non-default `type` does not re-narrow the value); see `DECISIONS.md` ADR-018.

| Axis type | `formatter` `value` at runtime | Correct handling |
| --- | --- | --- |
| `category` | `string` — `String(domainValue)` | Treat as a label; do not re-parse as a `Date`. |
| `time` | `Date` | by the declared semantic: `formatDateOnly` for `calendarDate`, `formatDate` for `instant` |
| `ordinal-time` | `string` — ISO date string (`"2024-01-15T…"`) | by the declared semantic, as for `time` |
| `number` / `log` / `sqrt` | `number` | numeric formatting (`formatCurrency`, `formatNumber`, …) |

For tighter typing, declare formatters with the exported `AxisFormatter<V>` alias and one of `TimeAxisTickValue`, `NumberAxisTickValue`, `CategoryAxisTickValue`.

> ⚠️ **Date on a `category` axis — the silent-invalid-date trap.** A `category` axis keys its bands by the category as `ChartItemKey` reads it (A6, ADR-106) and hands the label `formatter` the category's text, `String(value)`. A `Date` xKey therefore reaches the formatter as a non-ISO string like `"Fri May 01 2026 00:00:00 GMT+0200 (Central European Summer Time)"`. A `formatter` that does `formatDate(v as Date, …)` receives that string, fails to parse it, and renders **an empty tick label**. Because bar-class charts default to `category`, this happens whenever you pass `xKey` pointing at a `Date` field without an explicit non-category `type`. **This does not compile** on bar-class wrappers (the `formatter` value is typed `string`), and a dev-only runtime guard logs one error per dataset if a `Date` reaches a band scale through a type bypass (`as` / `any` / untyped data).
>
> Two correct patterns:
>
> ```tsx
> // Pattern A — pre-format the label to a string, use a category axis.
> // Best for bar-class charts (bars need a band scale for their width).
> const chartData = rows.map((r) => ({
>   ...r,
>   label: formatDateOnly(r.month, 'month-year', locale),
> }));
> <BarChart data={chartData} xKey="label" xAxis="category" series={…} />
>
> // Pattern B — keep the Date xKey, declare an explicit time scale so the
> // formatter actually receives a Date (line/area charts).
> <LineChart
>   data={rows}
>   xKey="date"
>   xAxis={{
>     type: 'ordinal-time',
>     temporalSemantic: 'calendarDate',
>     formatter: (v) => formatDateOnly(v, 'month-year', locale),
>   }}
>   series={…}
> />
> ```

For ready-made snippets (monthly bars, daily lines, range-comparison), see `recipes/DATE-AXES.md`.

### 9.2 `AxisConfig` Fields

| Field | Type | Effect |
| --- | --- | --- |
| `type` | `AxisType` | Scale type — see §9.1. |
| `temporalSemantic` | `'calendarDate' \| 'instant'` | What the values of a `time` / `ordinal-time` axis are. **Required** on a temporal axis, forbidden on any other; no default (ADR-027, §9.1). |
| `position` | `'top' \| 'right' \| 'bottom' \| 'left'` | Override axis position. Default: `'bottom'` for x-axis, `'left'` for y-axis. Wrapper still controls orientation. |
| `title` | `string` | Axis-level caption. |
| `formatter` | `(value, index?, allValues?) => string` | Custom tick label formatter. Optional `index` and `allValues` enable boundary-aware decisions (e.g. show the year only on the first tick). Legacy `(value) => string` remains compatible. |
| `valueSemantics` | `ChartValueSemantics` | What the values on this axis **mean** (unit + precision), declared once for the axis, the tooltip and the screen-reader table. Also yields the smallest meaningful tick step — `{kind: 'count'}` is how you get whole-number ticks. Replaces the removed `integerTicks`. See §9.4. |
| `tickCount` | `number` | Target number of ticks. The pixel floor and the semantic quantum remain constraints, but nothing overrides this silently. Loses to `tickInterval` and `tickValues`. |
| `tickInterval` | `number` | Fixed step between ticks, in domain units. Stronger than `tickCount`. An unreadable choice warns in development but is honoured. |
| `targetPixelInterval` | `number` (default 72 / 100) | Target pixel gap between ticks — 72 vertical, 100 horizontal. Shapes how open the axis looks. |
| `minPixelInterval` | `number` (default 32 / 40) | Hard minimum pixel gap. A readability guarantee: no automatic resolution and no `tickCount` crosses it. |
| `min` / `max` | `number` | Clamp the domain. |
| `softMin` / `softMax` | `number` | Soft bounds (ADR-096): the automatic domain reaches at least to them and further where the data goes; they widen, never narrow. Numeric axes (`number`, `sqrt`, `log`) only; not taken by a category or time axis config. See § 10.11. |
| `nice` | `boolean` (default `true`) | Round domain to nice values. |
| `reversed` | `boolean` | Flip axis direction. Y-axis: low values at top. X-axis: right-to-left. Implemented as a range-flip — domain, zoom, crosshair, formatters all keep working. |
| `rotation` | `number` | Tick-label rotation (degrees). |
| `autoRotate` | `boolean` | Try 0° → -45° → -90° when labels overlap. |
| `maxLabelWidth` | `number` | Truncate labels past this pixel width. |
| `wrap` | `number` (default 1) | Category axes only: the most lines a name wraps into before it rotates or is cut; the full name stays in `<title>`. Time and numeric axis configs do not take it. See §10.8 (ADR-091). |
| `truncate` | `boolean` | Truncate labels that do not fit: an unrotated label to its tick spacing, a rotated one to its reserved row (at most 70 px deep), full text kept in `<title>`. Default true for category and ordinal-time axes. With `false` a rotated axis reserves its full label length (ADR-063). |
| `tickValues` | `unknown[] \| (domain) => unknown[]` | Explicit tick values, overrides auto. |
| `style` | `AxisStyleOptions` | Per-element visual overrides (line / tick / label / title). |
| `parentLevel` | `boolean \| ParentLevelOptions` | Two-row hierarchical labels. **Default: `false` for every axis type** — opt in explicitly. Exposed on `AxisConfig` (wrapper API). See §9.3. |
| `gridLine` | `{enabled?, style?}` | Grid for this axis. Grid lines always follow the axis' resolved ticks; there is deliberately no separate density (ADR-024). Default: on for a value axis (`number`, `log`), off for a `category` axis, and on for a `time` or `ordinal-time` axis only when the chart draws a line, area or range area. A zero line, one step stronger, marks zero when the domain crosses it. |

### 9.3 Hierarchical Time Labels — `parentLevel`

When enabled, the axis renders a second row of labels below (or above) the main ticks showing additional calendar context. The renderer follows the principle **"the parent row complements the main row, never duplicates it"**: based on the preset chosen for the main row, the parent shows either the year, the full date, or nothing at all.

```ts
interface ParentLevelOptions {
  enabled?: boolean; // Master switch. Default: true (when the option is set).
  formatter?: (value: unknown) => string; // Override the parent label. Receives the FIRST tick value of each group.
  fill?: string; // Override label color. Default: axis label fill.
  fontSize?: number; // Override font size. Default: same as axis labels.
  fontWeight?: number | string; // Default: 600 (semi-bold).
  showTicks?: boolean; // Divider tick marks between parent groups. Default: true.
}
```

| Form | Effect |
| --- | --- |
| (omitted) | **No parent row** (default). |
| `parentLevel: true` | Enable with defaults; the renderer picks the right content automatically. |
| `parentLevel: false` | Suppress the second row. |
| `parentLevel: {fontSize: 13}` | Customize specific properties; everything else falls back to defaults. |

The parent row exists only on a temporal axis: groups and labels both read each tick by the axis's `temporalSemantic`, so a group never disagrees with its label (ADR-027). A group is the unit its label names: a row that shows only the year groups by year (one "2025" under a year of daily ticks, not one per month), a row that shows the full date groups by day (ADR-085).

#### Dedup matrix

When the axis uses the auto-formatter (no custom `axis.label.formatter`), the parent-row content is derived from the main-row preset:

| Main-row preset | Main-row example | Parent-row content |
| --- | --- | --- |
| `time`, `time-seconds` | `14:30` | `15 Mar 2024` (full date) |
| `time-day` | `15 Mar 14:30` | `2024` (year only) |
| `day-month` | `15 Mar` | `2024` (year only) |
| `month`, `month-long` | `Mar` / `March` | `2024` (year only) |
| `short`, `long`, `full`, `numeric`, `day-month-year-short`, `month-year`, `year`, `datetime`, `datetime-long`, `iso` | `15 Mar 2024`, `Mar 2024`, `2024`, … | **suppressed** (year already in main row) |

When you provide a custom `formatter`, that formatter is always called and the dedup logic does not apply.

The auto-formatter (`createAutoDateFormatter`) is boundary-aware: the first tick of the visible window and any tick that crosses a calendar-year boundary fall back to a coarser preset (`day-month` → `short`; `day-month-year-short` → `short`) so the year is always anchored somewhere in the strip — **even without the parent row**. When `parentLevel` is enabled, the boundary highlight is suppressed automatically (the parent row already anchors the year — duplicating it on individual ticks is noise).

```tsx
// Default — no parent row, but the auto-formatter still
// anchors the year on the first tick + every year crossing:
<LineChart
  data={dailyPrices}
  xKey="date"
  yKey="close"
  xAxis={{temporalSemantic: 'calendarDate'}}
  height={300}
/>

// Opt in to a parent row — useful for long-range views (multi-year of
// daily/monthly data) where the year context matters most:
<Chart
  data={dailyPrices}
  height={300}
  options={{
    series: [{type: 'line', xKey: 'date', yKey: 'close'}],
    axes: [
      {position: 'bottom', type: 'time', temporalSemantic: 'calendarDate', parentLevel: true},
      {position: 'left', type: 'number'},
    ],
  }}
/>
```

---

### 9.4 Tick resolution — precedence and density

Ticks are resolved once, by `core/ticks/`, and the axis labels, the grid lines,
the axis margin and the export all read that one result (ADR-024). Two things
follow from that which are worth knowing as a consumer.

**Precedence is explicit.** Higher wins:

```text
tickValues      exact values, used verbatim
tickInterval    a fixed step
tickCount       a target count
automatic       from the available pixels
```

Only `tickCount` and the automatic path are subject to the pixel floor. An
explicit `tickValues` or `tickInterval` is never silently discarded — an
unreadable one warns in development and still renders.

**Density comes from pixels, not from a constant.** `targetPixelInterval`
drives the normal look, `minPixelInterval` is the guarantee. The axis picks a
step from the round-number family `{1, 2, 2.5, 5} × 10ⁿ`, then measures the
formatted labels and coarsens until they fit.

**Whole-number ticks come from the data's meaning, not from a flag.** There is
no `integerTicks` and no `allowDecimals`:

```tsx
// A count of people, logins, documents:
yAxis={{valueSemantics: {kind: 'count'}}}
```

Beyond the declaration there is a guard that needs no configuration at all: if
two adjacent ticks would render the **same label** through the actual formatter,
the axis takes a coarser step. That covers compact notation, consumer closures
and every locale, so a chart without any declaration still cannot show `1, 1, 2, 2`.

**Category axes keep their labels longer than time axes do.** A category label
identifies a bar, so rotation and truncation are tried before any label is
dropped. A time-series label marks a position on a continuum, so thinning is
free — and there it lands on calendar boundaries (the first observation of each
week, month, quarter or year) rather than on every n-th point.

---

## 10. Data Labels

All data labels accept an optional `background?: LabelBackgroundOptions` that renders a `<rect>` behind the label text. Available on cartesian `LabelOptions` (bar, line, scatter, histogram, waterfall — via `series.label`; heatmap via its own `label`) and standalone `DataLabelOptions` (pie, funnel, nightingale, radial-bar, gauge, linear-gauge, bullet — via `dataLabels`). Funnel and radial bar draw their labels in the family's name and value roles, so they take no `fontSize` or `color`; a scalar chart takes only `formatter`, `format` and `background` (§3.5).

On a bar or waterfall, a label background counts as the surface behind an inside label only through its `fill`: the label stays inside where the label colour reads 4.5:1 on that fill (composed over the bar where it is translucent). A background with only a `stroke`, or a `transparent` fill, is decoration and padding; the label is then measured on the bar itself (ADR-079).

**Inside values on the platform palette.** A bar is data and keeps its colour (ADR-075), so a bare inside label stays inside only where the mode's foreground reads 4.5:1 on the bar. On the platform palette that holds in light mode for `--chart-2`, `-4`, `-7`, `-9` and `-10` (not for `--chart-1`, the colour of a single series) and in dark mode for none: there the labels sit above their bars. To show values inside in every mode, give the label a filled background, for example the mode's background (ADR-083):

```tsx
import {formatNumber} from '@workspace/active-format';

<BarChart
  data={rows}
  xKey="quarter"
  xAxis="category"
  yKey="assets"
  label={{
    enabled: true,
    position: 'inside',
    formatter: (value) => formatNumber(value, {decimals: 1}, locale),
    background: {fill: 'var(--background)', borderRadius: 3},
  }}
  height={280}
/>;
```

The radial bar's angular scale ticks at round values, and never finer than what the values can mean: a count ticks at whole numbers (the semantic quantum of `valueSemantics`, ADR-108). The radial bar draws one name row and one value row per ring. Each name is keyed by a small square in its ring's colour, in one straight column between the right-aligned names and the rings (the name itself keeps the one label colour). Each row gets at least one text line: where the rings sit closer, the rows are thinned at one regular step, outermost first, as a crowded category axis is thinned; the rings never change for it, and every name and value stays in the tooltip, the announcement and the data table. Where the value column alone would push the rings below their readable thickness, the values are left out first. No name is ever drawn over another, and the pointer moves from ring to ring across the gaps without the chart returning to rest. Under the pointer the tooltip stands beside the pointer, towards the centre first and never over the addressed ring, and keeps its side from ring to ring while that side fits; on a heatmap it keeps its side from cell to cell while that side fits, never over the addressed cell; where a heatmap is too short for a card to stay above or below its cells through a vertical move, it stands beside the cell first (ADR-079).

An `outside` bar label sits just beyond the bar's value end: above a bar that grows up and below one that grows down, right of a horizontal bar that grows right and left of one that grows left (a negative value, or a positive one on a `reversed` value axis). Where that side would leave the plot, the label moves inside the bar (ADR-077).

A heatmap cell whose value is `null`, `undefined`, `NaN` or infinite has no value: it is drawn empty, carries no label, shows no tooltip, and reads "(no value)" to the keyboard and in the data table; it never counts as 0 and never widens the colour scale. A `0` is a value like any other (ADR-077).

```ts
interface LabelBackgroundOptions {
  fill?: string; // Background fill. Default: undefined (no fill).
  stroke?: string; // Border color. Default: undefined (no border).
  strokeWidth?: number; // Border width px. Default: 0, or 1 when stroke is set.
  borderRadius?: number; // Corner radius px. Default: 0.
  padding?: number | {x?: number; y?: number}; // Default: {x: 4, y: 2}.
}
```

When `background` is omitted, labels render as bare `<text>`. When neither `fill` nor `stroke` is visible (both absent, transparent, or `strokeWidth: 0`), the `<rect>` is skipped entirely — no extra DOM nodes.

```tsx
<Chart
  data={data}
  height={300}
  options={{
    series: [
      {
        type: 'line',
        xKey: 'month',
        yKey: 'revenue',
        label: {
          enabled: true,
          background: {
            fill: '#fef3c7',
            borderRadius: 4,
            padding: {x: 6, y: 3},
          },
        },
      },
    ],
    axes: [
      {position: 'bottom', type: 'category'},
      {position: 'left', type: 'number'},
    ],
  }}
/>
```

### 10.1 Format Strings

Every `formatter` location in the public API accepts a sibling `format?: string` field: a [D3-format mini-language](https://d3js.org/d3-format) specifier that is compiled once and applied to numeric values. `format` is a pure-string shorthand for the most common cases (currency, percentages, fixed precision, SI prefixes) and is safe for JSON / CMS-driven configuration where a function literal cannot be serialized.

```tsx
// Axis tick labels
<LineChart data={rows} xKey="x" yKey="y" xAxis={{type: 'number', format: '$,.0f'}} height={300} />

// Data labels on bars (one `label` option for the wrapper's series)
<BarChart data={rows} xKey="category" yKey="share" label={{enabled: true, format: '.0%'}} height={300} />

// Hierarchical / scalar data labels
<PieChart data={rows} valueKey="value" categoryKey="name" dataLabels={{enabled: true, format: ',.0f'}} height={300} />
<GaugeChart value={0.73} dataLabels={{format: '.1%'}} height={200} />
```

**Supported locations.** The `format` string is accepted wherever a `formatter` callback is accepted:

| Location | Field | Applies to |
| --- | --- | --- |
| Axis tick labels (compiled) | `AxisConfig.format` | x-axis / y-axis on all cartesian charts |
| Axis tick labels (internal / advanced) | `AxisOptions.label.format` | Same — low-level type activated |
| Cartesian series data labels | `LabelOptions.format` | `bar`, `line`, `scatter`, `histogram`, `waterfall` series |
| Heatmap cell labels | `HeatmapChartProps.label.format` | `HeatmapChart` |
| Hierarchical / scalar data labels | `DataLabelOptions.format` | `PieChart`, `NightingaleChart`, `RadialBarChart`, `FunnelChart`, `GaugeChart`, `LinearGauge`, `BulletChart` |

**Precedence.** `formatter` always wins when both are supplied on the same options object. Setting both is permitted (useful when a `format` default is overridden by a callback in a specific chart) but reports a development diagnostic, once per format, to flag that the string is shadowed.

**Invalid specs.** A malformed format string is caught at render time: a development diagnostic `Invalid format string "…"` is reported once per spec, with the parser's error under `data.error`, and the axis/label falls back to its built-in behaviour (auto-formatter for axes, the label's default text for data labels: the series' value semantics, else the plain number). No exception is thrown.

**Non-numeric values.** The D3-format function runs only when `typeof value === 'number'`. String / date values on category and time axes pass through `String(value)` unchanged, so applying a `format` string to a non-numeric axis is a silent no-op rather than an error.

**Locale.** Format strings use d3-format's default symbols (US grouping `,`, US currency `$`, US decimal `.`). They are **locale-neutral** — they do **not** read `BaseChartProps.locale` or consult `@workspace/active-i18n`. For locale-aware output (Dutch `€1.234,56`, German `1.234,56 €`, nl-NL date formats), keep using the `formatter` callback with the existing `@workspace/active-format` helpers (`formatCurrency`, `formatNumber`, `formatFractionAsPercent`, `formatPercentagePoints`). The `format` string is the fast path for static dashboards and demos; the `formatter` callback is the right tool for user-facing multilingual apps.

### 10.2 Label fallback and formatter context (ADR-078)

`LabelOptions.position` is the **preferred** place of a bar or waterfall value label; `fallback` says what happens when that place does not work.

```ts
interface LabelOptions {
  position?: 'inside' | 'outside'; // preferred place. Default 'outside'
  // a bar's label (BarLabelOptions) also takes 'inside-end' | 'inside-base' (§ 10.16)
  fallback?: 'auto' | 'hide'; // Default 'auto'
  formatter?: (value: number, context?: LabelFormatterContext) => string;
  // … enabled, format, fontSize, color, offset, background
}

interface LabelFormatterContext {
  datum: ChartDatum; // the displayed row of the labelled item
  seriesId: string; // the series' identity (ADR-049)
  seriesName: string; // its `name`, else its value key
}
```

- **`'auto'`** keeps the family's order: an outside label that would leave the plot moves inside; an inside label that does not read 4.5:1 on the mark's fill (in the one label colour, W-FV-3) moves outside; a label for which neither works is hidden. A fill the browser cannot resolve does not read (ADR-079). An inside label longer than its mark along the value direction (taller than a short waterfall step, for one) leaves the mark and does not read on it either, so it moves outside too (ADR-085); overflow across a narrow bar does not move it under `'auto'` (only `'hide'` demands an inside fit).
- **`'hide'`** uses the preferred place only; an inside label then also needs room inside the mark.
- A **stacked** segment is labelled inside or not at all (its outside is the next segment); an explicit `position: 'outside'` on a stack is reported once as a development diagnostic. No stack-total label exists.
- A **waterfall** label sits above its step (`outside`) or inside it (`inside`).
- **Line, scatter and histogram** labels have one place (above the point or bin), so `'auto'` and `'hide'` both hide a label that collides.
- **Default text.** A label without `formatter` or `format` reads the series' value semantics (its own `valueSemantics`, else its y axis'), the declaration the axis, tooltip, table and announcement read: one value, one notation, in the chart's presentation. Without a declaration it is the plain number. A percentage stack labels its segments as shares (W9). `formatter` or `format` always wins.
- The **formatter context** reaches bar, waterfall, line and scatter labels; a waterfall label's context also carries `stepKind` (`'change'`, `'subtotal'`, `'total'`, § 10.12). A histogram bin has no source row and its formatter receives the number only; a funnel stage's formatter receives its own context with its shares (§ 10.16). A one-argument formatter works unchanged.
- **Formatters must be pure.** A label formatter can be called more than once for one label: in the pass that measures it, in the pass that paints it, and again on a re-render. Return the same text for the same value and context, and have no side effects (no counters, no logging, no dependence on call order); then measurement, collision and the drawn label agree. The same holds for a tick formatter, a tooltip `valueFormatter` and a stack-total formatter.
- A hidden label's value stays in the tooltip, the data table and the announcement.

```tsx
<BarChart
  data={rows}
  xKey="fund"
  xAxis="category"
  yKey="ratio"
  label={{
    enabled: true,
    position: 'inside',
    fallback: 'hide',
    formatter: (value, context) => `${String(context?.datum.code)} ${value}`,
  }}
  height={300}
/>
```

### 10.3 Bar sizing (ADR-078)

`BarSizingOptions` on `BarSeriesOptions`, `WaterfallSeriesOptions` and `RangeBarSeriesOptions`, and as props of `BarChart`, `WaterfallChart` and `RangeBarChart`:

| Field | Meaning | Default |
| --- | --- | --- |
| `widthRatio` | The share of the category step the bar, or the group of bars of one category, may occupy: `0.1` … `1`. | Active's band: 0.8 of the step, the whole step on a category axis too dense for gaps |
| `maxWidth` | The widest one bar draws, in CSS px (at least 1; `Infinity` lets the share decide). Where there is room every bar draws exactly this wide, so it doubles as a fixed width. | Active's column cap (a 64 px slot per bar); a range bar has no cap |

- **Bars shrink, never overflow**: a bar draws `min(available, maxWidth)`, never narrower than one pixel and never wider than its share; no bar is dropped.
- The bars of one category form **one group**, sized as a whole and centred on the category; the small separation between grouped bars stays. A stack is one slot. Horizontal bars apply the same rule along the height.
- Per field, the **first bar series** that declares it decides for the group; a later different value is reported once.
- A ratio outside `0.1 … 1` is clamped; a value that cannot be used falls back to Active's sizing; both are reported once (ADR-076).
- Zoom changes the step; `maxWidth` still caps the bar. Histogram, candlestick, OHLC and box plot are not sized here (their bins abut by meaning, or they have their own `widthRatio`: a box plot's is a share of the category **band**, not the step, § 10.15). A range bar is: several range-bar series of one category form one group sized here (§ 10.15).

```tsx
<BarChart data={rows} xKey="quarter" xAxis="category" yKey="assets" maxWidth={24} height={300} />
<BarChart data={data} xKey="month" xAxis="category" yKey="flow" widthRatio={0.5} height={300} />
```

### 10.4 Pie and donut label roles (ADR-078)

Three independent roles:

| Role | Prop | Content | Place |
| --- | --- | --- | --- |
| Callout | `dataLabels` (`PieCalloutLabelOptions`) | `name` (default on), `value` (default on), `percentage` (default on), `minAngle` | outside, with a leader, in two columns |
| Sector | `sectorLabels` (`PieSectorLabelOptions`) | `percentage` (default on), `value`, `name`, `formatter`/`format`, `minAngle` | inside the slice, one line |
| Centre | `centerLabel`, `centerLabelSecondary` (donut) | host text | the hole of the donut |

- Labels stay off unless the host enables a role. A role whose parts are all off draws nothing.
- **Callouts stay inside the chart.** The pie stays centred and gives each side room for the wider of the two callout columns, so a long name on one side never runs past the edge; a name wider than its column (at most 30 % of the chart) is cut with an ellipsis and stays whole in the tooltip and the table (ADR-080).
- A **sector label** sits at the slice's centroid (the middle of the ring on a donut) in the one label colour, and shows only where its text fits wholly inside the slice, the slice is at least `minAngle` degrees, and the label reads 4.5:1 on the slice at rest and while it steps back. Otherwise it is hidden; it never moves outside.
- With sector labels each slice is drawn on its **label surface** (W-FV-3): its colour drawn towards the background as far as the label needs, keeping at least half of it; the legend swatch and the tooltip show the slice as drawn. A colour that would need more keeps its paint and its sector label is hidden. The label mode therefore changes the drawn colours: a pie with sector labels and one without show the same category in different colours. Compare colours across charts only where they share the theme and the label mode, and read a slice's colour from its legend swatch, which shows it as drawn.
- Percentages are shares of the **visible** total: a slice hidden in the legend leaves the denominator.

```tsx
// The name outside, the share inside
<PieChart data={rows} valueKey="value" categoryKey="name" dataLabels={{value: false, percentage: false}} sectorLabels height={320} />
// A donut with its total in the centre and shares on slices of 15° and more
<PieChart data={rows} valueKey="value" categoryKey="name" donut centerLabel="€ 1.2 bn" sectorLabels={{minAngle: 15}} height={320} />
```

### 10.5 Heatmap colour scale and recede (ADR-078)

```ts
// The linear form of QuantitativeColorScale; 'stops' and 'classes' in § 10.9 (ADR-092, ADR-098).
interface LinearColorScale {
  type?: 'sequential' | 'diverging'; // Default 'sequential'
  min?: number; // Default: the lowest value (and `mid`)
  max?: number; // Default: the highest value (and `mid`)
  mid?: number; // diverging only. Default 0
  colors?: [string, string] | [string, string, string]; // 3 for a diverging scale (formerly `colorRange`)
}
// On HeatmapChartProps:
interface HeatmapScaleProps {
  colorScale?: QuantitativeColorScale; // linear, or a 'stops' or 'classes' scale (§ 10.9)
  colorLegend?: boolean | ColorLegendOptions; // the key; formerly `gradientLegend`
  highlight?: {recede?: boolean}; // Default false
}
```

- **Fixed ends** make heatmaps comparable: with the same `colorScale` (ends and colours), equal values get equal colours in every chart, whatever its data, provided the charts share the theme and the label mode: values in the cells compress the scale (below), so a labelled and an unlabelled heatmap draw the same value in different colours.
- A **diverging** scale interpolates `min → mid` and `mid → max` on their own, with the `--chart-diverging-*` colours by default; an open end reaches the centre.
- A value **beyond a fixed end** takes that end's colour (W-CF-3). Its true value stays in the cell label, the tooltip, the data table, the announcement and the CSV export; the colour scale reads `≤ min` / `≥ max`, and the chart's description says that values reach beyond the scale.
- **Missing values** are never on the scale (ADR-077).
- A configuration that cannot be drawn (a non-finite end, `min` not below `max`, one end beyond every value, a centre outside fixed ends, `colors` of the wrong length) is reported once per chart through the diagnostics sink and drawn as the nearest safe scale; a scale never turns around.
- With values in the one label colour, every colour of the scale is drawn towards the background by one share until the label reads 4.5:1 everywhere, so a diverging scale keeps its order and its centre. Where a colour of the scale cannot be resolved by the browser, the cells carry no labels (their values stay in the tooltip and the table; ADR-079).
- **Any finite domain** draws: ends and centre at any magnitude (`-1e308 … 1e308`) give finite positions, exact ends and an exact centre; the raw values are never changed (ADR-079).
- **`colorLegend.position`**: `'bottom'` (default) centres a horizontal scale under the matrix. `'right'` stands it upright beside the matrix, low at the bottom, with the same end labels, `≤`/`≥` and diverging centre; the matrix gives up the width the scale and its widest label take, so the scale never covers a cell, also when narrow. Both are part of the export, in place (ADR-079).
- **`highlight.recede`** (W-CF-2) lets the other cells step back mildly, at Active's near context step, while a cell is addressed. The addressed cell, every label, the gutter cues, the scale marker and the keyboard cue stay as they are. There is no strength; never under forced colours, without motion, and never in an export. The default (`false`) is W-FV-4: no cell changes.

```tsx
<HeatmapChart data={rows} xKey="quarter" yKey="fund" colorKey="ratio" colorScale={{min: 90, max: 130}} height={240} />
<HeatmapChart data={data} xKey="month" yKey="assetClass" colorKey="value" colorScale={{type: 'diverging'}} label={{enabled: true}} highlight={{recede: true}} height={240} />
```

---

### 10.6 Value semantics on the standalone families (ADR-089)

The Cartesian charts read what a value means from `valueSemantics` on an axis or a series (§ 9.2). The twelve numeric standalone families take the same `ChartValueSemantics` once, as a prop of the chart:

```tsx
import {
  PieChart,
  TreemapChart,
  type HierarchyNode,
} from '@workspace/active-charts';

declare const costs: HierarchyNode;
const EUR = {kind: 'money', currency: 'EUR'} as const;

<PieChart
  data={rows}
  valueKey="amount"
  categoryKey="payer"
  valueSemantics={EUR}
  dataLabels
  height={280}
/>;
<TreemapChart data={costs} valueSemantics={EUR} height={280} />;
```

| Family | The value the declaration writes | Where |
| --- | --- | --- |
| `PieChart` | a slice's value | callout and sector values, tooltip, table, announcement |
| `FunnelChart` | a stage's value | stage labels, tooltip, table, announcement |
| `NightingaleChart`, `RadialBarChart` | a segment's or ring's value | value labels or column, the radial bar's scale, tooltip, table, announcement |
| `RadarChart` | a value on the variable axes | ring labels, tooltip, table, announcement |
| `HeatmapChart` | a cell's colour value | cell labels, the colour scale's end and centre labels, tooltip, table, announcement |
| `TreemapChart`, `SunburstChart` | a node's (summed) value | tooltip, table, announcement |
| `SankeyChart` | a link's value; a node's fixed or larger flow value | tooltip, table, announcement |
| `GaugeChart`, `LinearGauge`, `BulletChart` | the value, `min`, `max`, the target, the ticks | readout, scale ends and ticks, target, accessible name, table |

- **Precedence**, per role: the role's own `formatter` or `format` (a data label, a scalar's `dataLabels`, a heatmap's `label`), and `tooltip.valueFormatter` in the tooltip; else the declared semantics; else the chart presentation's number.
- **A share is not a value.** A share of a whole (a pie slice of the visible total, a funnel stage of the first, a ring of `max`, a treemap tile of the shown total, a scalar of its range) stays a percentage, in the chart's presentation. The pie tooltip states the value (in the declaration, else the presentation's number) and the share on a row of its own, with or without a declaration; the funnel tooltip does the same for a stage and its share of the first. `tooltip.valueFormatter` writes the value row, never the share row. CSV keeps the raw value and writes no share.
- **The machine boundary stays raw.** CSV and Copy value write the number in machine form (W4); copy as table likewise.
- **Value truth is unchanged.** A scalar rests on `value` itself in every animation mode (ADR-085), in the declared notation; a heatmap value beyond a fixed scale end keeps its true number; a missing cell stays "no value".
- **Text without a value** (the names of a treemap tile, a sunburst arc or a sankey node) keeps its role; value semantics add no label.
- Org chart, timeline and sparkline show no numeric value and take no `valueSemantics`.

### 10.7 Marker shapes and legend reading (ADR-090)

**Marker shapes.** One bounded vocabulary, `MarkerShape`: `'circle' | 'square' | 'diamond' | 'triangle' | 'triangle-down'`. No image, no path, no renderer callback, no shape per datum. Every shape has the area of the circle of the same size, so no series weighs more than another.

| Family | Option | Automatic shape |
| --- | --- | --- |
| `LineChart`, `AreaChart` | `series[].marker.shape` (`<Chart>`: `series[].marker.shape`) | yes |
| `ScatterChart` (also bubble) | `series[].marker.shape` | yes |
| `LollipopChart` | `markerShape`, per series `series[].markerShape` | yes |
| `DumbbellChart` | `markerShape` (both ends) | no |
| `RadarChart` | `series[].marker.shape` (the vertices) | no |
| `TimelineChart` | `marker` (every event) | no |

```tsx
<ScatterChart
  data={rows}
  xKey="risk"
  series={[
    {yKey: 'thisYear', name: '2025'},
    {yKey: 'lastYear', name: '2024', marker: {shape: 'triangle'}},
  ]}
  legend
  height={280}
/>
```

- **Precedence.** An explicit shape always wins. Without one, `<Chart>` assigns a shape only where colour alone does not keep a series apart, by the trigger of the dash pattern (ADR-038): from the seventh default palette slot, or where a host palette repeats. The first six default colours, and every colour of a host palette until it repeats, keep the circle, so a chart of a few distinctly coloured series draws exactly what it drew. The seventh to tenth default colours take square, diamond, triangle and triangle-down; each repeat of a colour takes the next shape. A line with markers that gets a dash gets a shape with it. A dumbbell's ends (two colours and two positions), a radar and a timeline take only an explicit shape.
- **Where the shape shows.** The plot; the legend key (a marker series is keyed by its shape, a line or an area that draws shaped markers shows the shape on its stroke; a round marker keeps the plain key); the tooltip key; the emphasised node and the keyboard cue, at the mark's own size (W-FV-2); every export. Under forced colours a shape is a solid mark in the system colour of its series.
- **What it is not.** A shape is visual redundancy for the colour, never data: the tooltip text, the data table and the announcement do not name it. The interaction target stays the chart's nearest-point hit area; the plot clip and the layout reserve a shape's corners, so a shape at a domain extreme is drawn whole.

**Legend alignment.** `legend.align: 'start' | 'center' | 'end'` places the entries along the legend's side. Logical: `start` is the inline start of a legend above or below the chart (the left under `ltr`, the right under `rtl`) and the top of a legend beside it. Default `'center'` above or below, `'start'` beside, as before. On `<Chart>` and every standalone family with a series or category legend; the export legend stands the same way. No offsets, no floating legend.

**Stack-order reading.** Policy, no option (W-BC-5): the legend and the shared tooltip of a stacked bar or area list the members of a stack in the order the eye meets their segments. A vertical stack is read from its top, a horizontal one from its inline start (the plot does not mirror under `rtl`, ADR-057). The legend reads each member on the side it draws on (a member whose values are all zero or negative below the baseline, any other above it); the shared tooltip reads its category exactly. Hiding a series never reorders the legend. Series outside the stack, and every chart without a stack, keep the series order. The context menu and the legend toggle keep naming the series they act on.

**Narrow side-legend fallback.** A legend at `left` or `right` stands beside the chart while the chart keeps at least 240 px beside it (twice the plot minimum of the layout); where it would not (a narrow container, long names), it stands below the chart, folded as a legend there folds, and returns to its side when the room returns. A step of the degradation order (W-BC-4), not an option and not a breakpoint: the decision reads the measured width of the chart's row and the measured width of the legend's column, so it holds still at any one width. Visibility, isolation and the fold state are kept across the move, and the chart is not re-mounted. An export carries the legend on the side it is drawn, and a handle kept from an earlier render exports what is drawn now.

### 10.8 Stack totals, stack groups, label roles and category wrap (ADR-091)

**Stack groups.** `stackGroup: string` on a bar or area series names the stack it belongs to and implies stacking. Series with the same group stack onto each other; different groups never do. Bar groups stand side by side within the category, one column per group (an unstacked bar beside declared groups is a column of its own), in the order their first member appears; hiding a whole group closes its column. Each group has its own baseline, percentage base, shared-tooltip total, legend reading order (§ 10.7) and stack total. Two area groups draw over the same x positions, each from its own baseline. Without `stackGroup` the stacked series of one axis and orientation share one unnamed stack, which takes one column by the same rule: a stack beside an unstacked bar no longer fills the band under it (CH-24, ADR-093). A chart of only stacked bars still fills the band with its one stack, and a chart of only unstacked bars keeps one column per bar.

**Stack totals.** `stackTotal` on a bar series draws one total per stack and category: the net sum of the stack's visible members, the number the shared tooltip already names as the stack total.

| Field | Type | Effect |
| --- | --- | --- |
| `enabled` | `boolean` | Present means on; `false` switches it off. |
| `formatter` | `(value, {datum, stackGroup}) => string` | The total's text. Wins over `format` and the semantics. |
| `format` | `string` | D3-format shorthand, as on every data label (§ 10.1). |
| `fontSize`, `color`, `offset`, `background` | as on `LabelOptions` | The look; `offset` defaults to 4 px. |

- The first member of the stack (in series order) that declares `stackTotal` sets it. Without `formatter` or `format` the total reads the value semantics of the stack's first member (its own, else its axis'), else the plain number.
- The total sits beyond the end of the side its sign points to: above a positive vertical stack, below a negative net, right or left of a horizontal one; a reversed axis turns it with the stack. It has one place: a total that would leave the plot or collide with another total is hidden, and its value stays in the tooltip, the data table and the announcement. The automatic value domain keeps room for it, so the tallest stack's total is drawn (an explicit `min` or `max` wins).
- No total on a percentage stack (it always makes 100 %; a development warning names a declaration there), on an area, or in a category where no counted member has a value.
- The data table gets one column per total-bearing stack, right after its last member (`Stack total`, or `<group>: Stack total`); the keyboard announcement of a segment names the total after its own value. CSV and copy as table stay the raw rows.

```tsx
<BarChart
  data={allocation}
  xKey="quarter"
  xAxis="category"
  series={[
    {yKey: 'equity', name: 'Equity', stackGroup: 'Actual'},
    {yKey: 'bonds', name: 'Bonds', stackGroup: 'Actual'},
    {yKey: 'plan', name: 'Plan', stackGroup: 'Budget'},
  ]}
  stackTotal={{}}
  legend
  height={300}
/>
```

**Label roles.** Off by default; `label: {enabled: true}` shows them. The text, fallback and look are the point-label contract (§ 10.2): `formatter`, then `format`, then the value semantics; one place per label, hidden where it would leave the plot or collide; the axis keeps room for labels at its ends. The values of every area series of one plot and the plot's stack totals take **one** collision decision (NEW-01): the totals first, then the areas in series order, so a label never prints over a label of another series; the series that gives way keeps its value in the tooltip, the data table and the announcement, and an export draws the same kept labels. A total or an area value that would print on a bar it does not belong to is hidden the same way (CH-25, ADR-093): a stack total may stand on its own stack, an area value on no bar.

| Family | Option (wrapper and `<Chart>` series) | Where the label sits |
| --- | --- | --- |
| `AreaChart` | `label` (`PointLabelOptions`) | Beyond the edge the series draws, away from its baseline; a stacked band at its top, with its own value |
| `LollipopChart` | `label` (`PointLabelOptions`) | Beyond the head's marker, away from the baseline |
| `RangeBarChart` | `label` (`EndpointLabelOptions`) | Each value beyond its own end of the bar, away from the other |
| `RangeAreaChart` | `label` (`EndpointLabelOptions`) | The high value beyond the upper edge, the low value beyond the lower one |
| `DumbbellChart` | `label` (`EndpointLabelOptions`) | Each value beyond its own marker, away from the other |

`EndpointLabelOptions` adds `endpoints: 'both' | 'low' | 'high'` (default `'both'`). An end's side is read from the drawing, so a reversed axis moves a label with its end and never swaps the roles; an empty range reads its high value above (or right). The formatter context's `role` names the end (`'low'` or `'high'`); a dumbbell names each end by its own name (`lowName`, `highName`).

**Category label wrap.** `wrap: number` on a category axis (`AxisConfig.wrap`; `<Chart>`: `label.wrap`) is the most lines a name takes. Default `1`, today's behaviour. On a horizontal category axis the order is: one line; then the names wrapped whole into at most `wrap` lines, unrotated; then rotation where the axis may rotate (`autoRotate`); then wrapped with only the last line cut, while every name keeps a recognisable stub; then the long-standing single-line truncation and thinning. A line breaks only where a line may break (a breaking space, a word boundary of a script written without spaces, never at NBSP); a word too long for its line ends the name, cut once. The full name stays the label's `<title>`. The layout reserves the drawn lines; no font shrinks. An explicit `rotation` wins over `wrap`. On a vertical category axis (horizontal bars) `wrap` caps the wrapping the gutter already does (default up to three lines). Time, ordinal-time and numeric axes do not take `wrap`; they thin.

**Export legend dash (CH-23).** The export legend's line key draws the series' dash pattern (explicit `lineDash`, or the automatic second encoding, ADR-038), at the painted key's length, with or without a marker shape: SVG, PNG and PDF alike.

### 10.9 Quantitative colour and per-datum paint (ADR-092)

**Quantitative colour (ADR-092, ADR-098).** A `QuantitativeColorScale` (a heatmap's `colorScale`, a hierarchy's `colorMeasure.scale`, § 10.13) takes one of three forms, chosen by `type`. One scale resolution feeds the cells, the scale or class legend, its marker, the description, the tooltip, the table, the announcement and the label-safe colours, so they cannot disagree.

```ts
type QuantitativeColorScale =
  | {
      type?: 'sequential' | 'diverging';
      min?: number;
      max?: number;
      mid?: number;
      colors?: [string, string] | [string, string, string];
    } // LinearColorScale, § 10.5
  | {type: 'stops'; stops: readonly {value: number; color: string}[]}
  | {
      type: 'classes';
      classes: readonly {
        from?: number;
        to?: number;
        name: string;
        color: string;
      }[];
    };
```

- **Stops** place two or more colours at values of the data, read in value order; a value between two stops mixes their colours by where it lies between them, so stops may sit irregularly (the minimum at 104.5, the required own funds at 125). Two stops paint exactly as a sequential scale, three as a diverging one. The first and last stop are the ends: a value beyond them takes the end colour, keeps its own value everywhere, and the gradient marks that end `≤` or `≥`. The gradient draws every stop.
- **Classes** are named ranges `[from, to)`: `from` belongs to the class, `to` to the next; an absent `from` or `to` is open. The scale draws a **class legend** (swatch and name per class, low to high, under the matrix in rows that wrap, or beside it with `colorLegend.position: 'right'`) in place of the gradient, and a cell's class is named in the tooltip (a `Class` row under the value), the data table and the announcement (`112, Reserve deficit`). The description lists the classes with their ranges. While a cell is current, a transient mark under its class's swatch shows where it lies.
- **Not missing:** a value that lies in no class (a gap between classes, or beyond a bounded end) is drawn in a neutral paint, named "Outside the classes" wherever a class is named, and the legend adds that entry while such a value exists. A **missing** value is never classified and stays an empty cell, "(no value)".
- **Overlaps** are allowed but reported: a value in two classes belongs to the earlier one.
- **Forced colours** (CH-27, Walt 2026-10-05): the authored class colours collapse. Every valued cell is drawn as `Canvas` with a `CanvasText` edge and the class legend's swatches collapse with them (no key to a colour no cell shows); a missing cell keeps no edge. The class stays a name: in the class legend, the tooltip, the data table and the announcement, and the transient marker under the current cell's class (in `Highlight`) shows its class without colour. No system colour per class is invented, and no class text is drawn in the cells.
- **Diagnostics** (ADR-076), each once per chart and ground: a stop that is not finite or repeats a value, stops out of value order, fewer than two valid stops (the default sequential scale over the data), a class without a name or colour or with `from` not below `to` (left out), an overlap, values in no class, and `colors` beside stops or classes (unused: their colours are their own).
- **Label contrast:** with values in the one label colour, stop colours are drawn towards the background by one share as before; class colours are surfaces of their own and are measured one by one. CSV and copy keep the raw values; a class is never exported as data.
- The same scale colours treemap and sunburst nodes by a measure (§ 10.13). **Not here:** a logarithmic colour scale (deferred), colour by measure on scatter, contour bands.

```tsx
<HeatmapChart
  data={data}
  xKey="quarter"
  yKey="fund"
  colorKey="ratio"
  colorScale={{
    type: 'classes',
    classes: [
      {to: 104.5, name: 'Below the minimum', color: 'var(--chart-negative)'},
      {
        from: 104.5,
        to: 125,
        name: 'Reserve deficit',
        color: 'var(--chart-caution)',
      },
      {from: 125, name: 'Sufficient', color: 'var(--chart-positive)'},
    ],
  }}
  label={{enabled: true}}
  height={280}
/>
```

**Per-datum paint (ADR-092, ADR-094).** A host paints single items by a rule with a bounded resolver: `(context) => paint | undefined`. The context is `{datum, index, value, seriesId, seriesName, role?}` (`seriesId` is the identity a label formatter receives; `role`, a `ChartValueRole`, names the part of a mark painted part by part, ADR-094). It returns **paint only**.

| Where | Option | Returns |
| --- | --- | --- |
| Bar series (`<Chart>`, `BarChart`) | `paint` on the series | `{fill?}` |
| Scatter series (`ScatterChart`) | `paint` on the series | `{fill?, stroke?}` |
| Line and area series | `marker.paint`, while markers are drawn | `{fill?, stroke?}` |
| `PieChart` | `paint` (context `seriesId: 'pie'`) | `{fill?}` |
| Lollipop series (`<Chart>`, `LollipopChart`, its series config) | `paint`: the head; the stick follows the head unless `stickStroke` is set | `{fill?}` |
| Range-bar series (`<Chart>`, `RangeBarChart`) | `paint`: the interval; context `value` is the high end | `{fill?}` |
| Dumbbell series (`<Chart>`, `DumbbellChart`) | `paint`, asked once per end with `role: 'low'` or `'high'` and that end's value; the connector keeps the series stroke | `{fill?}` |
| `FunnelChart`, `RadialBarChart`, `NightingaleChart` | `paint` (context `seriesId` `'funnel'`, `'radial-bar'`, `'nightingale'`) | `{fill?}` |

- **Paint only:** never geometry, shape, size, opacity, state, label colour, CSS or a class. A returned field the mark does not paint is ignored and reported; a stroke on a fill-only mark too. A result that is not a colour, or a resolver that throws, falls back to the series paint and is reported once.
- **One reading:** the chart asks the resolver once per item and data (a cache per resolver, series, row and index), and every place that shows the item's paint reads that result: the mark, the surface its inside label is measured on (ADR-079), the tooltip swatch (single, shared and split rows), and, through the drawn chart, the SVG, PNG and PDF export. Pass a stable function (a module constant or a memoised callback); a resolver that reads changing state outside its context is not re-asked until its identity changes.
- **Legend truth:** a series legend keeps the series colour; a painted bar or point is not a category of its own. A pie slice, a funnel stage, a radial-bar ring and a nightingale segment **are** their legend category, so the key shows the paint (the radial bar's name-column key too; a funnel key shows the stage as drawn, on its label surface where it carries an inside label); the categories stay the same. Lollipop, range bar and dumbbell keep their series key.
- **Forced colours** override the host paint: a painted item takes back exactly what its series draws there.
- **Accessibility:** colour is never the only meaning. The table and the announcement read values, never colour names; a rule a reader must know belongs in the caption or a class, not in paint alone.
- Hierarchy and graph families already own their node and link colour and take no resolver. Waterfall, candlestick and OHLC paint by role and take none; the heatmap's colour is its quantitative scale; the box plot is not painted per datum.

```tsx
<BarChart
  data={rows}
  xKey="fund"
  xAxis="category"
  series={[
    {
      yKey: 'q1',
      name: 'Return Q1',
      paint: ({value}) =>
        value < 0 ? {fill: 'var(--chart-negative)'} : undefined,
    },
  ]}
  height={280}
/>
```

### 10.10 Radial angles (ADR-095)

One radial clock for every family that takes an angle: **degrees, clockwise from 12 o'clock** (`0` is 12 o'clock, `90` is 3 o'clock, `-90` is 9 o'clock). A domain is a `startAngle` and an `endAngle` on that clock, absolute positions; there is no sweep prop. Geometry never mirrors under RTL: the text keeps its logical sides as before.

| Family | Props | Default | Omitted `endAngle` | Full circle |
| --- | --- | --- | --- | --- |
| `RadialBarChart` | `startAngle`, `endAngle` | `0` → `360` (a full ring) | `startAngle + 360` | yes |
| `GaugeChart` | `startAngle`, `endAngle` | `-135` → `135` (270° dial) | `startAngle + 270` | no (a dial) |
| `PieChart` | `startAngle` | `0` | (always whole) | always |
| `NightingaleChart` | `startAngle` | `0` | (always whole) | always |

`SunburstChart` and `RadarChart` take no angle props; both start at 12 o'clock and turn clockwise, on the same clock.

**Reading a pair.** Any finite number is accepted. The sweep is `endAngle − startAngle`, taken on the numbers as given, before any reduction, so `0 → 360`, `90 → 450` and `-90 → 270` are each the full circle and never an empty one.

| Pair (start → end) | Draws |
| --- | --- |
| sweep in (0°, 360°\] | that domain; exactly 360° is the full circle |
| sweep above 360° (`0 → 720`) | the full circle, and a developer diagnostic |
| sweep of 0° or less (`0 → 0`, `360 → 0`, `270 → 90`) | the family default, and a developer diagnostic |
| a value that is not a finite number | the family default, and a developer diagnostic |
| a full circle on a `GaugeChart` | the default dial, and a developer diagnostic |

The diagnostics go to the host's `<ChartDefaults diagnostics>` sink (ADR-076), once per chart and ground, outside production, naming the component and its props; nothing is announced and nothing reaches the drawing as `NaN`.

**What turns and what does not.** Every mark, track, scale tick, label, tooltip anchor, keyboard cue, hit area, the fit of a gauge and the sweep entrance read the one resolved domain. Nothing a reader navigates is reordered: the legend, the keyboard order, the data table, CSV, Copy and the announcement keep the data (or hierarchy) order; a pie's slices and a rose's wedges keep that order clockwise from `startAngle`. The radial bar's name, key and value columns are chrome beside the rings and never rotate.

**Pad angles are degrees.** `PieChart.padAngle` and `NightingaleChart.padAngle` are degrees (they were radians). The defaults are exactly the former quantities, so a default chart draws as before: about 0.573° (0.01 rad) on a pie and about 1.146° (0.02 rad) on a rose. From radians: `degrees = radians × 180 / π`.

**The sweep entrance** starts at the resolved `startAngle` and turns clockwise through the domain's own sweep over the whole duration (a half dial is half revealed at half the duration), centred on the pivot. Reduced motion draws the resting geometry at once. Labels and other chrome stand outside the swept group from the first frame. `PieChart` keeps its scale-and-fade entrance.

```tsx
// A half dial and three-quarter rings on the one clock.
<GaugeChart value={112.4} min={90} max={130} startAngle={-90} endAngle={90} height={240} />
<RadialBarChart data={rows} valueKey="value" categoryKey="name" endAngle={270} height={280} />
// A pie turned a quarter: its first slice starts at 3 o'clock.
<PieChart data={rows} valueKey="value" categoryKey="name" startAngle={90} height={280} />
```

Migrating from the earlier angle props: `migration/MIGRATION-NOTES.md` (`RadialBarChart`'s `startAngle` + 90; `maxAngle` → `endAngle`).

### 10.11 Error bars, soft bounds and zones (ADR-096)

**Error bars.** `errorBars: {lowKey, highKey}` (`ErrorBarOptions`) on `LineSeriesOptions`, `ScatterSeriesOptions` and `BarSeriesOptions`, and on the matching wrapper series configs; a scatter also takes `xLowKey` and `xHighKey` (`ScatterErrorBarOptions`, both or neither) on a numeric x axis. Two **absolute** bound fields of the host's rows; a symmetric σ is the host's arithmetic. No style option.

- **Drawn**: a stem from the mark to each bound and a cap at each bound the plot shows, 1 px, in the chart's neutral foreground ink (`CanvasText` under forced colours), never the series colour; on a bar it crosses the fill and stays inside the bar's item, on a line it lies above the stroke and below the markers, on a scatter below the points. A bound beyond the value domain (an explicit `min`/`max` cuts it) or at or below 0 on a log axis runs to the plot edge **without a cap**; the other side keeps its cap; the text keeps the true bound. A horizontal bar draws it along x.
- **Read**: the tooltip (single, shared, split), the data table and the keyboard announcement say `value (low–high)` in the series' notation (`charts.errorBar.range`); one bound alone is named (`charts.errorBar.lowerOnly`, `charts.errorBar.upperOnly`). A host `valueFormatter` writes all three numbers. A custom renderer receives `SingleTooltipData.errorBounds` / `xErrorBounds`, `entries[].errorBounds` and `TooltipRendererParams.point.errorBounds` (`TooltipErrorBounds`). A scatter with x error bars gains a table column for its x value with its x bounds (`charts.errorBar.xValue`). The whisker is no pointer or keyboard target. The legend is unchanged.
- **Machine output**: CSV and copy-as-table carry the raw bound columns under the host's key names after the value (`value, low, high`), a scatter's x bounds after the x column (`x, xLow, xHigh`).
- **Domain**: the bounds contribute (above). **Labels**: an outside bar label and a line or scatter point label sit beyond the whisker; an inside bar label keeps its place.
- **Invalid input**: one bound missing or not finite draws the other side only; a low bound above the high one draws no whisker and reads the value alone (development diagnostic, once per series and data); a row without a value draws none; a value outside its interval is drawn as given. On a stacked or percentage-stacked series no error bars are drawn and their bounds do not span the axis (development diagnostic); an `errorBars` on another family is ignored with a diagnostic.
- **Preparation**: a series with error bars keeps every row (no reduction) and blocks time grouping for the chart (development diagnostic): the bounds of an aggregate are not an aggregate of the bounds.

**Soft bounds.** `softMin` / `softMax` on a numeric axis config (`AxisConfig`, `AxisOptions`). Data 20–80 with `softMin: 0` → from 0; data −10–80 → from −10. On a log axis a soft bound at or below 0 is ignored; a non-finite soft bound, or a `softMin` above `softMax`, is ignored (both, in the latter case); a soft bound shadowed by an explicit bound on its side is ignored. Each with a development diagnostic; a soft bound never makes the chart unavailable. A soft bound on a time, ordinal-time or category axis is ignored with a diagnostic (the narrowed `CategoryAxisConfig` and `TimeAxisConfig` do not take it). No announcement.

**Zones.** `zones: {axis?, ranges}` (`SeriesZones`) on `LineSeriesOptions` (`LineZoneRange`: no `fill`) and `AreaSeriesOptions` (`ZoneRange`), and on the wrapper configs. Each range is `{from?, to?, stroke?, fill?, lineDash?, name?}`: `from` inclusive, `to` exclusive (the class rule of § 10.9), read in the given order; a value in an overlap belongs to the earlier range (development diagnostic), a value in no range keeps the series paint. A range with `from` at or above `to`, or a boundary that is not a finite number or date, is ignored with a diagnostic.

- **Axis**: `'y'` (default) by value; `'x'` by position on a `number`, `log`, `sqrt`, `time` or `ordinal-time` x axis (a date boundary is read as the data reads a date; on an ordinal-time axis a date between two rows lies between them by its share of time). x zones on a category axis are ignored with a diagnostic.
- **Geometry**: the paint changes exactly where the **drawn** path crosses a boundary: between data points, on every curve (`linear`, `monotone`, `catmullRom`, `step`, `stepBefore`, `stepAfter`), on a reversed or log axis. Missing values stay gaps; a `connectMissingData` bridge is zoned like any drawn segment. A zone gives colour and dash only: width, opacity, gradient shape, marker shape and size, curve and animation stay the series'. The parts draw and morph as one line.
- **Area**: fill and stroke follow the zone; a y zone splits the filled region by height (the fill below 100 in one paint, above it in another), an x zone by position. A stacked area takes x zones; y zones on a stack are ignored with a diagnostic.
- **Markers**: a marker takes the zone of its own value (`from` inclusive); an explicit `marker.fill` or `marker.paint` wins over the zone; the tooltip swatch follows the marker.
- **Names**: a named zone is read in the tooltip (`charts.tooltip.zone`), in one data-table column per series with named zones, and in the keyboard announcement; an unnamed zone is presentation only. No legend entry, no CSV column, no colour name.
- **Forced colours**: zone colours give way to the series' system colour; a zone's dash stays.
- **Export**: SVG, PNG and PDF draw the same bands (the PDF clips by them).

### 10.12 Waterfall kinds: subtotals and totals (ADR-097)

`kindKey` on `WaterfallSeriesOptions` and `WaterfallChart` names the field of a row that holds its kind (`WaterfallStepKind`): `'change'`, `'subtotal'` or `'total'`. A row without the field, or with `null` or `''`, is a change. There is no other way to declare a summary: `totalKey` and `totalIndices` are gone (no alias, see the migration notes), and a row's position never decides its kind: a total is not "the last row".

The chart keeps the **balance** and an **anchor** (the balance at the latest subtotal or total, zero before any) in one pass over the host rows, in host order:

| Kind | Bar (`y0` → `y1`) | Drawn amount | Balance after | Anchor after |
| --- | --- | --- | --- | --- |
| `change` | balance → balance + v | v | balance + v | unchanged |
| `subtotal` | anchor → balance | balance − anchor (the period's net) | unchanged | balance |
| `total` | 0 → balance | balance | unchanged | balance |

A subtotal **floats**: it states the net movement since the previous subtotal or total (since the start for the first one). A total stands on zero and states the balance. Both may stand anywhere, repeat and be followed by changes. The worked example: Opening +100, Revenue +50, H1 subtotal (0 → 150, 150), Costs −30, Q3 subtotal (150 → 120, −30), Adjust +10, Close total (0 → 130, 130).

- **Data**: a subtotal's and a total's amount is **derived**; the row's own value field is not read. Leave it empty. A finite value that differs from the derived amount (relative tolerance 1e-9) is ignored with a development diagnostic; an equal value is silent. A change whose value is missing draws no bar and moves nothing; one that is `NaN` or infinite does the same with a diagnostic. An unknown kind (`'Total'`, `'subtoal'`, a number) is drawn as a change with a diagnostic. Diagnostics go to the `ChartDefaults` sink once per rows, series and cause, and name fields, row positions and counts, never a value.
- **Geometry**: every bar's value end is `y1`, the balance after the row: it is rounded there, the crosshair guide stands there and its readout names it. A negative-period subtotal hangs down from its anchor; a negative total hangs down from zero; a zero-length summary draws the minimum extent at the balance. A reversed axis mirrors all of it.
- **Bridges**: unchanged rule: each bridge leaves the previous drawn bar at its balance and meets the next bar, a change at its start, a subtotal or total at its value end. A subtotal's anchor edge gets no bridge; a change without a value is crossed at the balance.
- **Paint**: a change by its sign (`positive`, `negative`); a subtotal and a total share the summary paint `total`. The path carries `data-type="subtotal"` or `"total"`; under forced colours both are `GrayText`. No subtotal paint prop and no per-datum paint.
- **Labels**: the drawn amount (the change, the period's net, the balance), never a summary's own field and never the balance on a subtotal. The formatter context carries `stepKind`. Placement, collision and the inside-contrast rule are unchanged.
- **Tooltip**: a change states Start, Change and Result; a subtotal its signed amount (`charts.tooltip.subtotal`) and Result; a total the balance (`charts.tooltip.total`). `SingleTooltipData.waterfall` is `{value, cumulative, kind}` (it replaces `isTotal`), and `yValue` is the drawn amount.
- **Table and announcement**: three columns per waterfall series: the drawn amount, its kind in words (`‹series›: Kind`, `charts.accessibility.stepKind`) and the balance (`‹series›: Cumulative`); a change without a value leaves its amount empty. The keyboard announcement names the kind with its amount and the balance (a total: the balance only).
- **Machine output**: CSV and copy-as-table write the raw host rows with x, the value field and the `kindKey` column, and no derived amount: a summary row exports its own (normally empty) value and its kind. **Copy value** on one bar copies the amount the bar draws.
- **Zoom**: positions are computed over every source row and then windowed, so a window keeps the balance and the anchor from before it, and a summary keeps its word at the window's edge.
- **Preparation**: a chart with a waterfall is never reduced or time-grouped; host row order is calculation order.

### 10.13 Quantitative scales: colour by measure, bubble size, heatmap axes (ADR-098)

**One scale, three families.** `QuantitativeColorScale` (§ 10.5, § 10.9) is the one public colour scale: the heatmap's `colorScale`, and a treemap's or sunburst's `colorMeasure.scale`. Its domain is always the chart's whole data (every cell; every node of the tree), never a zoomed or drilled part; a value beyond a fixed end takes the end colour and keeps its true number everywhere; a missing value is never on the scale. No logarithmic form (deferred).

**Colour by measure (treemap, sunburst).** Opt-in; without it the chart keeps its branch colours.

```ts
interface HierarchyNode {
  // …label, value, children, color, id, meta
  colorValue?: number | null; // read only under colorMeasure; never the size, never summed
}
interface ColorMeasureOptions {
  label: string; // required: key caption, tooltip row, table column, description
  scale?: QuantitativeColorScale; // default: sequential over the whole tree
  valueSemantics?: ChartValueSemantics; // the colour values' notation; the size keeps the chart's
  legend?: boolean | ColorLegendOptions; // the colour key; default shown, under the chart
}
// TreemapChartProps / SunburstChartProps
colorMeasure?: ColorMeasureOptions;
```

- **Size and colour are two measures.** `value` sizes the node as before; `colorValue` colours its data mark. A treemap's data marks are its tiles; its group headers keep their branch colours (identity). Every sunburst arc is a data mark and takes its **own** value.
- **Never an aggregate.** A data mark without its own `colorValue` (or with one that is not a finite number) is drawn in a neutral "no value" paint (`--border`), distinct from the outside-the-classes paint (`--muted`); its tooltip and table say "(no value)" and the key lists "No value" while one is drawn. A parent is never coloured from its children.
- **The whole tree.** Drilling never rescales: a node keeps its colour and the key its ends.
- **Labels** keep the one label colour: the measure's colours are drawn towards the background by one share (classes each measured) as the heatmap's are, so every name reads 4.5:1 on its actual fill; under a measure no branch or depth tint is applied.
- **`node.color`** does not paint a data mark under a measure (reported once); it still colours a treemap header.
- **Readers:** the tooltip names the measure and its value (`Funding ratio: 131`, with the class of a classed scale), never a colour; the data table has a column headed by `label`; the keyboard announcement and the description state the measure and the scale. CSV adds the raw `colorValue` column; no colour or class is exported as data.
- **Forced colours:** the measure fills collapse as the heatmap's cells do (`Canvas` with a `CanvasText` edge); the key keeps its names and ends, without colour.

**Bubble size (scatter).**

```ts
// ScatterChartSeriesConfig / ScatterSeriesOptions (and ScatterChart in single-series mode)
sizeDomain?: readonly [number, number]; // the values drawn at sizeRange[0] and sizeRange[1]
sizeName?: string; // the size measure's name; default sizeKey
// ScatterChartProps / ChartOptions
sizeLegend?: boolean; // the size key; default false
```

- **The size domain is the full data.** Without `sizeDomain`, the values span the extent of the size field over the chart's full rows (never the zoomed, reduced or grouped window), so a bubble keeps its radius while the reader zooms, pans or regroups. A value maps on a square root from the domain onto `sizeRange` (default 4 to 30 px); every value equal draws the middle radius, as before.
- **`sizeDomain`** fixes the domain for comparable charts. A value beyond it takes the end radius and keeps its true number in the tooltip, table and CSV; the size key marks that end `≤` or `≥`. A domain that is not two finite values, low below high, is reported and the full-data domain is used. Zero and negative values are values: the square root is sign-symmetric, so a smaller value draws a smaller bubble.
- **A missing size** (not a finite number: `null`, `NaN`, an infinity) has no size: the point is drawn at its ordinary marker size (`marker.size`, default 5), never at a middle radius, and takes no part in the domain or the key; the tooltip and table say "(no value)".
- **The size key** (`sizeLegend: true`) is a scale key, not a legend entry: three nested bubbles (the high end, a round middle, the low end) drawn through the series' own radius function, with their values and `sizeName` as caption; no toggle, no focus stop, one image named in words. It stands under the chart, after a series legend below it, aligned as the legend is; the export appends it the same way. It is shown only where **one** size scale explains every shown bubble series (the same `sizeKey`, `sizeRange` and domain rule); series that size by different scales each keep their own radii, the key is left out and a development diagnostic calls it ambiguous (never an invalid chart).
- **Readers:** the data table has a size column per bubble series (`Funds: Assets`), in the chart's number; the tooltip's size row is labelled `sizeName`. Under forced colours the key's bubbles and texts take `CanvasText`.

**Heatmap axes.**

```ts
interface HeatmapAxisConfig {
  title?: string;
  formatter?: (category: string, raw: unknown) => string; // gutter, tooltip, table, announcement
  temporalSemantic?: 'calendarDate' | 'instant'; // date categories in the chart notation
  order?: readonly ChartItemKey[]; // the data's values, by item key; listed first, the rest in first-appearance order
  reversed?: boolean;
  wrap?: number; // default 1 (columns), 3 (rows, as the row allows)
  autoRotate?: boolean; // columns only; default true
}
// HeatmapChartProps
xAxis?: HeatmapAxisConfig;
yAxis?: Omit<HeatmapAxisConfig, 'autoRotate'>;
```

- **A coordinate is its item key (ADR-113),** as a Cartesian category band is (ADR-106): a text by its text, a finite number by its number, a valid `Date` by its time (so a timestamp and the `Date` of that time are one coordinate), a boolean by its text; any other value (`NaN`, an infinity, an invalid `Date`, an object) names no coordinate (ADR-118). `1` and `"1"` are two columns that both read "1": the label is display only, and `formatter` (which receives the category's text and its raw value) is how a host tells them apart. Every surface keys a cell by that identity: the layout, the keyboard, selection (`{kind: 'cell', x, y}` keeps the typed values), the data table, the hit test and Copy value. `order` names categories by the data's values and matches by item key.
- **One row per cell (ADR-113).** Two rows on one coordinate (an exact repeat, or a timestamp and its `Date`) are contract-invalid: the chart shows its unavailable state with a development diagnostic (counts, never a value), whether or not selection is on; aggregate the rows first. A row whose x or y is missing or outside the item-key domain (`null`, `NaN`, an infinity, an invalid `Date`, an object; ADR-118) names no cell: it is not drawn, listed, navigable or selectable, the other rows draw, and a development diagnostic counts it. The CSV stays the raw rows, values in their own types.
- **An absent cell is no cell (ADR-117).** Where the data has no row for a row and column, nothing is drawn, nothing is an item and nothing is 0. The keyboard still walks every position of the matrix and reads an absent one "(no value)"; one invisible keyboard node, the focused position's, carries the id `aria-activedescendant` names. No structure of the chart grows with rows × columns: a sparse matrix costs what its rows and categories cost.
- **The data table takes the smaller truthful form (ADR-117).** The matrix (a header row of columns, a header column of rows, "(no value)" where a row and column hold no data) while its cell count `(rows + 1) · (columns + 1)` is no larger than that of the list of present cells, `3 · (present + 1)`; otherwise the list: one row per cell with data, in the displayed order (by row, then column), under the headers `charts.dataTable.row`, `charts.dataTable.column` and `charts.dataTable.value`, preceded by a summary that is also the table's description (`charts.dataTable.cellsListed`: "12 of 1,000,000 cells hold data; cells without data are not listed."). The rule is structural and decided before anything is built; there is no option. A selected cell reads "…, selected" in either form. The CSV stays the raw rows.
- **One displayed order.** First appearance in the data by default; `order` and `reversed` change it; nothing is sorted. The cells, the labels, the keyboard, the tooltip, the data table and the export all follow that one order. An `order` entry without data is left out and an unlisted category follows; both are reported.
- **Measured gutters** (a deliberate default change; see the migration notes): the row gutter is as wide as the widest row label up to 30 % of the chart (no fixed 80 px), the column band as tall as its labels. A column label wraps (to `wrap` lines), then turns to -45° or -90°, then is cut once; a row label wraps up to three lines as its row allows, then is cut. A cut label keeps its full name as its `<title>`; labels never shrink and are never skipped, also at 320 px, under RTL and with long Arabic or CJK names. A turned label is cut where it would leave the chart.
- **Titles** use the Cartesian axis-title style, are part of the export and do not add table rows. Not offered: numeric axis options (`min`, `max`, `nice`, ticks, grid), scrolling or virtualisation.

**Exports.** SVG, PNG and PDF carry the quantitative fills, the colour key and the size key exactly as drawn (screen parity, label-safe colours included) and the heatmap's axes and titles; CSV carries raw values only (`colorValue`, the `sizeKey` column), never a colour, class or radius. A PDF of a clamped key (`≤`/`≥`) needs export fonts that cover those signs, as every PDF text does (ADR-058).

### 10.14 Hierarchy and flow: label policy, sankey columns and colours, org collapse and card fields (ADR-099)

**Treemap and sunburst: one label policy.** Visual only: the tooltip, the data table, the accessible names and the announcements keep every node's name and value whatever the policy draws.

```ts
interface HierarchyLabelOptions {
  secondary?: 'share' | 'value' | 'none'; // treemap default 'share', sunburst 'none'
  levels?: readonly number[]; // default every level; 1 = the first level under the SHOWN root
}
// TreemapChartProps / SunburstChartProps
label?: HierarchyLabelOptions;
```

- **`secondary`** is a data mark's second line: `'share'` of the shown total (the treemap's default), `'value'` the node's summed value in the chart's `valueSemantics` (the tooltip's and the table's text), `'none'`. Shown whole or not at all. Treemap: leaf tiles only; a group header stays one name. Sunburst: only where the ring holds the name's lines and one more, every line as wide as its narrowest tangential slot; on thin outer rings it simply does not show. Under `colorMeasure` the second line stays a size (share or value); the measure is in the tooltip, the table and the key.
- **`levels`** names the levels whose marks draw text: group headers (by their depth) and tiles on the treemap, rings on the sunburst. Counted from the root the chart shows, as the keyboard announces "level n of m": a drilldown re-bases it. Entries that are not whole numbers of at least 1 are ignored and reported; an empty list draws no label. No per-level font, colour or style.
- **Headers where they are drawn (N3, CH-34).** Every internal node below the shown root draws its header strip and name. The shown root draws no header and reserves no band: the first strip or tile starts at the top of the plot (under the breadcrumb when drilled).
- **The drill path follows the data (N2).** A drilled treemap or sunburst holds its path by node identity (`id`, else position) and resolves it against the tree the host passes now; a node that is gone or became a leaf lands the view on its deepest surviving level.

```tsx
import type {HierarchyNode} from '@workspace/active-charts';

declare const mandates: HierarchyNode;

<TreemapChart
  data={mandates}
  valueSemantics={{kind: 'money', currency: 'EUR', decimals: 0}}
  label={{secondary: 'value', levels: [1, 2]}}
  drilldown
  height={360}
/>;
```

**Sankey: columns, flow colour and order.**

```ts
// SankeyChartProps
nodeAlignment?: 'justify' | 'source' | 'sink' | 'center'; // default 'justify'
linkColor?: 'source' | 'target' | 'gradient'; // default 'source'
nodeOrder?: 'auto' | 'input'; // default 'auto'
```

- **`nodeAlignment`** chooses a node's column. `'justify'` (the default): every node at its distance from the sources, every sink in the last column. `'source'`: every node at its distance from the sources, so a sink can stand in a middle column. `'sink'`: every node at its distance to the sinks, so a source can stand in a middle column. `'center'`: as `'source'`, with a source drawn just before its first target. Flow terms, never left and right: the sankey does not mirror in `rtl`. A node with no link stands in the first column (`'source'`, `'center'`) or the last (`'justify'`, `'sink'`). The labels, the hover and the menu follow the drawn node.
- **`linkColor`**: a flow takes its source's colour, its target's, or a gradient from the one to the other. A link's own `color` wins in every mode. A gradient flow is drawn as a filled band, so SVG, PNG and PDF all carry the gradient. Colour carries a node's identity along its flows only: there is no legend, and the tooltip, the table and the keyboard name both ends.
- **`nodeOrder`**: `'auto'` keeps the layout's own order, which reduces crossing flows; `'input'` keeps the order of `nodes` in every column, so comparable charts draw every node in the same place.
- **Readers.** The data table keeps the order of `links` in every mode. The keyboard steps the nodes column by column as drawn, top to bottom; ArrowUp and ArrowDown stay in the drawn column (N1: a sink drawn in the last column is stepped there, not with the column of its distance from the sources).

**Org chart: collapse and card fields.**

```ts
// OrgChartProps<TExtra>
collapsible?: boolean; // default false
collapsedIds?: readonly string[]; // controlled
defaultCollapsedIds?: readonly string[]; // uncontrolled
onCollapsedChange?: (collapsedIds: string[], change: OrgCollapseChange) => void;
cardFields?: readonly OrgCardField<TExtra>[]; // at most 3; default [{key: 'title'}]

type OrgCollapseChange =
  | {source: ChartInteractionSource; id: string; collapsed: boolean; scope: 'node' | 'subtree'; requested: boolean}
  | {source: 'update'; removed: string[]; requested: false};
interface OrgCardField<TExtra> {
  key: Extract<keyof TExtra, string>; // a string or finite number in meta
  label?: string; // table column, tooltip row; default the table's "Detail"
  showLabel?: boolean; // the card reads "label: value"
}
```

- **Request and input (ADR-105).** `source` is the reader's input: `'pointer'` (a mouse or pen on the badge), `'touch'` (a finger), `'keyboard'` (Enter or Space); a menu action (Collapse, Expand, Expand all below) reports the input that opened the menu, and `scope` says node or subtree. With `collapsedIds` the change is a request (`requested: true`) computed from the host's ids and drawn only when the host passes it back; uncontrolled, the chart applies it (`requested: false`).
- **Identity.** A collapsed card is named by its host `id`. A card with reports and no `id` cannot collapse (reported once). With `collapsible`, two nodes with one `id` make the data invalid: the chart shows its unavailable state (W12).
- **State.** Controlled, the chart only reports a request and draws `collapsedIds`; ids that name no collapsible card are ignored, never reported. Uncontrolled, `defaultCollapsedIds` seeds the state, a request updates it and is reported, and ids the data no longer holds are dropped with `source: 'update'`. Collapsing a card keeps the state of the cards below it; expanding it shows them as they were. The host's data is never changed.
- **Layout.** A collapsed card's reports take no part in the layout, the reporting lines or the fit; a toggled card keeps its place on the screen.
- **One grammar.** A collapsible card carries a badge on the edge that faces its reports: `+3` (its hidden cards) while collapsed, a short line while expanded. The badge is the one pointer target that toggles; a click on the card never does. Enter and Space on the focused card toggle it; Shift+F10 and the ContextMenu key open its menu, which offers Collapse or Expand and, where something below is collapsed, Expand all below (after the host's items). When the host collapses an ancestor of the focused card, the focus moves to that ancestor and says so.
- **Card fields.** Under the name, in order, one line each; a card too small for every line leaves the last fields out (the name keeps at least one line); the font never shrinks. The tooltip lists every field, the data table has a column per field headed by its `label`, the CSV a raw column per `key`.
- **Readers and export.** The keyboard walks the visible cards; a collapsible card is a button with `aria-expanded`, and its name says "collapsed, 3 hidden" or "expanded". The data table and the CSV list the whole organisation. An image or PDF draws the visible tree and says in its caption what is collapsed ("Collapsed branches: 3 cards not shown").

```tsx
import type {HierarchyNode} from '@workspace/active-charts';

type Person = {title?: string; unit?: string};
declare const organisation: HierarchyNode<Person>;

<OrgChart<Person>
  data={organisation}
  collapsible
  defaultCollapsedIds={['operations']}
  cardFields={[
    {key: 'title', label: 'Role'},
    {key: 'unit', label: 'Unit', showLabel: true},
  ]}
  nodeHeight={72}
  height={420}
/>;
```

### 10.15 Compound grouping and orientation (ADR-101)

Range bar, range area, box plot and dumbbell take several series, and the box plot lies down. No new layout option: the layout follows from the family.

| Family | Several series | Orientation | Wrapper |
| --- | --- | --- | --- |
| Range bar | Side by side in their category, in series order; the group takes the share `widthRatio`/`maxWidth` set | `horizontal` | `RangeBarChart` `series: RangeBarChartSeriesConfig[]` |
| Box plot | Side by side, in series order; each outlier on its own box's centre line | `horizontal` (new) | `BoxPlotChart` `series: BoxPlotChartSeriesConfig[]` |
| Dumbbell | Side by side, one marker diameter plus a gap apart, never wider than the category | `horizontal` | `DumbbellChart` `series: DumbbellChartSeriesConfig[]` |
| Range area | Overlaid in series order, the later on top, each at its own `fillOpacity` | vertical only | `RangeAreaChart` `series: RangeAreaChartSeriesConfig[]` |

- **Automatic and per family.** Two or more visible series of one family, orientation and axis group; there is no `grouped` option, no mode and no slot width. Families never group with each other: a range bar drawn with ordinary bars overlays their group (a planning band behind the actual bars), a dumbbell never joins the lollipops. One series of a family draws as a lone series, centred in its category.
- **Hidden series.** A hidden series gives its slot back; the rest close the gap and re-centre. It leaves the value domain, the keyboard and the pointer targets; a series leaving through its exit transition fades in the slot it had.
- **Sizing.** A range-bar group takes the host's `widthRatio` share of the step, capped at `maxWidth` per bar (§ 10.3; the first series that declares a field decides). A box takes `widthRatio` of its **band** (not the step; default 0.5); a group of boxes takes `widthRatio`, default 0.8, of the band and divides it.
- **Dense groups.** No series limit and no "no room" state: glyphs narrow down to one pixel and never leave their category; endpoint labels give way first; every value stays in the tooltip, the data table and the keyboard.
- **Pointer and keyboard.** A grouped glyph's pointer target is its own slot, so the pointer over one series never finds another. The keyboard steps category by category, then series by series in series order, then (dumbbell) low before high; orientation changes nothing in that order or in any word.
- **Horizontal box plot.** `horizontal: true` on the series or the wrapper: categories on y, values on x, the same five statistic keys and outliers. Min stays the low value and max the high value whichever way the axis runs; tooltip, table and announcement read min, Q1, median, Q3, max in that order in both orientations. Like a horizontal range bar, a horizontal `BoxPlotChart` takes no `navigator` and no `annotations`; data grouping already refuses horizontal marks. The entrance default is `'grow-right'`.
- **Reversed axes.** Every part of a box is placed from its own projected value, so a box spans Q1 to Q3 on a reversed axis too; endpoint labels keep their roles (§ 10.8).
- **Several dumbbells.** Each series keeps its two end colours (palette slots `2 × index` and `2 × index + 1`, or `startFill`/`endFill`). Its legend entry is its `name` (else `{lowName}–{highName}`) with a paired key of exactly those two colours, on screen and in an exported image; a click hides the whole series. The tooltip, the announcement and the data table name the series first: "Plan: 2024". A chart with **one** dumbbell series keeps its two end entries and its short end names.
- **Endpoint labels.** The endpoint labels of range bars, range areas and dumbbells join the plot's one collision decision with the area labels (NEW-01): in declaration order, a later label gives way to an earlier one of any series. Each label sits at its own glyph.
- **Export.** SVG, PNG and PDF draw the grouped and horizontal geometry as the screen does; the CSV writes the host's rows and fields, never a slot or offset.

The wrappers take their series from the single-series keys **or** from `series`, never both; the props type refuses the mix, and a caller outside the type system is told once in development (`series` wins). Chart-level fields (range bar: `cornerRadius`, `widthRatio`, `maxWidth`, `horizontal`, `label`; range area: `strokeWidth`, `curve`, `label`; box plot: `strokeWidth`, `widthRatio`, `medianStrokeWidth`, `outlierRadius`, `horizontal`; dumbbell: `connectorStroke`, `connectorWidth`, `markerSize`, `markerShape`, `horizontal`, `label`) hold for every series.

```tsx
declare const returns: Record<string, string | number>[];
declare const funding: Record<string, string | number>[];

<RangeBarChart
  data={returns}
  xKey="assetClass"
  series={[
    {yLowKey: 'low10', yHighKey: 'high10', name: 'Ten years'},
    {yLowKey: 'low5', yHighKey: 'high5', name: 'Five years'},
  ]}
  legend
  height={280}
/>;

<DumbbellChart
  data={funding}
  xKey="fund"
  series={[
    {
      yLowKey: 'plan24',
      yHighKey: 'plan25',
      name: 'Plan',
      lowName: '2024',
      highName: '2025',
    },
    {
      yLowKey: 'act24',
      yHighKey: 'act25',
      name: 'Reached',
      lowName: '2024',
      highName: '2025',
    },
  ]}
  horizontal
  legend
  height={320}
/>;
```

### 10.16 Family singles: histogram bins, funnel context, timeline time, candle border, bar placements, sparkline domain (ADR-103)

Eight bounded options, each on a concept the package already had; each is opt-in. A bar `Sparkline` without `domain` reads from zero (a behaviour change; see the migration notes).

**Histogram: one bin rule.** `binCount` (a target, default 10, round boundaries), `binWidth` or `bins` (`HistogramBinRuleOptions`); the type takes one, on `Histogram` and on the `<Chart>` histogram series. An untyped call with several uses `bins`, then `binWidth`, then `binCount`, and says so once in development.

- `binWidth` is in x units: every bin that wide, on multiples of it. The first bin starts on the multiple at or below the smallest value, the last ends on the multiple at or above the largest; equal values make one bin. A width that is not a finite number above 0, or that would make more than 1 000 bins, is reported and the default count applies.
- Every bin includes its lower bound; only the last also includes its upper bound (the tooltip's "30 to under 40" and "60 to 70 inclusive").

**Histogram: area-proportional bins.** `areaProportional: true` (`HistogramAmountOptions`, count or sum only) draws each bin's amount per the narrowest bin's width: height = amount × narrowest width / bin width, so a bar's AREA is its amount where bins differ in width. A bin as wide as the narrowest keeps its amount; equal bins draw exactly what they draw without the option. The label, the tooltip value, the announcement and the table keep the amount; the tooltip adds "Per {width}" with the drawn value (`charts.tooltip.binRate`). The y axis reads the drawn value, so the host titles it ("Participants per 10 years"). A mean is not proportional to width: the type refuses `areaProportional` with `aggregation: 'mean'`. Unequal `bins` without the option draw by height, as before, with a development diagnostic.

```tsx
<Histogram
  data={data}
  xKey="age"
  bins={[
    [20, 30],
    [30, 40],
    [40, 60],
    [60, 70],
  ]}
  areaProportional
  label={{enabled: true}}
  yAxis={{title: 'Participants per 10 years'}}
  height={300}
/>
```

**Funnel: formatter context.** `FunnelChartProps.dataLabels` is `FunnelDataLabelOptions`: its `formatter(value, context?)` receives a `FunnelLabelFormatterContext`, the `LabelFormatterContext` of the stage (`datum`, `seriesId` `'funnel'`, `seriesName` the chart's `name`) with `index` (the row in `data` before the legend filter), `shareOfFirst` and `shareOfPrevious`: raw fractions over the VISIBLE stages, absent where a number or its denominator is not finite or the denominator is not above zero; `shareOfPrevious` is absent on the first visible stage. Its text is the stage's value text in the label, the data table and the announcement; the tooltip keeps `tooltip.valueFormatter`, CSV the raw number. A one-argument formatter works unchanged. The other standalone families keep `formatter(value)`.

```tsx
<FunnelChart
  data={data}
  valueKey="count"
  categoryKey="stage"
  dataLabels={{
    percentage: false,
    // A host formats with active-format; plain numbers keep the example short.
    formatter: (value, context) =>
      context?.shareOfPrevious === undefined
        ? String(value)
        : `${value} · ${Math.round(context.shareOfPrevious * 100)} % of previous`,
  }}
  height={320}
/>
```

**Timeline: time-proportional placement.** Ordinal is the default and unchanged: events in the host's order, evenly spaced, `date` display text only. `timeAxis: {temporalSemantic: 'calendarDate' | 'instant'}` (`TimelineTimeAxis`) places each event at its `TimelineEvent.time` (`TimelineTime`: a `Date`, an epoch timestamp in milliseconds or an `IsoDateString`, read as every temporal axis reads one, § 9.1) on the extent of the event times; one distinct time stands in the middle. With `timeAxis` the type requires `time` on every event.

- `date` stays the text shown, tooltipped and announced ("Q1 2026" for a precise time is fine); it is never parsed.
- A marker stands at its time. Label collision moves labels only (tiers, sides, inward anchoring, cutting), never a marker; equal times share one x and are never moved apart.
- The keyboard, the data table and the position in the announcement read ascending time; equal times keep the host's order. Lanes keep the host's order of first appearance; colours and the context menu's `TimelineElement.index` keep the host's row.
- The CSV keeps the host's row order and adds a `time` column in machine form: a calendar day as `YYYY-MM-DD`, an instant as an ISO instant.
- One event without a readable `time` (an untyped call) puts the whole chart back on ordinal placement, reported once. No chart mixes the two.

**Candlestick: body border.** `bodyStrokeWidth` (default 1) on `CandlestickChart` and the candlestick series: one width for rising and falling bodies, in `up.stroke` / `down.stroke`; the wick keeps `wickStrokeWidth`. `0` draws no border. Under forced colours a borderless body keeps a 1 px system border, so a rising (hollow) candle never disappears. A negative or non-finite width is reported and the default applies.

**Bar: inside-end and inside-base.** `BarLabelOptions.position` (`BarLabelPosition`) adds `'inside-end'` (inside, against the bar's value end) and `'inside-base'` (inside, against its base) to `'inside'` (centred) and `'outside'`. The ends are semantic: a positive vertical bar's value end is its top, a negative one's its bottom, a horizontal bar's the end its value points to, and a reversed value axis keeps the roles. A stack segment uses its own ends (a percentage stack's too); the stack's end belongs to `stackTotal`. A label at an end needs its length plus `offset` inside the bar along the value direction and must read on the bar; otherwise `fallback: 'auto'` moves it outside (a stack segment's label is hidden) and `'hide'` hides it. No label shrinks. `WaterfallChart` keeps `'inside' | 'outside'` (a floating subtotal's base is its anchor, not zero).

**Bar: per-series wrapper labels.** `BarChartSeriesConfig.label`: present, it replaces `BarChartProps.label` for that series as a whole object (no merge); absent, the series takes the chart's `label`. The rule of `AreaChart` and `LineChart`, and the `<Chart>` bar series `label`.

```tsx
<BarChart
  data={rows}
  xKey="quarter"
  xAxis="category"
  series={[
    {yKey: 'contributions'},
    {yKey: 'investmentReturn', label: {enabled: true, position: 'inside-end'}},
  ]}
  label={{enabled: true}}
  height={300}
/>
```

**Sparkline: fixed domain.** `domain: readonly [number, number]` is exact: `[low, high]` maps to the bottom and top of the sparkline, no padding, no zero, so sparklines that share one domain (a grid column; the host computes it once) draw equal values at equal heights. Two finite numbers, low below high, or it is reported and the automatic domain applies. It applies to line, area and bar. A value beyond it is drawn to the edge and keeps its own number in the CSV; a min, max or last dot outside it is not drawn; lines and areas are clipped to the box. The automatic domain of a line or area is the data's extent with a tenth of its range on each side; a bar sparkline's always reaches zero (padded only away from it), because a bar reads from zero. With a host domain a bar still reads from zero: a zero outside the domain is cut at the nearest edge, never moved into it, and the domain is never widened.

## 11. Data Preparation: Reduction and Grouping

A Cartesian chart prepares its rows before it draws them (ADR-034). Preparation may make a chart faster; it never changes what the data means.

### 11.1 Reduction

Lines and areas with more rows than `maxPoints` are reduced. Reduction only **selects** source rows; it never averages or invents a value. The x range is cut into one bucket per pixel column of the plot. In each bucket, for every line, area and stacked-area top edge, the first, last, lowest and highest row are kept. The reduced line therefore covers the same pixels as the full line, for every series at once.

- **Actual x.** Buckets follow the x value on the axis' own scale (number, time, log, sqrt, or the rank on an ordinal-time axis), never the row position. Rows that are not in x order are not reduced.
- **Gaps.** For a series without `connectMissingData`, every gap keeps its first missing row and the defined rows on either side, so the line breaks where the full line does.
- **Other series keep every row.** A row in which a bar, scatter, candlestick, OHLC, range, box-plot, lollipop or dumbbell series has a value is always kept, and so is every row of a line with error bars (ADR-096). A chart with a waterfall is never reduced, so running totals are computed on every step.
- **Eligible axes (W3).** Reduction runs only on a `number`, `time`, `log`, `sqrt` or `ordinal-time` x axis, never on a category axis and never next to horizontal marks.
- **Threshold.** `maxPoints` (default 500; `0` draws every row) is reachable as `ChartOptions.maxPoints` and as the `maxPoints` prop of `LineChart` and `AreaChart`. The algorithm limits itself: it leaves rows out only where more than four rows of one series fall into one pixel column. Other wrappers do not expose it, because their families are never reduced.

### 11.2 Data grouping

Time-series charts with high-cardinality x-data (10+ years of daily prices) opt into calendar-aligned aggregation via `ChartOptions.dataGrouping` (and the `dataGrouping` prop of `LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `CandlestickChart`, `OhlcChart` and `RangeAreaChart`). When the visible range is denser than `groupPixelWidth` allows, rows are aggregated into UTC-aligned buckets (week, month, quarter, year, …) and the chart draws one point or glyph per bucket.

```ts
interface DataGroupingOptions {
  enabled?: boolean; // default: true when the field is set
  units?: GroupingUnitTable; // default: full table (ms → year)
  approximation?: AggregationMethod; // single-valued series; default per type
  groupPixelWidth?: number; // default: 6 — minimum px per bucket
  anchor?: 'start' | 'middle' | 'end'; // default: 'start' — bucket x position
  forced?: boolean; // default: false — group even when the rows fit
}

type AggregationMethod =
  | 'average'
  | 'sum'
  | 'open' // first value
  | 'high' // highest value
  | 'low' // lowest value
  | 'close' // last value
  | 'ohlc' // role bundle of price series
  | 'range'; // role bundle of range series
```

**Every role is aggregated under its own field name.**

| Series | Aggregation |
| --- | --- |
| line, area, scatter | the value with the series' or chart's method, default `average`; a scatter's `sizeKey` follows the same method |
| bar | the value, default `sum` (a series with `errorBars` is never grouped, ADR-096) |
| candlestick, OHLC | first `openKey`, highest `highKey`, lowest `lowKey`, last `closeKey` |
| range-area, range-bar | lowest `yLowKey`, highest `yHighKey` |

`approximation: 'ohlc'` or `'range'` on a single-valued series has no meaning; that series keeps its type's default and a development diagnostic says so. A field without a value in a bucket stays absent, so a line breaks there as it does in the source.

**One chart-wide policy.** The rows are shared by every series, so a chart groups every series or none. It stays ungrouped, with a development diagnostic naming the series, when a series opts out (`series.dataGrouping: false` or `{enabled: false}`) or cannot be aggregated truthfully: `waterfall`, `histogram`, `box-plot`, `lollipop`, `dumbbell`, horizontal marks and any series with `errorBars` (ADR-096). `series.dataGrouping: {approximation}` sets one series' method.

**Silently skipped:** a category x axis (no timeline), a range that already fits the pixel budget (unless `forced: true`), and a chart without a layout width (every chart component supplies one).

**Bucket metadata.** Each grouped row carries `__groupMeta` (`GroupMeta`: `unit`, `multiple`, `start`, `end`, `count` = the number of source rows in the interval, and `raw` = the lowest, highest and summed value of the first series with a value there). Read it with `getGroupMeta(datum)` (`undefined` for an ungrouped row). The built-in tooltip shows the interval; custom tooltip renderers receive it as `params.groupMeta`.

```tsx
// Simplest usage — boolean shorthand
<LineChart
  data={tenYearsOfDailyPrices}
  xKey="date"
  yKey="price"
  xAxis={{temporalSemantic: 'calendarDate'}}
  height={350}
  navigator
  zoom
  dataGrouping
/>

// Explicit configuration: candles grouped role by role
<Chart
  data={data}
  options={{
    series: [
      {
        type: 'candlestick',
        xKey: 'date',
        openKey: 'o',
        highKey: 'h',
        lowKey: 'l',
        closeKey: 'c',
      },
    ],
    axes: [
      {position: 'bottom', type: 'time', temporalSemantic: 'calendarDate'},
      {position: 'left', type: 'number'},
    ],
    dataGrouping: {groupPixelWidth: 8, forced: true},
  }}
  height={350}
/>
```

### 11.3 What each path reads (W4)

| Path | Rows |
| --- | --- |
| Rendering, hover and hit targets, tooltip | displayed (reduced or grouped) |
| Screen-reader data table, keyboard announcements | displayed, with provenance (below) |
| `exportAsCsv`, context-menu CSV export | raw: the chart's full `data` |
| Context-menu copy-as-table | raw: the visible (zoomed) range |

The screen-reader table of a prepared chart is described (`aria-describedby`) by a sentence that says how many of the rows it lists, or that each row summarises an interval. A grouped table reads each x cell as the bucket's interval (`charts.accessibility.interval`) and adds a column with its number of source rows (`charts.accessibility.sourceCount`); the keyboard announcement of a bucket ends with the same count and interval (`charts.accessibility.groupedPoint`). The words are host keys, like every chart text (ADR-028): `charts.accessibility.reducedData`, `groupedData`, `interval`, `sourceCount` and `groupedPoint`.

## 12. Sparkline — Carve-Out from the Archetype Contract

`Sparkline` is the one wrapper in `@workspace/active-charts` that does **not** follow the contract described in §§2-4. This is a deliberate design decision, not a gap. The summary below is what consumers need to know at the API surface.

**What the carve-out means:**

- **No archetype mixin.** `Sparkline` does not extend `BaseChartProps`, `CartesianChartProps`, or any other archetype — it has its own minimal prop set (`data`, `width`, `height`, `type`, `color`, `showLastPoint` for a line or area, `showMinMax`, `domain` (§ 10.16), `accessibility`, `onReady`, `onExportComplete`, `onExportError`).
- **Accessible name (ADR-093).** `accessibility={{label}}` (`Pick<AccessibilityOptions, 'label'>`, the name every chart takes) makes the sparkline one image with that name (`role="img"`). Without a name, or with a blank one, it is decorative and hidden from assistive technology (`aria-hidden`), and the surrounding text carries its meaning. It has no description, table, announcements or keyboard stop.
- **Raw `<svg>` output, no wrapper div.** Sparkline's root DOM element is the `<svg data-testid="sparkline">` itself — there is no outer `<div>`, no `className`, no `data-chart-scope` attribute. Every other chart renders `<div className={...} data-chart-scope><svg.../></div>`; Sparkline does not. Consumer CSS and data-grid cell renderers may rely on this exact DOM profile.
- **No `<ChartDefaults>` prop propagation.** `<ChartDefaults>` does not resolve a `<Sparkline>`'s props: Sparkline does not call `useChartDefaults()`. One provider value does reach it: its handle's PNG, SVG and PDF exports use `<ChartDefaults exportFonts>`, as every chart's do (ADR-059; LAB-AT23). Its colour is per instance via the `color` prop (CSS variables such as `var(--chart-1)` work because Sparkline reads them directly from the cascade, not via the contract); without a `color` it uses the same tokens as every chart (§ 13).
- **Reduced handle.** `Sparkline` accepts a `ChartExportHandle` ref and supports the image exports (`exportAsPng`, `exportAsSvg`, `exportAsPdf`) and `getSvgElement()`, plus `onReady`, `onExportComplete` and `onExportError`. `exportAsCsv` writes its points (`index`, `value`, or `x`, `y`); there is no `setZoom` (a sparkline does not zoom), and `getContainerElement()` returns `null` because there is no container element.
- **Custom `React.memo` comparator.** `Sparkline` wraps its drawing in `React.memo` with an explicit handwritten comparator over exactly nine render-critical props (the accessible name and, by its two numbers, the `domain` among them). The callbacks reach the drawing as stable proxies over the latest props, so a replaced `onExportComplete`, `onExportError` or `onReady` is the one called without a re-render (ADR-085). `showLastPoint` puts a dot on the last point of a line or area; a `type: 'bar'` sparkline does not take it. This comparator is contract-owned by Sparkline and cannot be inherited by other wrappers; it is an intentional performance property of the "inline use in grid cells and dashboards" use case, not a generic optimisation.

**When the carve-out will be reconsidered:**

Three explicit triggers force a revisit. Until one of them fires, Sparkline stays outside the contract by design:

1. A production feature lands with 20+ Sparklines per page that need `<ChartDefaults>` propagation (accessibility, animation) without per-instance prop plumbing.
2. A revision of `BaseChartProps` makes contract adoption substantially cheaper (e.g. zero-cost `useChartDefaults`, optional wrapper-div via `asChild`, tightened event surface).
3. A second micro-chart is added to the package. One carve-out is defensible; two are not — either both micro-charts join the contract, or they get a separate, explicitly exported micro-chart contract of their own.

**What this means for readers of this document:** every other section in this file (§§2-11) describes the shared wrapper contract. Anything about `Sparkline` beyond "it renders a raw `<svg>` from an array of numbers" is intentionally absent — consumers work from `sparkline.tsx`'s JSDoc and the explicit prop list, not from the archetype contract.

---

## 13. Styling: CSS tokens and the package stylesheet

Charts are styled by CSS custom properties. The host sets the public tokens once, in its own global stylesheet, for each colour mode it supports; every chart inherits them through the ordinary cascade. There is no theme provider and no JavaScript theme object.

**Visualisation roles** (`--chart-*`):

| Token | Role |
| --- | --- |
| `--chart-1` … `--chart-10` | Categorical series, in slot order. The first six carry the strongest distinction. |
| `--chart-positive`, `--chart-negative`, `--chart-caution` | Data meaning: rise and fall, a threshold band. Not the host's UI status colours. |
| `--chart-sequential-min`, `--chart-sequential-max` | Ordered low to high (the heatmap's default range). |
| `--chart-diverging-min`, `--chart-diverging-mid`, `--chart-diverging-max` | Below and above a centre. |
| `--chart-on-fill-dark`, `--chart-on-fill-light` | Text drawn on a mark, and a mark drawn over other fills (the bullet's value bar); the chart picks the one that contrasts with what lies beneath. |

**Structural roles**, read under the host's ordinary semantic names: `--background`, `--foreground`, `--card`, `--muted`, `--muted-foreground`, `--border` (surfaces, text, axes, grid, tooltip) and `--ring`, `--popover`, `--popover-foreground`, `--accent`, `--accent-foreground`, `--primary` (focus, menus, period selector), and `--radius`, the corner radius the context menu derives its own from as the Active Grid does (ADR-087). The chart reads no Tailwind alias (`--color-*`) and no UI status colour: a destructive menu item carries the menu's own red (`--ac-menu-destructive`), not the host's `--destructive`.

```css
:root {
  --chart-1: oklch(0.55 0.2 260);
  /* … --chart-2 … --chart-10, the roles above */
}
.dark {
  --chart-1: oklch(0.65 0.18 260);
  /* … */
}
```

**Defaults.** The package ships a complete fallback for every token, light and dark (dark under the `.dark` class on the document root or an ancestor). A token the host leaves unset uses it; a token the host sets always wins, in any scope and whatever the order in which the stylesheets load, because the package never declares a public token. The defaults meet the budgets of `QUALITY-BUDGETS.md` on their own surfaces. A host that sets only some of the ten categorical slots mixes its colours with the defaults; choose a complete palette, since a mixed one is not measured. The quality of a host's own values is the host's to measure.

**Private names.** Everything the package declares or writes itself (the defaults and its animation internals) is named `--ac-*` and is not part of the contract; a host sets only the public names above. **One palette.** The host keeps its categorical palette in one stylesheet; a scoped override names one slot or a semantic role, and a second copy of the palette elsewhere drifts from the first.

**The package stylesheet** (focus, motion, forced colours, print and the defaults) is loaded by the package entry as a static CSS side effect; a CSS-capable bundler includes it. A host imports nothing, registers no source for a utility-class scanner and wraps nothing in a provider.

**Precedence**, lowest to highest: package default, host token (global or scoped in CSS), the chart's `colors` palette (category assignment only), an explicit series, datum or annotation colour. That is the whole colour API: there is no theme prop, no theme object and no visual field on `<ChartDefaults>` (ADR-045). A per-chart override of a structural or semantic role is plain CSS on the chart's `className` or an ancestor:

```css
.portfolio-card {
  --chart-1: oklch(0.55 0.15 150);
  --foreground: oklch(0.3 0.02 260);
}
```

**Runtime changes.** A decision a chart takes in JavaScript (the on-fill colour of a label, and how its axis and data labels fit) is resolved in the chart's own element, after mount, and taken again when the styles change. No remount, provider or call is needed for a change the chart observes; a change it cannot observe needs one call. The contract (ADR-044, ADR-050):

| Change | Automatic / host | Mechanism |
| --- | --- | --- |
| `class` on an ancestor of the chart, up to `<html>` | automatic | ancestor observer, attributes only |
| inline custom property (`style="--x: …"`) on an ancestor | automatic | ancestor observer; an inline style without a custom property does not count |
| `data-theme` on an ancestor | automatic | ancestor observer |
| `dir` on an ancestor (the inherited direction, ADR-056) | automatic | ancestor observer; re-reads the chart direction |
| `<style>` in `<head>`: added, removed, text rewritten | automatic | head observer (child list, character data) |
| `<style>` in `<head>`: `media`, `type` or `title` changed | automatic | head observer (attributes) |
| `<link rel="stylesheet">` in `<head>`: added, removed | automatic | head observer (child list) |
| `<link rel="stylesheet">`: finishes loading, or fails to load | automatic | one capturing `load` and one `error` listener on the document |
| `<link rel="stylesheet">` in `<head>`: `media`, `disabled`, `rel`, `type`, `href` or `title` changed | automatic | head observer (attributes); `link.disabled` reflects the attribute |
| `prefers-color-scheme`, `forced-colors` | automatic | media-query listeners |
| `<style>.disabled`, `sheet.disabled` | host | a CSSOM flag without an attribute: `invalidateChartStyles()` |
| a CSSOM edit (`insertRule`, `deleteRule`, a rule's style), an adopted stylesheet | host | `invalidateChartStyles()` |
| a `<style>` or `<link>` outside `<head>` | host | `invalidateChartStyles()` |
| a media condition other than the two above that starts or stops matching (viewport width, print) | host | `invalidateChartStyles()` |
| explicit `invalidateChartStyles()` | host | the call itself |

The paint follows every change by itself; the table decides only the decisions taken in JavaScript. A host that changes styles in a way the table marks "host" calls once afterwards:

```ts
import {invalidateChartStyles} from '@workspace/active-charts';

const sheet = new CSSStyleSheet();
sheet.insertRule(':root { --chart-1: oklch(0.6 0.2 30); }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
invalidateChartStyles();
```

`invalidateChartStyles()` carries no value and sets no theme; the paint follows the tokens without it.

**Typography.** A chart measures its tick and data labels in the font it paints them in, in its own element: `var(--font-sans)` and an inherited family or weight resolve where the chart sits, not on the page body, so two sibling charts under different font variables lay out the same labels differently and correctly. A late font load measures again; so does a change in the table above that changes a font the chart resolved. Before mount (the server render and the first client render) a chart estimates its labels and reads no style; it measures in its scope before the first paint.

**Export** captures the view the chart shows at the moment it resolves (see § 4): SVG, PNG and PDF read the same colours, and PNG and PDF take the chart's background. A light report of a dark view is an export made in the light colour mode.

---

## 14. What Is **Not** in this Document

- Per-prop default values, pixel sizes, animation timings. These live on the TypeScript interfaces and are the authoritative source.
- Rendering internals (scales, SVG layout, voronoi interaction). See `src/core/` and `ARCHITECTURE.md`.
- Release notes. See `CHANGELOG.md`.

If you find this document out of date relative to the types, the types win.
