<!-- GENERATED by scripts/build-llms.mjs from llms/charts.md — do not edit this file. -->

# `lr-chart`

- **Import** `import '@aceshooting/lyra-ui/components/lr-chart.js';` (stable tag alias; registers the tag)
- **Class** `LyraChart`, also available unregistered from `@aceshooting/lyra-ui/components/charts/chart/chart.class.js`
- **Family** `components/charts/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** `chart.js`, `chartjs-plugin-annotation`, `chartjs-plugin-datalabels`, `chartjs-plugin-zoom` — see `llms/peers.md`
- **Themeable via** 16 parts, 37 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-chart` (core)

Chart.js wrapper used directly and by the eight typed Chart.js tags plus `lr-histogram`;
`lr-lite-chart` and `lr-box-plot` are independent implementations. It supports both a simplified
series surface and a raw Chart.js `config` passthrough (mirrors Web Awesome's `wa-chart` `config`
property).

For horizontal scalar bars, tooltip values come from parsed x rather than the category y, including
an effective raw `config.options.indexAxis` override. Both formatter APIs follow this rule;
structured points retain their y-value formatting.

**Properties:**
- `axes: LyraChartAxes = 'both'` — `'x' | 'y' | 'both' | 'none'`, controlling complete cartesian
  axes: labels, ticks, borders and grid lines. The `y` setting includes `y2`; invalid values use
  `both`. Radial charts ignore this setting. Use `grid` when only grid lines should disappear.
- `compact: boolean = false` — hides cartesian axes and removes automatic plot padding. Combine
  with `withoutLegend` and a short `height` (for example `64px`) for an inline histogram or
  sparkline. Tooltips, accessible data, formatting and keyboard activation remain available.
  `config` still wins over generated options, so explicit scale visibility or layout padding
  can override the preset. Leaving compact mode restores `axes`. Radial charts are unaffected.

  For a monthly histogram above `lr-slider`, give both controls the same inline allocation and
  use `lr-bar-chart compact without-legend height="64px"`. Map range values to bin edges: with
  twelve months, set the slider's `min=0`, `max=12`, `step=1` and treat its values as the half-open
  interval `[minValue, maxValue)`. Equal edges select no months. Use `show-value`,
  `value-display="formatted"` and `value-placement="label"` to display application-formatted month
  names without a separate readout listener. Commit filtering on `lr-change`; `lr-input` remains
  available for live previews. Chart categories retain their authored physical order: for an RTL
  slider that starts at the right edge, reverse both histogram labels and counts. The Monthly
  Range bar-chart story demonstrates both directions without scale configuration.
- `type: LyraChartType = 'bar'` — `LyraChartType = 'line' | 'bar' | 'scatter' | 'pie' | 'doughnut' |
  'radar' | 'polarArea' | 'bubble'` — every named default used by a typed `lr-*-chart` is
  already a first-class member, so `<lr-chart type="pie">` needs no subclass or cast to work;
  unknown runtime attribute/property values fall back to `bar` before reaching Chart.js
- `description: string | null = null` — accessible chart description
- `grid: 'x'|'y'|'both'|'none' = 'both'` — controls cartesian grid lines. On a radial chart, `x`
  controls angle lines and `y` controls concentric grid lines
- `indexAxis: 'x'|'y' = 'x'` (attribute `index-axis`) — Chart.js index axis. `'y'` is Chart.js's own
  mechanism for horizontal bars (it also flips `line`/`area` types onto a horizontal category axis).
  The `horizontal` boolean that used to alias `'y'` was removed in 9.0.0 — use `index-axis="y"`
- `label: string | null = null` — accessible chart label. Host `aria-label` has highest precedence
  by presence, including an explicit empty string
- `max: number | null = null`, `min: number | null = null` — finite value-axis bounds. They apply to
  the cartesian value axis selected by `indexAxis`, or the radial `r` scale; non-finite writes are
  omitted before Chart.js sees them
- `annotations: readonly LyraChartAnnotation[] = []` (attribute: false) — declarative reference
  lines and shaded bands: a threshold, an event year, a regime change, a highlighted period.
  `LyraChartAnnotation { axis?: 'x' | 'y'; value?: number; from?: number; to?: number; label?:
  string; tone?: LyraVariant }` — `tone` is the shared `LyraVariant` vocabulary
  (`'neutral' | 'brand' | 'success' | 'warning' | 'danger'`), not a chart-local copy. A finite `value` renders
  a reference line on that axis; a finite `from`/`to` pair renders a band bounded on that axis and
  spanning the other. `axis` defaults to `'y'`. An entry with neither (or non-finite numbers) is
  dropped rather than handed to Chart.js; a reversed range is normalized. Labelled entries are
  included in the generated accessible description, mirroring `lr-heatmap` — the label is
  consumer-supplied text and so is not localized, and an unlabelled line has no nameable meaning to
  announce. Needs the optional `chartjs-plugin-annotation` peer, loaded on first actual demand, so a
  page with no annotated charts never downloads it; without it the chart still renders, and a
  console warning plus a localized visible warning and light-DOM announcement explain the no-op.
  The plugin is registered globally, like `chartjs-plugin-zoom` and unlike
  `chartjs-plugin-datalabels`: it draws nothing unless a chart supplies annotation options, so the
  registration is unobservable to charts that set none, and registration is also what installs the
  plugin's own element defaults
- `scaleType: 'linear' | 'logarithmic' = 'linear'` (attribute `scale-type`, type
  `LyraChartScaleType`) — scale type for the **value** axis; the categorical axis is never
  affected. `'logarithmic'` plots data spanning several orders of magnitude (prices, latency
  percentiles, file sizes) honestly, where a linear axis collapses everything below the maximum
  into the baseline. Inherited by `lr-line-chart`, `lr-scatter-chart` and `lr-bar-chart`, and
  applied to the secondary `y2` axis too when one is present. A logarithmic axis cannot represent
  zero (`log(0)` is `-Infinity`), so `beginAtZero` is not forwarded in that mode and non-positive
  points are dropped by Chart.js's own log scale. Chart.js rejects an unregistered scale type at
  construction, so `LogarithmicScale` is registered with the core — it ships inside the `chart.js`
  module already loaded, adding no download weight
- `plugins: LyraChartPlugin[] = []` — peer-neutral per-instance Chart.js plugin structures,
  combined without duplicates with Lyra's
  on-demand data-label plugin and any `config.plugins` entries
- `labels: readonly string[] = []` (attribute: false)
- `datasets: readonly LyraChartSeries[] = []` (attribute: false) — `LyraChartSeries { readonly
  label: string; readonly data?: readonly (number|null)[]; readonly points?: readonly
  LyraChartPoint[]; readonly color?: string|readonly string[]; readonly stack?: string; ... }`. The
  deprecated `Series` and `ChartPoint` names were removed in 9.0.0 — import
  `LyraChartSeries`/`LyraChartPoint` instead.
  - `stack` is a Chart.js dataset `stack` group id: series sharing one `stack` value on the same
    (stacked) axis accumulate into one stack; a different id starts an independent stack Chart.js
    draws side by side with the first on that axis. Omitted series share one implicit group, so
    every chart written before `stack` existed sums exactly as it always did. Only meaningful on an
    axis that is actually stacked — see `stacked`/`stackedAxes` below.
  `LyraChartPoint { readonly x: number; readonly
  y: number; readonly r?: number; readonly label?: string }`: `r` is the bubble
  radius, and the optional per-point `label` is retained by events, CSV export, keyboard
  announcements, generated summaries, and the accessible table. Point wording is localized as
  whole messages: `chartPointCoordinates`, `chartBubblePointCoordinates`, and
  `chartLabeledPoint` own coordinate names, order, separators, and the label wrapper. A caller's
  point label is interpolated verbatim rather than translated or parsed. At runtime, non-record
  dataset entries are dropped while valid sibling series remain usable; an omitted `data`/`points`
  payload is still a valid empty series.
  - `pointRadius` takes a single number (applied to every point) **or** an array matching `data`'s
    length that sizes each point independently — passed straight through to Chart.js, which
    supports both natively. Useful for emphasizing a single outlier or the latest reading without
    splitting the series in two.
  - `segmentColors` colors each *segment* (the line drawn between two consecutive points) by the
    segment's **starting** point index, so `['red', 'green']` over 3 points paints the first
    segment red and the second green; a shorter array cycles. When rows are sampled, each
    represented segment retains the original source starting-point index modulo the palette length.
    Wired to Chart.js's
    `segment.borderColor` scriptable option, so it is only meaningful for line-type series.
    Typical use is threshold/anomaly banding along one line. A series that omits it (or passes an
    empty array) emits no `segment` key at all, leaving line rendering exactly as before.
- `hiddenDatasets?: readonly number[]` (attribute: false) — complete controlled DOM-legend
  visibility state. Leave it `undefined` (the default) to honor each effective dataset's declarative
  `hidden` value; pass `[]` to deliberately show every dataset, or a canonical list of zero-based
  indexes to hide those datasets. A defined value wins over the declarative defaults. Duplicate,
  non-integer, negative, and out-of-range indexes are discarded. The component writes the accepted
  legend-toggle snapshot back to this property so a host can persist it, and a programmatic write
  reconciles Chart.js and the DOM legend silently without emitting either legend-visibility event.
- `withoutLegend: boolean = false` (attribute `without-legend`, reflected) — the legend shows by
  default; set this to hide it. Renders a wrapping DOM legend (when shown) whose keyboard-operable
  buttons toggle dataset visibility. The DOM surface preserves long public labels that a canvas legend
  would clip. Its pressed state follows `hiddenDatasets` whenever that controlled snapshot is
  defined, otherwise the effective dataset's declarative `hidden` value before Chart.js is ready
  and across chart type/plugin rebuilds.
- `legendMode: LyraChartLegendMode = 'auto'` (attribute `legend-mode`) — `auto` resolves to `datum`
  on pie, doughnut and polar-area charts and to `dataset` everywhere else, so a single-dataset slice
  chart labels every slice rather than emitting one aggregate row that identifies only the first
  colour. `dataset` forces dataset toggles even on a slice chart. `datum` shows one category toggle
  per slice in pie, doughnut and polar-area charts; other types retain dataset legends. Category names use source labels with localized
  numbered fallbacks. Colors and values come from the first dataset. With multiple datasets/rings,
  a category toggle hides that source index in every ring, matching Chart.js category visibility.
- `hiddenDatums: readonly number[] = []` (attribute: false) — clone-owned hidden source category
  indexes for radial charts. `[]` restores all categories. Invalid, duplicate and out-of-range
  indexes are ignored when applied or emitted. Programmatic changes are silent; accepted datum
  legend toggles write the complete next snapshot. Data and type replacements, reconnection and
  sampling retain source-index meaning. This state is independent of `hiddenDatasets`; hiding a
  whole dataset continues to hide its ring. Accessible data and CSV export retain hidden values.
- `legendDisplay: LyraChartLegendDisplay = 'auto'` (attribute `legend-display`) — `auto` preserves
  the existing label with optional legend formatting. `label` always shows labels alone, even when
  `formatter`/`valueFormatter` supplies values for tooltips or axes. `value` appends the formatted
  numeric value. `percentage` appends a locale-formatted share independently of these callbacks:
  the denominator is the sum of absolute represented legend values, including hidden entries.
  `value-percentage` appends both, as `label: value (percentage)`. Dataset entries use sampled
  sums; category entries use the first dataset's represented values. Zero totals give 0%. The
  `formatter`/`valueFormatter` callback backing `value` and `value-percentage` also receives that
  same share as `percentage` in its `surface: 'legend'` context (`LyraChartFormatterContext`), so a
  custom formatter can render its own combined text without recomputing it from raw data. These
  options affect the DOM legend; tooltip/axis/table formatting is unchanged.
  Simplified pie/doughnut datasets with magnitudes above `Number.MAX_SAFE_INTEGER` are uniformly
  rescaled for finite canvas geometry. Lyra tooltips, data labels, legends, events and CSV retain
  original values; direct Chart.js callbacks see the rescaled peer data. Explicit `config.data`
  remains the unmodified full-fidelity escape hatch.
- `legendPosition: LyraChartLegendPosition = 'top'` (attribute `legend-position`) — accepts the
  Chart.js `left|top|right|bottom|center|chartArea|{ [scaleId]: number }` positions plus logical
  `start`/`end`; the additive `auto` chooses right above 480px and bottom below that allocation
  width. Logical positions swap under RTL, and a literal `left`/`right` stays on the physical edge
  it names in both directions — the rendered DOM legend honors both, so `legend-position="start"`
  really does paint at the reading-start edge under `dir="rtl"`
- `valueFormatter?: LyraChartValueFormatter` (attribute: false) — formats numeric (value-axis)
  tick, tooltip, legend, and generated accessible-table values; the callback receives the value
  and `'tick'`, `'tooltip'`, `'legend'`, or `'table'` context. Never runs against the categorical
  x-axis's own labels (line/bar's `labels` strings) — Chart.js's category scale passes the tick
  index to `ticks.callback`, not the label text
- `formatter?: LyraChartFormatter` (attribute: false) — the family-wide context-object formatter:
  `({ value, surface, datasetIndex?, index?, label?, seriesLabel?, statistic?, axis? }) => string`.
  `surface` identifies `visual`, `spoken`, `export`, `tick`, `tooltip`, `legend`, or `table`.
  `statistic`, when present for a structured datum or stack total, is one of `x`, `y`, `r`,
  `min`, `q1`, `median`, `q3`, `max`, or `total`. It takes precedence over the legacy positional
  `valueFormatter` where both are supplied. The legacy callback remains a visual/table compatibility
  hook; use `formatter` when a generated spoken value or CSV cell must use the same unit text.
  `axis` names the scale the number is plotted on — `'x'` wherever it carries numbers rather than
  categories (scatter/bubble, and a horizontal bar/line), `'y'`/`'y2'` for the two cartesian value
  axes, `'r'` for a radar/polar-area ring, and `undefined` where there is no axis at all (a
  pie/doughnut slice). Two value axes usually exist precisely because they carry different units,
  so without it one formatter cannot render a secondary axis correctly. `datasetIndex`, `index`,
  `label` and `seriesLabel` now reach the `tick`, `tooltip`, `legend` and `visual` surfaces as well
  as the `table`, `export` and `spoken` ones they always reached, and indexes are reported in
  source space — the same space the data table, the CSV export and `lr-point-click` use. Every one
  of these fields was `undefined` before, so no existing formatter changes behaviour. A
  `stackTotals`/`tableTotals` stack total is the exception in the other direction: it is a sum
  *across* the stack's datasets, so it carries `statistic: 'total'`, the category `index`/`label`
  and the stack's own `axis`, but no `datasetIndex` and no `seriesLabel` — naming the topmost
  series would make a unit-switching formatter render that one series' unit for a cross-series
  number. `lr-lite-chart`'s total cells drop the same two fields.
- `tooltipTitleFormatter?: LyraChartTooltipGroupFormatter` (attribute: false) — tooltip title
  formatter (e.g. a scatter point's own name). `LyraChartTooltipGroupFormatter = (items:
  readonly LyraChartFormatterContext[]) => string`: unlike `formatter`, which runs once per item,
  this runs once per tooltip render and receives every hovered item's context at once (one entry
  per dataset the tooltip covers, each in the same shape `formatter`'s `'tooltip'` surface already
  produces). Unset (the default) leaves Chart.js's own default title — the shared category label.
- `tooltipFooterFormatter?: LyraChartTooltipGroupFormatter` (attribute: false) — tooltip footer
  formatter (e.g. a category's stack total under the items), in the same shape and calling
  convention as `tooltipTitleFormatter`. Unset (the default) leaves Chart.js's own default: no
  footer.
- `area: boolean = false` — chart-wide default for whether line-type series fill the region under
  their line; a series's own `fill` overrides it, rendered with a translucent version of its color
- `zoom: boolean = false` — wheel/drag/pinch zoom on the `x` axis only (pan disabled, and the zoom
  range is limited to the original data extent); shows the `reset-zoom-button` while zoomed
- `height: string = '280px'` — a valid CSS length used only as the component's private fallback.
  A consumer-set `--lr-chart-height` always takes precedence; invalid values remove that fallback
  and likewise leave the public token/default in control.
- `xLabel: string | null = null` (attribute `x-label`)
- `yLabel: string | null = null` (attribute `y-label`)
- `y2Label: string = ''` (attribute `y2-label`)
- `beginAtZero: boolean = true` (attribute `begin-at-zero`)
- `stacked: boolean = false` — stacks the `x`/`y`(/`y2`) scale entries `buildScales()` returns; only
  meaningful for `bar`/`line` types (scatter/bubble's linear `x` scale and the radial `r` scale used
  by radar/polar-area are out of scope)
- `stackedAxes?: Partial<Record<'y' | 'y2', boolean>>` (attribute: false) — per-value-axis override
  of `stacked`, keyed by `'y'`/`'y2'`. An axis absent from this record — including every axis when
  the whole property is unset — falls back to `stacked`, so a chart that never sets this renders
  byte-identically to before. Lets a `stacked` bar series on the primary axis sit next to an
  unstacked overlay series on `y2` (via `LyraChartSeries.axis: 'y2'`): e.g. `stacked` plus
  `stackedAxes: { y2: false }`. The shared categorical axis (`x` for a vertical bar/line, or `y`
  under a horizontal `indexAxis`) has no entry of its own — it always mirrors the resolved `'y'`
  value, matching Chart.js's own paired index/value-scale stacking contract.
- `withoutAnimation: boolean = false` (attribute `without-animation`, reflected) — disables Chart.js
  construction animation; reduced-motion preference also disables it regardless of this value
- `withoutTooltip: boolean = false` (attribute `without-tooltip`, reflected) — disables the
  Chart.js tooltip plugin for this instance
- `dataLabels: boolean = false` (attribute `data-labels`) — draws each point's value on the chart via
  the optional `chartjs-plugin-datalabels` peer (see `peers.md`). Unset (the default) leaves labels
  off; because the plugin is registered **per chart instance** (not globally), a `<lr-chart
  data-labels>` never affects any other chart on the page. If the peer is unavailable or cannot
  register, the core chart remains usable, labels stay disabled, and a localized visible
  `feature-warning` plus assertive announcement explains the nonfatal limitation. The screen-reader
  equivalent is the always-present accessible data table (`show-data-table` makes it visible) —
  labels are a purely visual, canvas-only addition and add no new a11y surface.
- `stackTotals: boolean = false` (attribute `stack-totals`) — on an actually-stacked axis (`stacked`
  or `stackedAxes`, bar/line only), draws the per-category stack total above each stack, via the
  same `chartjs-plugin-datalabels` peer. Computed per `LyraChartSeries.stack` group as well as per
  axis: two stack groups sharing one axis each get their own total, drawn above their own topmost
  dataset, and a dataset on an axis that isn't stacked (e.g. an unstacked `stackedAxes` overlay)
  never gets one. Null/undefined points are skipped; a category whose every value is null shows no
  total (not `0`). The generated accessible table receives one formatted total column per stacked
  axis (a dual-axis stack gets separately labelled primary- and secondary-axis columns); with
  multiple stack groups on one axis, that column still totals only the implicit group of series
  that never set their own `stack` id — a per-group breakdown is available programmatically but not
  yet surfaced as extra table columns. The table totals do not depend on the optional visual-label
  peer being installed. If that peer is unavailable, the chart retains its core rendering and
  generated table totals while a localized nonfatal warning explains that the canvas labels cannot
  be drawn.
- `config?: LyraChartConfiguration` (attribute: false) — peer-neutral configuration structurally
  compatible with Chart.js's `ChartConfiguration`, deep-merged over the generated
  config; any nested key wins without clobbering sibling generated keys. This is the raw Chart.js
  escape hatch, so a caller-supplied `config.type` is passed through rather than normalized.
  Explicit `config.data.labels` and `config.data.datasets` arrays are authoritative independently:
  an omitted member still comes from the simplified `labels`/`datasets` properties, while an
  explicit array replaces that generated member rather than concatenating with it. This effective
  model drives canvas rendering, `appendData()`, export, the accessible name/summary, keyboard
  navigation and activation events, the DOM legend, and the generated fallback table.
  For generated **bar** series, an explicit series `width` wins. Otherwise authored bar
  `borderWidth`/`borderRadius` defaults in `options.datasets.bar`, its nested `elements.bar`,
  `options.elements.bar`, or root `options` are left for Chart.js to resolve, including scriptable,
  indexable and `barBorderWidth`/`barBorderRadius` forms. Unset values retain Lyra's `--border-width`
  and `--border-radius` defaults; removing config restores those defaults. Raw `config.data.datasets`
  remain authoritative. Dense charts with transparent borders should set
  `config.options.datasets.bar.borderWidth = 0` (or the public `--border-width: 0` CSS hook):
  a one-pixel transparent stroke can consume the fill of a bar narrower than two pixels.
  As a declarative alternative, place one `<script type="application/json">` in the default slot;
  an explicitly assigned `config` property wins over the slotted object. Invalid/non-object JSON is
  ignored without evaluating script or exposing prototype-pollution keys to the merge.
- `showDataTable: boolean = false` (attribute `show-data-table`) — makes the always-available
  accessible data table visible rather than screen-reader-only
- `dataTableToggle: boolean = false` (attribute `data-table-toggle`, new in 11.0.0) — renders a
  localized disclosure button (`part="data-table-toggle"`) above the data table so a *sighted*
  reader can reveal the numbers on demand. `showDataTable` alone is all-or-nothing, which left
  consumers wrapping a duplicated table in their own `<details>`. With the toggle on,
  `showDataTable` becomes the disclosure's **initial** state rather than its whole behavior; the
  table stays in the DOM in both states, so assistive technology never loses it, and the button
  carries `aria-expanded` plus `aria-controls` pointing at the `data-table` wrapper. Until the
  reader activates it, the disclosure follows `showDataTable`; afterwards the reader's selected
  state remains authoritative for that mounted chart. A supplied `slot="data-table"` follows this
  same disclosure state. Unset, nothing renders and behavior is identical to before.
- `chartArea: LyraChartArea | undefined` (readonly) — current Chart.js chart-area geometry in
  canvas-local coordinates (`top`, `left`, `right`, `bottom`, `width`, `height`), when a chart is
  drawn
- `chart: LyraChartInstance | undefined` (readonly-by-convention) — peer-neutral structural view of
  the live Chart.js instance; absent before load and after disconnect
- `appendData(label, values, maxPoints?)` — appends one aligned numeric category and optionally
  keeps only the newest `maxPoints`. Each labels/datasets member is written back to the surface
  that owns it: an explicitly overridden `config.data` member stays in `config`, while an omitted
  member continues through the simplified property and retains its generated Chart.js styling.
  Point-based scatter/bubble series are left unchanged because appending their x/y/r coordinates
  needs a richer caller-defined contract.

**Methods:** `renderChart()` requests a connected/visibility-gated render or incremental update.
`resetZoom()` resets any active zoom/pan to the original view. `refreshTheme()` forces
a redraw so the `--lr-chart-*` tokens below are re-read from the current computed style. A
built-in `ThemeWatcher` now calls this automatically when `prefers-color-scheme` flips or an
ancestor's `class`/`style`/`data-theme`/`data-color-scheme` attribute mutates — the most common
theme-toggle mechanisms — so a consumer rarely needs to call it by hand; it remains public as the
escape hatch for theme changes those signals can't observe. Canvas redraw remains visibility-gated;
when a DOM legend is present its computed swatch colors are refreshed too.
`exportData('csv' | 'png')` returns a spreadsheet-safe CSV snapshot or the current PNG data URL
when Chart.js is loaded. Numeric datasets retain the compact one-column-per-series CSV shape.
Point datasets expand into `<series> x`, `<series> y`, and, when present, `<series> r` and
`<series> label` columns so radius and per-point labels are not flattened away.

**The categorical series ramp.** Eight tokens, `--lr-color-chart-1` … `--lr-color-chart-8`, with a
separate set of values for light and dark mode. A series that sets no `color` of its own is assigned
one of them by index.

The ramp is **generated, not hand-picked**, by a search that maximises worst-case separation between
every pair of series under all three dichromacies, over a candidate pool that already clears 3:1
against the surface (WCAG 1.4.11 — a chart series is a non-text graphical object conveying data).
The consequence worth designing around: the ramp separates on **lightness** as well as hue. Hue is
exactly the channel colour-vision deficiency collapses, so two series that differ only in hue become
the same series under red-green colour blindness; lightness is the channel every form of colour
blindness preserves. That is also why the eight colours are not evenly lit — an evenly-lit
categorical ramp cannot satisfy the constraint at all.

The separation guarantee is *pairwise across all eight*, so any subset of the ramp — and any
ordering of it — inherits it; you can pick entries 1 and 5 as freely as 1 and 2. What forfeits the
guarantee is supplying your own colours through `Series.color` or replacing the ramp with a set
chosen by hue alone. An eight-hue set at one lightness looks tidier on screen and is unreadable to
the ~8% of men with red-green colour-vision deficiency.

**`--lr-theme-color-chart-1` … `-8` is the retheme hook.** Each internal `--lr-color-chart-N` reads
the matching `--lr-theme-color-chart-N` application input and falls back to the generated value, so
setting the theme inputs once at `:root` recolours every chart in the app with no per-component
override and no `::part()` rule. Charts are canvas-rendered, so a live theme change is picked up by
the built-in `ThemeWatcher` (or `refreshTheme()`) rather than by CSS alone.

Under `forced-colors: active` the ramp deliberately collapses onto the three system colors the mode
actually guarantees (`Highlight`/`LinkText`/`CanvasText`, cycling), so colour alone stops separating
more than three series there. The chart therefore adds a **non-colour encoding automatically** in
that mode, cycling eight variants by series index: a canvas fill pattern (stripes, crosshatch, dots,
checker), a `borderDash` stroke pattern, a `pointStyle` shape, and the matching texture on the DOM
legend's `legend-swatch`, which carries a `data-encoding` attribute naming the variant (`solid`,
`horizontal`, `vertical`, `diagonal`, `reverse-diagonal`, `crosshatch`, `dots`, `checker`). Nothing
is opt-in and no author colour is substituted. Direct labels via `dataLabels` are still worth adding
when a chart must stay readable with no legend at all.

The instance method `seriesPalette(): string[]` resolves that ramp through `getComputedStyle` and
returns the concrete, theme-aware colors — the exact same values the chart hands an uncolored
series, with any `--lr-theme-color-chart-*` override already applied.

The module also exports `seriesPalette(scope?: Element | null): string[]`, which can run before a
chart exists. Omit `scope` to read `document.documentElement`, pass an element to resolve its theme
scope, or pass `null` to request the light-mode fallback directly. When a scope has no component
token layer yet, the helper reads the `--lr-theme-color-chart-N` inputs directly. Both forms return
a fresh eight-color array each call (safe to mutate) and let chart-adjacent UI, KPI tiles, or the
`Series` array itself come from one source of truth.

The same module also exports `ChartThemeColors`, the resolved chrome colours a chart paints around
its series. Every field is an already-resolved CSS colour string, because canvas silently ignores a
raw `var(--lr-…)` string and would paint nothing; `<lr-box-plot>` consumes the same shape, so a
consumer drawing its own canvas overlay beside a chart can match the grid, tick, legend and tooltip
chrome exactly rather than re-deriving it.

Import the standalone form from `.../chart/chart-colors.js`, not from `.../chart/chart.js`: the
latter is `<lr-chart>`'s registration entry, so it defines the element (and pulls in `<lr-skeleton>`)
as a side effect. `chart-colors.js` is side-effect-free and carries nothing but the palette helpers,
so a KPI tile that only needs eight color strings stays a ~1KB import.

```ts
import { seriesPalette } from '@aceshooting/lyra-ui/components/charts/chart/chart-colors.js';

const colors = seriesPalette();
const series = [
  { label: 'Revenue', data: [12, 19], color: colors[0] },
  { label: 'Costs', data: [7, 11], color: colors[1] },
];
```

**Events:** `lr-zoom` (`detail: { zoomed: boolean }`, fired on zoom-complete and on
`resetZoom()`), `lr-point-click` (fired when pointer input lands on an intersecting data
point/segment, when a generated-table value is activated, or when Enter/Space activates the
keyboard-current canvas datum; `detail: { datasetIndex: number, index: number, label: string |
undefined, value: unknown }`). For scatter/bubble points, `label` prefers the per-point label and
`value` is the complete `LyraChartPoint`, including optional `r` and `label`.
`lr-datum-activate` emits the same activation with `kind: 'bar'|'point'|'segment'|'slice'` for
family-wide handling; `lr-point-click` remains as a compatibility event. Also
`lr-before-legend-visibility-change` (cancelable proposal), and
`lr-legend-visibility-change` (accepted commit). Both legend events carry
`{ datasetIndex: number, visible: boolean, hiddenDatasets: readonly number[] }`; the latter is the
complete, sorted, valid next snapshot. Call `preventDefault()` on the proposal to veto the toggle;
then no property change or commit event occurs.
Category toggles use a separate pair, `lr-before-datum-visibility-change` (cancelable proposal) and
`lr-datum-visibility-change` (accepted commit), carrying
`{ index: number, visible: boolean, hiddenDatums: readonly number[] }`. Both details and their
complete, sorted index snapshots are frozen. `index` is the source category index, including when
the displayed data is sampled; it applies across all rings. Programmatic assignments emit neither
pair. `preventDefault()` leaves both controlled state and the chart unchanged.

```html
<lr-doughnut-chart
  legend-mode="datum"
  legend-display="label"
  .labels=${['A', 'B', 'C']}
  .datasets=${[{ label: 'Distribution', data: [5, 3, 2] }]}
  .hiddenDatums=${[]}
></lr-doughnut-chart>
```

**Slots:** default — one optional `<script type="application/json">` Chart.js configuration;
`data-table` — an optional consumer-provided complete, paginated, or virtualized accessible table
alternative; `center` —
optional overlay content positioned at the chart area's center, useful for doughnut and pie totals.

**Bounded rendering and data alternative:** simplified `labels`/`datasets` canvas rendering, the
DOM legend, generated table, keyboard-operable datum model, generated point-details in the summary,
and automatic canvas name process at most 1,000 category×series records. When sampling is necessary,
the selected category and series indexes are distributed
deterministically and retain their first and last endpoints; a localized `data-truncation` notice
is shown and announced. Supplying `slot="data-table"` suppresses the generated detailed sample and
notice, so use that escape hatch when the complete data set needs pagination, virtualization, or
another application-owned presentation. Explicit `config.data` is the deliberate full-fidelity
Chart.js escape hatch and is not rewritten by the simplified-surface sampler.

**CSS parts:** `base`, `plot` (the fixed-height canvas/overlay region), `canvas`, `legend` (the
wrapping DOM legend), `legend-item` (a dataset/category visibility button), `legend-item-hidden`
(added while the dataset/category is hidden), `legend-swatch`,
`reset-zoom-button`, `description`, `notices` (wrapper for nonfatal feature warnings and
bounded-alternative truncation notices), `data-table`, `data-table-toggle` (the `dataTableToggle` disclosure button), `data-truncation` (the bounded-alternative
notice), `feature-warning` (a nonfatal missing optional-feature warning), `center` (the
chart-area-centered wrapper for the `center` slot), `error` (neutral visible message rendered in
place of `canvas` when the optional `chart.js` peer dependency fails to load; the failure transition
is announced through the shared document-level light-DOM assertive sink)

**Themeable custom properties:** `--lr-chart-height` (the public, consumer-owned plot-height hook;
it sizes the `plot` region and the host's minimum block size, while a visible table or wrapping
legend grows the host in normal flow. The `height` property writes only a private fallback, so this
public token wins across valid, invalid, and unset `height` updates. Set it on the host or an
ancestor, not a shadow-tree descendant, since custom properties only cascade downward);
`--lr-chart-grid-color` (default `var(--lr-color-border)`),
`--lr-chart-tick-color` (default `var(--lr-color-text-quiet)`), `--lr-chart-legend-color`
(default `var(--lr-color-text)`), `--lr-chart-tooltip-bg` (default `var(--lr-color-surface)`),
`--lr-chart-tooltip-text` (default `var(--lr-color-text)`) — each resolved fresh via
`getComputedStyle` on every draw (Chart.js renders to canvas, not the DOM, so it can't consume CSS
`var()` directly), driving the grid lines, tick labels **and axis titles** (`xLabel`/`yLabel`/
`y2Label` title text reuses `--lr-chart-tick-color` too — there's no separate title-color token),
legend text, and tooltip background/text respectively; `--lr-chart-tick-font-size` (default
`var(--lr-font-size-xs)`, any CSS length unit) — the axis tick-label font size, same
`getComputedStyle` resolution and same token *name* as `lr-lite-chart`'s SVG equivalent, though not
the same default: `--lr-font-size-xs` is 12px at the standard root, matching Chart.js's own
built-in tick font size that every canvas chart rendered before this token existed, while
`lr-lite-chart`'s SVG axis labels default to the smaller `--lr-font-size-2xs` (10px), unchanged
from before this token existed. Also sizes the radar/polarArea `r`-scale `pointLabels` (the spoke
labels) once set, but NOT by default: Chart.js's `RadialLinearScale` gives `pointLabels` its own
distinct built-in default (10px, not this token's 12px tick default), so leaving the token unset
keeps that separate default rather than adopting the tick size; setting the token brings both the
ticks and the point labels to the same size; plus
`--lr-chart-legend-item-hover-bg` / `--lr-chart-legend-item-active-bg`,
`--lr-chart-data-table-button-hover-bg` / `--lr-chart-data-table-button-active-bg`,
`--lr-chart-data-table-toggle-hover-bg` / `--lr-chart-data-table-toggle-active-bg` (the
`dataTableToggle` disclosure button), and
`--lr-chart-reset-zoom-button-hover-bg` / `--lr-chart-reset-zoom-button-active-bg` — independent
background hooks for each DOM control's hover and pressed states. Hover defaults to
`--lr-color-brand-quiet`; pressed defaults to its standard active color mix. Override one pair
without repainting the other controls;
`--lr-chart-legend-side-max` (default `var(--lr-size-15rem)`) caps the side-positioned legend track;
the responsive grid also limits it to one third of the chart allocation and stacks it below the
plot in narrow containers;
`--lr-chart-canvas-hover-outline-width` (default `var(--lr-border-width-thin)`) — the width of
`[part="canvas"]`'s own `:hover` outline. Unlike the tokens above, this one is a real CSS
declaration on a DOM element (the outline is painted by the stylesheet, not by Chart.js), so it is
consumed directly with no `getComputedStyle` bridging; it is an inline `var()` fallback at the point
of use, so it can be set on the element or any ancestor, and left unset the outline is exactly the
`--lr-border-width-thin` it always was. `--lr-chart-canvas-hover-outline-color` (default
`var(--lr-chart-grid-color)`) independently controls that outline's color.

`--lr-chart-pattern-step` (default `var(--lr-space-2xs)`) is the tile size of the texture painted on
`[part="legend-swatch"]` while `forced-colors: active` matches — the legend half of the non-colour
encoding described under "The categorical series ramp" above, and the reason repeated system colours
stay tellable apart in the DOM legend. It has no effect outside forced colors, where the swatch is a
plain colour chip. It scales the tile, not the mark inside it: the stripe/crosshatch line width
stays `--lr-border-width-thin` and the `dots` radius stays absolute, so a larger step spaces those
marks further apart rather than thickening them (the percentage-based `checker` variant is the one
that scales with the step). It does not touch the **canvas** pattern, whose 8×8 bitmap geometry is
part of the encoding algorithm and is not themeable. Unlike
the properties above it is declared by the shadow stylesheet on the swatch itself, not inherited
from the host, so setting it on `lr-chart` does nothing — override it through the part:

```css
lr-chart::part(legend-swatch) {
  --lr-chart-pattern-step: 0.5rem;
}
```

Plus shared `--lr-space-xs`.

The mirrored Chart styling hooks are also available on all nine tags. All canvas-bound values are
resolved to concrete colors/CSS-pixel numbers on every draw; `rem` uses the live root font size and
`em` uses the chart's own font size rather than a hard-coded conversion.

- `--border-color-1`, `--border-color-2`, `--border-color-3`, `--border-color-4`,
  `--border-color-5`, `--border-color-6` — first six dataset/slice stroke colors, each falling back
  to matching `--lr-color-chart-N`
- `--fill-color-1`, `--fill-color-2`, `--fill-color-3`, `--fill-color-4`, `--fill-color-5`,
  `--fill-color-6` — first six dataset/slice fill colors, with the same Lyra
  palette fallbacks. A directly authored fill is used as-is; an unoverridden line-area fallback
  retains Lyra's translucent area treatment
- `--border-radius` → `--lr-radius`; `--border-width` → `--lr-border-width-thin`
- `--grid-border-width` → `--lr-border-width-thin`; `--grid-color` →
  `--lr-chart-grid-color`
- `--line-border-width` → `--lr-border-width-medium`; `--point-radius` → `--lr-space-2xs`

**Optional peer deps:** `chart.js` (mandatory peer, lazy-imported on every `connectedCallback()`
regardless of options), `chartjs-plugin-zoom` (lazy-imported *additionally* only when `zoom` is — or
later becomes — `true`; never fetched for a chart that keeps `zoom` unset/false, since the plugin
has a hard dependency on `hammerjs`), `chartjs-plugin-datalabels` only when `data-labels` or
`stack-totals` is enabled, and `chartjs-plugin-annotation` only when `annotations` contains a usable
entry. Each capability load is memoized once per page, registering only the tree-shaken
controller/element/scale subset actually used. A failed zoom, data-label, or annotation peer is not
a failed chart: the canvas, legend, and accessible alternative remain usable; the requested
enhancement is disabled and a localized static `feature-warning` is visibly rendered and
announced. In particular, unavailable data labels do not remove generated table totals.

```html
<lr-chart type="line" x-label="Day" y-label="kWh"></lr-chart>
<script>
  const c = document.querySelector('lr-chart');
  c.labels = ['Mon', 'Tue', 'Wed'];
  c.datasets = [{ label: 'Production', data: [12, 19, 7], color: '#2563eb' }];
</script>
```

**Known gotchas:**
- supported `type` values are normalized before reaching Chart.js; unknown runtime attribute or
  property values fall back to `bar`. Each typed `lr-*-chart` tag supplies its named chart type as
  a default while retaining the mirrored writable `type` surface.
- a built-in `ThemeWatcher` automatically rethemes an already-drawn chart when
  `prefers-color-scheme` flips or an ancestor's `class`/`style`/`data-theme`/`data-color-scheme`
  attribute mutates (coalesced to one redraw). `refreshTheme()` stays public for out-of-band theme
  changes those signals can't observe.
- the focusable canvas is an interactive `application`, not a static image: Arrow keys move through
  finite data, Home/End jump to the endpoints, and Enter/Space activates the current datum. Its
  localized `aria-roledescription` identifies the application as a chart; the generated table is
  the non-canvas alternative.
- generated `scales` are keyed off the *effective* type (`config.type` ?? `type`, see
  `effectiveType()`) and are type-appropriate: no scale at all for `type="pie"`/`"doughnut"` (true of
  `<lr-chart type="pie">` directly, not just the `lr-pie-chart`/`lr-doughnut-chart` subclasses),
  and a single radial `r` scale (respecting `beginAtZero`) for `type="radar"`/`"polarArea"`
  (`lr-radar-chart`/`lr-polar-area-chart`), instead of always generating the cartesian `x`/`y`/
  `y2` block. `xLabel`/`yLabel`/`y2Label` are still silently inert for all four of those types (a
  radial scale and "no scale" both have nowhere to put an axis title) — reach a titled radial scale
  only via raw `config`.
- `annotations` loads and registers `chartjs-plugin-annotation` on first actual demand; consumers
  do not hand-register it or route ordinary reference lines/bands through raw `config`. A chart
  with no usable annotations never requests the peer. Raw `config` remains the escape hatch for
  plugin-specific annotation options outside the declarative surface.
- while the `chart.js` peer is resolving, `render()` swaps in a `<lr-skeleton shape="rect">` for
  the canvas, and the **host element itself** (not the skeleton) carries `aria-busy="true"` — set/
  cleared in `updated()` off the private `loading` state (same lazy-load pattern as
  `lr-graph`/`lr-map`/`lr-flag`). Chart.js's own ~1000ms draw-in animation only ever fires on
  initial construction or a type change that rebuilds the `Chart` instance (every in-place data
  update already passes `'none'` to `Chart#update()` and never animates regardless); that
  construction-time animation is additionally skipped when `without-animation` is set or under
  `prefers-reduced-motion: reduce`.
  The raw `config` passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped
  unconditionally, so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and
  pollute `Object.prototype` through the merge.
- lazy-redraw + change gating: with `IntersectionObserver` available, canvas construction waits
  for its first delivered visibility decision. Off-screen charts remain unconstructed until
  visible, and later off-screen property changes skip redraws until visibility returns. Without
  the observer, drawing starts when the peer and canvas are ready. An empty delivered callback
  retains the visible fallback. Peer loading and accessible DOM may settle while visibility is
  pending. Independently, `updated()` only reaches
  Chart.js when at least one of `type`, `labels`, `datasets`, `description`, `grid`, `axes`, `compact`, `indexAxis`,
  `label`, `hiddenDatasets`, `legendPosition`, `min`, `max`, `plugins`, the internal resolved auto legend
  position, `valueFormatter`, `formatter`, `tooltipTitleFormatter`, `tooltipFooterFormatter`, `area`,
  `height`, `xLabel`, `yLabel`, `y2Label`, `beginAtZero`,
  `stacked`, `stackedAxes`, any `without*` control, `dataLabels`, `stackTotals`, `config`, the parsed
  slotted config, `zoom`, `locale`, `strings`, or the internal loading state actually changed in
  that update (so an
  unrelated property/state update, or a bare `requestUpdate()`, draws nothing). Resize callbacks
  ignore unchanged inline sizes and coalesce into one animation-frame task; a responsive legend
  position change and its reactive update share that same single redraw. `refreshTheme()`, resize,
  optional-plugin completion, and histogram data changes all use the same connected/visible gate.
  A theme refresh may still rerender the cheap DOM legend while off-screen so its computed swatches
  do not go stale, but it does not repaint the canvas there.
- Chart.js receives `effectiveLocale`; generated summary values use the same locale. Cartesian y/y2
  axes swap logical sides under RTL, and host `aria-label` is forwarded to the canvas and data-table
  caption.

---
