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

# `lr-heatmap`

- **Import** `import '@aceshooting/lyra-ui/components/lr-heatmap.js';` (stable tag alias; registers the tag)
- **Class** `LyraHeatmap`, also available unregistered from `@aceshooting/lyra-ui/components/data/heatmap/heatmap.class.js`
- **Family** `components/data/` — 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** none
- **Themeable via** 18 parts, 12 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-heatmap`

A Canvas-rendered heatmap with a DPR-aware, resize-aware redraw loop. One discriminated `data`
property selects `{ kind: "matrix", rowLabels, colLabels, values }` (the default) or
`{ kind: "calendar", days, ...calendarOptions }` (a GitHub-style weekday × week grid). Every cell
is independently addressable despite being
canvas-drawn (no per-cell DOM node by default): a `pointermove` hit-test over the canvas shows `[part="tooltip"]`
with that cell's label + value; the canvas is a named `role="application"`, `tabindex="0"` control
with arrow-key roving focus (a stroked ring is redrawn over the focused cell on every draw, and the
cell text is appended to the document's shared light-DOM polite sink); and a click, or Enter/Space
on the focused cell, fires `lr-cell-click`. The first render is silent, repeated identical focus
movements remain separate announcements, and `[part="live-region"]` is only an `aria-hidden` mirror.
Both modes deliberately retain physical LTR grid geometry under `dir="rtl"`: matrix column 0 and
calendar week 0 remain at the physical left, so ArrowLeft and ArrowRight retain their physical
previous/next movement instead of swapping for RTL.
Full canvas redraws pause while the host is outside the viewport. Data, locale, theme, resize, and
DPR invalidations remain pending and coalesce into one redraw when the heatmap intersects again;
environments without `IntersectionObserver` retain eager drawing.

Focus-only updates repaint a bounded neighborhood around the old and new cells, restoring all
intersected neighboring fills and overlays while preserving the focus-ring geometry. Calendar axis
pixels intersected by the repaint are restored too.

Changing `domain` or `midpoint` without replacing matrix data preserves absent and nullish cells as
no data in paint, accessible text, callbacks, and cell events. Missing values still use `-1` in
default unsigned mode and `NaN` in signed mode; supplied negative signed values remain data.

Invalid authored ramp colors retain the default endpoint fallback. The public `resolveRgb()` helper
likewise preserves its supplied fallback bytes. These authoring diagnostics are once-per-color
development warnings and remain silent in production; the separate missing-canvas runtime warning is
unchanged.

Set `accessibleCells: true` (`accessible-cells`) to opt into a semantic grid backed by a bounded
window of native buttons. It retains the complete `aria-rowcount`/`aria-colcount` and arrow-key
navigation model without mounting one node per cell. Buttons use localized `aria-label`s, explicit
`aria-selected="true"|"false"` from the controlled `selectedCell`, and roving tabindex; the grid
continues to emit `lr-cell-click` and leaves selection state consumer-controlled.
When matrix/calendar data refreshes while one of those buttons owns focus, the semantic matrix
coordinate or calendar date remains the sole roving stop. If it disappears, focus clamps to the
nearest surviving interactive cell, or to the stable heatmap base when none remain; an unfocused
refresh never steals external focus.

Changing `accessibleCells` also preserves owned focus across the rendering-mode replacement:
turning the overlay off moves a focused cell button to the canvas application control; turning it on
moves a focused canvas to the matching remembered cell, the first interactive cell, or the stable
base when no cell exists. A newer external focus destination is never reclaimed.

**Properties:**

- `data: HeatmapData = { kind: 'matrix', rowLabels: [], colLabels: [], values: [] }` (attribute:
  false), where `HeatmapData` is the readonly discriminated union:
  - `HeatmapMatrixData { kind: 'matrix'; rowLabels: readonly string[]; colLabels: readonly
string[]; values: readonly (readonly number[])[] }`
  - `HeatmapCalendarData { kind: 'calendar'; days: readonly CalendarDay[]; firstDayOfWeek?:
number; columnX?: (index:number)=>number; rowY?: (weekday:number)=>number;
weekdayLabelWidth?: number|'auto'; weekdayLabelText?: (jsWeekday:number)=>string|undefined; monthLabelText?:
(jsMonth:number,year:number)=>string|undefined }`
    Matrix `-1` or non-finite values are no-data. Calendar identity is ISO date; invalid dates are
    omitted and duplicates use one deterministic **first-valid-wins** entry before count, scale,
    paint, selection, focus and event paths.
    Collections are snapshotted into a bounded canonical projection; reassign `data` after changing
    caller-owned input.
    `weekdayLabelWidth` controls the calendar weekday-label gutter: a nonnegative CSS-pixel number
    pins its width, while `'auto'` measures the widest rendered weekday label without shrinking
    below the built-in `28px` gutter or growing beyond 40% of the host. Unset preserves the original
    `28px` geometry; malformed values are ignored.
- `cellSize: number = 22` (attribute `cell-size` — default `22` in matrix mode, `11` in calendar
  mode when left unset; explicitly setting it now governs both modes' per-cell size alike, and it's
  ignored in either mode when `fitToWidth` is set)
- `cellGapX: number = 1` / `cellGapY: number = 1` (attributes `cell-gap-x` / `cell-gap-y`) —
  matrix-only trailing horizontal/vertical gaps in CSS pixels, subtracted from the square
  `cellSize` pitch. Negative values clamp to zero, non-finite values use `1`, and oversized gaps
  leave at least one painted pixel. Custom geometry with `accessibleCells` grows the minimum pitch
  and caps gaps to preserve the `--lr-icon-button-size` target floor. Calendar spacing is unchanged.
- `cellRadius: number = 0` (attribute `cell-radius`) — matrix-only painted corner radius in CSS
  pixels, clamped from zero to half the smaller painted side; non-finite values use zero. Custom
  cell fills, focus/selection/annotation rings, semantic overlays, and PNG canvas output share the
  same bounds. Custom gaps are excluded from canvas pointer hits; rounded cells retain rectangular
  interaction bounds. The default one-pixel separator retains its existing hit area.
- `colLabelInterval: number = 1` (attribute `col-label-interval`) — paint every Nth matrix column
  label starting at column zero, including frozen labels and rotated label measurement. Truncated
  to an integer of at least one; non-finite values use `1`. Every original `data.colLabels` value
  remains available to tooltips, keyboard announcements, semantic cells, and `cellText`.
- `fitToWidth: boolean = false` (attribute `fit-to-width` — derives `cellSize` from the host's
  measured `clientWidth` on every draw/resize instead of the fixed `cell-size`, so the grid actually
  fills the available width; now applies to calendar mode as well as matrix mode — see gotchas for
  the default, non-`fit-to-width` behavior)
- `rowLabelWidth?: number | 'auto'` (attribute `row-label-width`, reflected) — width, in CSS px, of
  the **matrix** row-label gutter, or `'auto'` to measure the widest label and size the gutter to
  fit (never below the built-in `60`, never above 40% of the host width, so one long label cannot
  squeeze out the cells it describes). Unset keeps the built-in `60`, so no existing chart reflows.
  Independently of this, a label too wide for the resolved gutter is truncated with an ellipsis
  rather than clipped mid-glyph — clipping read as a rendering fault, truncation reads as "there is
  more here". `cellText` still carries the full label to the tooltip and the keyboard announcement.
  A malformed value is ignored rather than collapsing the gutter. Calendar mode is unaffected; it
  has its own fixed weekday gutter
- `colLabelHeight?: number | 'auto'` (attribute `col-label-height`, not reflected, `'auto'` new in
  11.0.0) — height, in CSS px, of the matrix column-label band, or `'auto'` to measure the labels
  and size the band to fit them. Under a non-zero `colLabelRotation` the measurement projects each
  label's width through the rotation, which is what makes a rotated axis usable without hand-tuning
  a magic number. Never below the built-in `20`, and bounded above by a sanity ceiling so a
  pathological label cannot produce an absurd canvas. Note this deliberately does **not** use
  `rowLabelWidth`'s "40% of the host" rule: the row gutter steals width from the cells and so must
  be bounded relative to them, whereas the canvas simply grows taller for this band and the cells
  keep their size. Unset keeps the built-in `20`, so no existing chart reflows. A malformed value is
  ignored
- `colLabelRotation?: number` (attribute `col-label-rotation`, new in 11.0.0) — rotation, in
  degrees, applied to matrix column labels. Unset or `0` paints them horizontally exactly as before.
  In a dense matrix the per-column width is far narrower than a typical label, so horizontal labels
  collide with their neighbours; `45` or `90` is the standard remedy. Each label rotates about an
  anchor at its own column's centre with the label's *end* at that anchor, so it leans back over the
  columns to its left and the last column's label cannot overflow the canvas. Values outside
  `[0, 90]` clamp into that range and non-finite values normalize to `0`. Pair with
  `colLabelHeight="auto"` to have the band size itself to the rotated extent. **Not mirrored under
  `dir="rtl"`** — both grid modes deliberately retain physical LTR geometry, so leaning one axis'
  labels the other way would be incoherent
- `stickyLabels: 'none' | 'rows' | 'cols' | 'both' = 'none'` (attribute `sticky-labels`, reflected)
  — freezes a **matrix** label band against the grid's own scrolling. `'rows'` pins the row-label
  gutter so it survives horizontal scrolling, `'cols'` pins the column-label band so it survives
  vertical scrolling, `'both'` pins both. Labels and cells otherwise share one bitmap, so neither
  band can be `position: sticky` on its own and a tall matrix scrolls its column header out of
  view; the only workaround was a light-DOM mirror row that had to hardcode the gutter width and
  cell size, which made it mutually exclusive with `row-label-width="auto"`. A frozen band is
  repainted into its own layer in the same draw pass, from the same resolved `matrixGeometry` the
  cells were painted with, so it tracks a `row-label-width`/`col-label-height` `"auto"`
  re-resolution, a resize, and a DPR change without drifting a pixel. Freezing needs something to
  scroll, so the frozen modes wrap the grid in a `[part="grid"]` scrollport: it is bounded inline by
  the host's own allocation — a matrix wider than a 320px host scrolls inside the component instead
  of overflowing it — and unbounded in block until you set `--lr-heatmap-grid-max-block-size`, which
  a frozen column band needs in order to have vertical scrolling to stay behind. The bands are
  `aria-hidden` duplicates of pixels the canvas already painted, so the accessible representation is
  unchanged, and the `accessibleCells` overlay moves inside the scrollport so the cell buttons
  scroll with the canvas they cover. The grid keeps this component's physical LTR geometry under
  `dir="rtl"`, so the frozen gutter stays on the same physical side as the labels the canvas paints.
  Matrix mode only, like `matrixGeometry`: the property is read in calendar mode but has no effect
  there, since a calendar's axes are a different geometry (fixed weekday gutter, month band, and the
  optional `columnX`/`rowY` overrides). Unset (`'none'`, the default) renders exactly what it always
  did — one canvas, no scrollport, no extra elements — and an unsupported value normalizes to it.
  The union is re-exported from the package root as `LyraHeatmapStickyLabels`, so a typed consumer
  can annotate a variable or a framework prop with it. Everything positioned in canvas coordinates
  moves into the scrollport with the cells: `[part="tooltip"]` renders inside it, so it stays on
  the cell it describes through a scroll instead of drifting by the scroll offset, and because
  `overflow: auto` clips whatever leaves the scrollport, the tooltip is kept inside the visible
  window too — clamped along the inline axis and flipped to below its cell when a frozen band
  leaves no room above it. Arrow-key navigation scrolls the focused cell into that window, clear of
  the frozen bands, which the frozen modes need in their own right: the canvas is the roving tab
  stop, its focus ring is painted into the bitmap rather than carried by a focusable element the
  browser would scroll to, and it calls `preventDefault()` on the arrows
- `maxCellSize?: number` (attribute `max-cell-size`) — ceiling, in CSS px, on the cell size
  `fitToWidth` derives from the host width, in **both** modes. Exists because `fitToWidth` divides
  the _whole_ host width across the grid, so a 5-week calendar or a 3-column matrix in a wide pane
  produces enormous blocks; capping them keeps a cell a cell
- `minCellSize?: number` (attribute `min-cell-size`) — the mirror floor, in CSS px, so a year-long
  calendar in a narrow pane keeps legible, hit-testable cells and overflows its host instead of
  collapsing to hairlines. It can only _raise_ the built-in `4`px floor, never lower it: a value
  below `4` normalizes to `4`. When both clamps are set and `maxCellSize < minCellSize`, the ceiling
  wins. For both: a non-finite value, or an empty attribute, means unset rather than `0`, and unset
  (the default) reproduces the unclamped fit-to-width behavior exactly
- `valueLabel?: string` (attribute `value-label`) — absence uses the localized default. Every
  supplied string is literal, including `"value"` and `""`; unset the property/attribute to resume
  localization.
- `withoutLegend: boolean = false` (attribute `without-legend`, reflected) — hides the colour
  legend, under the same name and the same polarity `lr-chart` has always used rather than a third
  spelling for one idea. The whole row leaves the DOM — the gradient bar or `legendStops` swatches,
  the `legend-lo`/`legend-hi` endpoint labels, the `valueLabel` caption, the labelled `annotations`
  entries and the `legend` slot go with it — so it contributes no layout box and assigns no slotted
  content, rather than being painted and then hidden. The legend's own preparation stops with it:
  `--lr-heatmap-color-steps-gradient`, which this component writes onto the host for the legend bar
  and for nothing else, is not written while the legend is hidden, and is removed again if it had
  been. Cells, tooltips, keyboard interaction, selection and the generated accessible summary are
  unaffected — the summary already names the value label independently of the legend.
- `scale: 'linear' | 'sqrt' = 'linear'` — governs both modes: in matrix mode, `'sqrt'` compresses the
  color ramp via `sqrtStep()` instead of mapping linearly; in calendar mode, the default `'linear'`
  still buckets by quartile (`quartileBucket()`, unchanged), while `'sqrt'` instead compresses via the
  same `sqrtStep()` magnitude compression as matrix mode, so one heavy day doesn't wash out the rest
- `domain?: [number, number]` (attribute: false) — pins the color ramp's input domain instead of
  deriving it from the data's own extremes, so two heatmaps of comparable data can share a scale
  rather than each normalizing to its own min/max. A reversed pair is normalized; a degenerate or
  non-finite one falls back to the derived range
- `midpoint?: number` — anchors a diverging ramp's neutral color on this value rather than at the
  middle of the domain, scaling the two halves independently (`lo`→0, `midpoint`→0.5, `hi`→1). A
  midpoint outside the resolved domain degrades to plain normalization rather than distorting the
  ramp
- **Signed data.** Setting either `domain` or `midpoint` opts the component into signed data, where
  only a **non-finite** value is no-data. With neither set (the default), a negative value is
  no-data — matching the long-documented `-1` sentinel, since a matrix of counts has no meaningful
  negative. Declaring a domain or midpoint is what disambiguates "a real negative" from "the
  sentinel", so signed datasets render their negative half instead of dropping it. In signed mode a
  structurally absent matrix cell reads as `NaN` so it stays no-data while a real `-1` beside it
  renders on the ramp; in default mode an absent cell still resolves to `-1`, keeping `valueAt()`
  and the `lr-cell-click` payload unchanged. `scale="sqrt"` rejects negatives in both modes — a
  square root of a negative has no meaning
- `bucketCount: number = 5` (attribute `bucket-count` — calendar mode only; non-finite values fall
  back to 5, while finite values are floored and clamped to 2–256 before the color-ramp allocation)
- `annotations: readonly HeatmapAnnotation[] = []` (attribute: false) — `HeatmapAnnotation { row?: number;
col?: number; date?: string; label?: string }`: matrix mode matches by `row`/`col`, calendar mode
  by `date` (whichever pair matches the active `mode`; the other fields are ignored). Draws a
  stroked ring over the matching cell; an annotation with a `label` also gets its own
  `[part="legend-annotation"]` entry in the legend. The collection is clone-owned, bounded, and
  frozen; reassign a new array after changes.
- `selectedCell: HeatmapSelectedCell | null = null` (attribute: false) — `HeatmapSelectedCell {
row?: number; col?: number; date?: string }`, matched the same way as `annotations`. Draws a
  persistent ring (independent of keyboard focus) over the matching cell, appends a "Selected: ..."
  description to the host's own `aria-label`, and adds localized selected wording to the keyboard
  announcement when the focused cell is the selection. Purely a controlled property — mirrors
  `<lr-lite-chart>`'s `selectedIndices`, this component never mutates it itself. Unset (the default,
  `null`) reproduces today's exact output.
- `multiple: boolean = false` — opts into controlled multi-cell selection through `selectedCells`.
  The existing `selectedCell` is ignored while enabled and resumes when disabled. Single-cell
  behavior is unchanged when omitted.
- `selectedCells: readonly HeatmapSelectedCell[] = []` (attribute: false) — clone-owned selection
  input, inspected up to `MAX_HEATMAP_CELLS` (10,000) entries. Duplicates, non-integer/out-of-grid
  coordinates and non-interactive cells are ignored. Matrix records use `{ row, col }`; calendar
  records use `{ date }`, including interactive calendar gaps. The component never assigns this
  property in response to user input. It draws selected rings and, with `accessibleCells`, exposes
  per-cell `aria-selected` plus grid `aria-multiselectable="true"`. The localized accessible summary
  reports the selected count through `heatmapSelectedCount`.

  `lr-selection-change` is a non-cancelable proposal: frozen `HeatmapSelectionChangeDetail {
  selectedCells: readonly Readonly<HeatmapSelectedCell>[]; source: HeatmapSelectionSource }`, with
  source `'pointer' | 'keyboard' | 'row' | 'column'`. Assign the event's array back to `selectedCells`
  to accept it; property assignments are silent. Output is deduplicated in row/column order. Click
  or Enter/Space toggles one cell and retains `lr-cell-click`. Pointer dragging paints or erases
  according to the starting cell, previews transient selection, and proposes once on release;
  pointer cancellation, Escape, disconnect, data/mode changes discard the gesture. A drag does
  not emit a cell click. Shift+arrows extends/contracts a rectangle from the anchor while retaining
  unrelated pre-range selection. Directions remain physical under RTL, matching the canvas.

  `toggleRowSelection(row: number): void` and `toggleColumnSelection(col: number): void` propose
  whole-axis toggles for application-owned controls: remove an entirely selected axis, otherwise
  add its interactive cells. Shift+Space and Ctrl/Meta+Space invoke those actions for the focused
  row/column. Calendar rows are weekday rows 0–6 and columns are zero-based weeks. Invalid indices
  and calls outside `multiple` mode are no-ops. For a narrow day/hour matrix, use `accessibleCells`
  and `stickyLabels="both"` to retain usable targets within the component's own scrollport.

  ```js
  import '@aceshooting/lyra-ui/components/lr-heatmap.js';
  const heatmap = document.querySelector('lr-heatmap');
  heatmap.multiple = true;
  heatmap.accessibleCells = true;
  heatmap.addEventListener('lr-selection-change', event => {
    heatmap.selectedCells = event.detail.selectedCells;
    // Derive filter state from the accepted row/column coordinates.
  });
  ```
- `accessibleCells: boolean = false` (attribute `accessible-cells`) — renders `[part="cells"]` with
  at most 400 `[part="cell"]` native buttons around the active cell. The semantic grid exposes the
  full row/column counts, buttons expose localized `aria-label`s and explicit `aria-selected`, and
  roving arrow navigation still reaches every canonical cell. The canvas remains the visual and
  pointer surface but is hidden from the accessibility tree. Controlled refresh focus follows the
  preservation/clamping behavior above.
- `cellText?: (pos: MatrixCellPos | CalendarCellPos, value: number) => string` (attribute: false) —
  formats the per-cell hover tooltip and keyboard announcement text; receives the cell
  position (`MatrixCellPos { row, col }` in matrix mode, `CalendarCellPos { week, weekday, date }` in
  calendar mode) and its value. `CalendarCellPos.date` is a **required** ISO `yyyy-mm-dd` string,
  present for every grid position — including a sparse gap position with no matching entry in `days`
  at all, which still sits on a real calendar day (that case simply reports the `-1` "no data" value
  alongside it). It lets a callback key off the date without re-deriving the grid's own
  anchor-week arithmetic; `MatrixCellPos` is unchanged, and so is `lr-cell-click`'s detail.
  Unset (the default) falls back to localized matrix row/column/value or calendar date/value
  templates. The default English catalog renders "Row X, Col Y: value" (matrix) / "Jan 15: value"
  — short month + day, **not** a weekday abbreviation (calendar). Matrix row/column placeholders,
  no-data wording, template order, calendar wording/date formatting, and numeric values all follow
  `locale` plus `registerLyraLocale()`/`.strings`; use `cellText` for application-specific wording.
- `cellInteractive?: (pos: MatrixCellPos | CalendarCellPos, value: number) => boolean` (attribute:
  false) — opts individual cells out of the interaction model; receives the cell position and its
  value, return `false` to make that cell present-but-non-interactive (no hover tooltip, click, or
  keyboard roving-focus stop) without losing the layout/color-ramp machinery. Unset (the default)
  keeps every cell interactive, unchanged.
- `data.columnX?: (index: number) => number` (calendar branch only) — overrides the
  internal week-column x-coordinate formula (`CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP)`) used
  consistently across drawing, hit-testing, the focus ring, and month-label positioning, so a
  consumer can pixel-align this calendar's week columns with a sibling `<lr-lite-chart>`'s bars
  (see that component's own `barX`) by supplying the same coordinate function to both. Unset (the
  default) is the original formula, unchanged.
- `data.rowY?: (weekday: number) => number` (calendar branch only) — the vertical
  analogue of `columnX`: overrides the internal weekday-row y-coordinate formula (`CAL_LABEL_H +
weekday * (cellSize + CAL_GAP)`), consulted consistently by drawing, hit-testing, and the focus
  ring (also consulted at `weekday = 7` to size the canvas height, mirroring `columnX` at
  `week = weekCount`). Unset (the default) is the original formula, unchanged. Ignored in matrix
  mode.
- `cellColor?: (pos: MatrixCellPos | CalendarCellPos, value: number) => string | undefined`
  (attribute: false) — overrides a cell's computed ramp/no-data color entirely for an exact value;
  return a CSS color string to force that cell to it, or `undefined` to fall back to the normal ramp
  math unchanged. Lets a consumer designate a value as categorically outside the ramp (e.g. a real
  zero-count day rendered as a neutral hairline, distinct from both "no data" and the ramp's own
  lightest step) without a synthetic ramp color, which can't safely reserve an exact value on a
  skewed dataset. Unset (the default) reproduces the exact ramp/no-data behavior for every cell.
- `data.weekdayLabelText?: (jsWeekday: number) => string | undefined` (calendar branch only) —
  overrides the weekday-axis label text; receives the real JS weekday index (`0` Sunday ..
  `6` Saturday) for a row that would otherwise render a label and, when it returns a string, uses it
  instead of the built-in `Intl.DateTimeFormat`-derived short weekday name. Unset (the default)
  reproduces today's exact locale-derived output.
- `data.monthLabelText?: (jsMonth: number, year: number) => string | undefined` (calendar branch
  only) — the month-axis analogue of `weekdayLabelText`: receives the real JS month
  index (`0` January .. `11` December) and full year for a month boundary that would otherwise
  render a label, and, when it returns a string, uses it instead of the built-in
  `Intl.DateTimeFormat`-derived short month name. Unset (the default) reproduces today's exact
  locale-derived output. Lets month labels track the same locale signal (e.g. an app's own i18n
  store) as `weekdayLabelText` and the component's other localizable strings, instead of always
  following the browser/OS-language default.
- `colorSteps?: readonly string[]` (attribute: false) — a clone-owned, bounded, frozen discrete array (≥2 entries) of CSS colors used as
  exact ramp steps instead of linearly interpolating between `--lr-heatmap-scale-lo`/`-hi`;
  governs both `mode`s and both `scale` values, discretizing whichever scale would otherwise
  interpolate continuously into `colorSteps.length` buckets instead. Unset (the default, or fewer
  than 2 entries) keeps today's 2-endpoint interpolation exactly. Invalid colors use the canvas
  fallback color and prevent the custom legend gradient from being assigned.
- `legendStops?: readonly HeatmapLegendStop[]` (attribute: false) — clone-owned, bounded, frozen `HeatmapLegendStop { value: number;
color?: string; label?: string; partOfRamp?: boolean }`: a discrete legend key rendered **instead of** the
  `--lr-heatmap-scale-lo`/`-hi` gradient bar and its `[part="legend-lo"]`/`[part="legend-hi"]`
  endpoint labels — one `[part="legend-stop"]` per entry, in array order, each a
  `[part="legend-swatch"]` filled with that entry's `color` plus a `[part="legend-stop-label"]`.
  `color` is optional: omit it, pass an empty string, or pass an invalid CSS color for a
  **caption-only** stop, which renders its `[part="legend-stop-label"]` alone with no
  `[part="legend-swatch"]` element in the DOM at all — so a leading "0" or trailing "more" caption
  around a run of colored stops doesn't leave an empty swatch box in the row.
  A stop's label defaults to the component's own locale-aware numeric formatting of `value`, so an
  explicit `label` is only needed when the number isn't the right caption ("none", "≥ 90%"). Exists
  for the consumer who supplies `cellColor`: because that callback overrides a cell's color
  entirely, the built-in two-endpoint bar can end up describing a ramp the grid no longer uses —
  supplying the same colors here keeps the legend honest instead of hiding `::part(legend)` and
  re-implementing swatches, labels and annotation entries by hand. Strictly presentation: the stops
  are never consulted by the color ramp, the bucket math, the tooltip, or the generated accessible
  name, so adding them changes nothing a cell renders. Labeled `annotations` still render their
  `[part="legend-annotation"]` entries after the stops. Unset (the default) or an empty array
  reproduces the exact gradient legend, unchanged. Reassigning stops whose `value`/`color`/`label`/
  `partOfRamp` fields are unchanged does not schedule a canvas redraw. The assignment still takes
  a fresh frozen ownership snapshot, so mutating and reassigning the same caller array is detected
  while mutation without reassignment remains isolated. A dev-mode-only warning fires when a stop's
  `color` doesn't match the corresponding `colorSteps` entry — set `partOfRamp: false` on a stop
  that is a real, distinctly-colored swatch deliberately outside the sequential ramp (e.g. a fixed
  "no data" color next to an N-step ramp) to exclude just that stop from the comparison; defaults to
  `true`. A caption-only stop (no `color`) is already excluded regardless of this flag.

**Getters/methods:** `exportData('png')` returns a PNG data URL for the most recently completed
canvas paint. It includes the painted axes, cells, focus/selection/annotation overlays, and frozen
label bands while `stickyLabels` is enabled; the DOM legend, tooltip, and accessible-cell buttons
are deliberately excluded. It returns `''` before a completed paint, while rendering is deferred or
zero-sized, after disconnect, or when the browser cannot encode the snapshot. `refreshTheme()` —
redraws canvas content after an upstream design-token or color-scheme change; called automatically
on theme changes, exposed for a consumer that needs to force a redraw manually. `matrixGeometry: Readonly<LyraHeatmapMatrixGeometryChangeDetail> | undefined` — the gutter/cell geometry the last matrix-mode draw actually painted with,
in CSS pixels; `undefined` in calendar mode. Lets a light-DOM consumer line up with the canvas
without hardcoding the same numbers `row-label-width`/`col-label-height`'s `"auto"` resolution would
otherwise keep private. For the case that motivated it — a frozen header or gutter on a tall or wide
matrix — prefer `stickyLabels`, which freezes the band inside the component and needs no mirror at
all; the getter remains the way to align a *separate* element (a sibling chart, a custom overlay)
with the grid. `LyraHeatmapMatrixGeometryChangeDetail` contains `padLeft`, `padTop`, and `cellSize`
(the square pitch), plus optional `cellWidth`, `cellHeight`, and `cellRadius` for custom matrix
presentation. When those optional fields are absent, painted width/height are `cellSize - 1` and
radius is zero. The getter and geometry-change event always share one frozen snapshot, including
changes to the resolved gaps/radius.

For a fluid day/hour matrix, keep all 24 hour strings in `data.colLabels` and use
`fit-to-width cell-gap-x="1" cell-gap-y="2" cell-radius="2" col-label-interval="3"`.
For a single fluid square cell, use `fit-to-width` with equal horizontal and vertical gaps.

**Events:** `lr-selection-change` (not cancelable; frozen readonly `HeatmapSelectionChangeDetail { selectedCells, source }` proposal in multiple mode, with `source: 'pointer' | 'keyboard' | 'row' | 'column'`; accept it by assigning `selectedCells`). `lr-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
`detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode),
`lr-matrix-geometry-change` (fired after a matrix-mode draw whose resolved `matrixGeometry` differs
from the previous draw — e.g. after `row-label-width="auto"`/`col-label-height="auto"` resolves
against new content or a resize; `detail` is the same object `matrixGeometry` returns; never fired
in calendar mode)

**Slots:** `legend` — custom content rendered inside the built-in `[part="legend"]` row, after the
gradient/`legendStops` swatches, the trailing `valueLabel` caption, and any labeled `annotations`
entries. Presentation-only, like `legendStops`: slotted content is never consulted by the color
ramp, the bucket math, the tooltip, or the generated accessible name. Nothing is rendered, and the
slot itself is absent from the shadow root, while `withoutLegend` is set. Available since 13.0.0;
this doc previously said "Slots: none" for several releases after that, which was a documentation
gap rather than a behavior change — the slot has worked, unannounced, the whole time.

**CSS parts:** `base`, `canvas`, `grid` (the scrollport wrapping the canvas while `stickyLabels`
freezes an axis — absent entirely otherwise), `row-labels`/`col-labels` (the frozen label bands,
rendered only for the axis `stickyLabels` names), `cells` (opt-in per-cell overlay), `cell` (one opt-in native cell
button), `tooltip` (hover tooltip, positioned over the hovered cell — inside `[part="grid"]` while
`stickyLabels` freezes an axis, so it scrolls with the cells, and a `[part="base"]` child
otherwise),
`live-region` (visually-hidden, `aria-hidden` mirror of the keyboard-focused cell; the actual
announcement uses the shared light-DOM polite sink), `projection-limit` (localized assistive
disclosure when bounded canonicalization truncates input), `legend`, `legend-lo`, `legend-hi` (both omitted, along with the gradient
bar between them, while `legendStops` is supplied), `legend-stop` (one per `legendStops` entry),
`legend-swatch` (that stop's color chip, not rendered at all for a caption-only stop),
`legend-stop-label` (that stop's text), `legend-value-label` (the trailing `valueLabel` caption that
closes the legend row, present in both the gradient and the `legendStops` branch),
`legend-annotation` (one per labeled `annotations` entry)

**Themeable custom properties:** `--lr-heatmap-scale-lo` (default `var(--lr-color-brand-quiet)`),
`--lr-heatmap-scale-hi` (default `var(--lr-color-brand)`) — the sequential color-ramp endpoints
(matrix mode) or quartile-bucket ramp endpoints (calendar mode), resolved via `getComputedStyle` each
draw (any valid CSS color syntax — hex/rgb/hsl/oklch/named — works, resolved through a scratch
canvas). Private defaults follow the theme (including dark mode), while inherited or direct public
values remain authoritative; the hard-coded `#cde2fb`/`#0969da` pair in the source is
only a last-resort constant for the case where the custom property resolves to an empty string (no
stylesheet applied at all), not the shipped default.
`--lr-heatmap-no-data-fill` (default `var(--lr-color-no-data)` — the no-data cell fill, same
resolve-via-`getComputedStyle` pattern), `--lr-heatmap-label-font` (default
`var(--lr-size-10px) var(--lr-font)` — the canvas-drawn axis/month/weekday label font),
`--lr-heatmap-focus-ring-color` (default
`var(--lr-focus-ring-color)` — the canvas-drawn ring stroked around the keyboard-focused cell;
also reused by `[part="canvas"]`'s own `:focus-visible` outline so the two stay visually in sync),
`--lr-heatmap-color-steps-gradient` (default
`linear-gradient(to right, var(--lr-heatmap-scale-lo), var(--lr-heatmap-scale-hi))` — the gradient
painted on the continuous legend bar; the component writes it onto the host itself while
`colorSteps` is supplied and the legend is rendered, and removes it again when either stops being
true — `withoutLegend` takes the legend row, this property's only reader, out of the DOM — so it is
a read-out rather than a knob you set). `--lr-heatmap-annotation-color` (default `var(--lr-color-danger)` — the canvas-drawn ring
stroked around an annotated cell, deliberately not one of the sequential ramp colors so it stays
visible regardless of what it's drawn over). `--lr-heatmap-selected-color` (default
`var(--lr-color-success)` — the canvas-drawn ring stroked around the persistent `selectedCell`, a
dedicated token distinct from both the focus ring and the annotation ring so a host can retheme it
independently). `--lr-heatmap-tooltip-bg` (default
`var(--lr-color-surface)`) and `--lr-heatmap-tooltip-text` (default `var(--lr-color-text)`) —
unlike the canvas-drawn tokens above, `[part="tooltip"]` is a real DOM element and consumes these
directly, no `getComputedStyle` bridging needed. `--lr-heatmap-sticky-label-bg` (default
`var(--lr-color-surface)` — the backdrop painted under a frozen `stickyLabels` band, resolved via
`getComputedStyle` like the other canvas-drawn tokens; it must stay **opaque**, since it covers the
same labels the scrolling canvas painted underneath it) and `--lr-heatmap-grid-max-block-size`
(default `none` — the block-size ceiling of the `[part="grid"]` scrollport, consumed directly by
that real DOM element; set it to give a frozen column band vertical scrolling to stay behind). Also consumes `--lr-color-text-quiet` (axis label
color), `--lr-space-xs`, `--lr-radius`/`--lr-shadow` (tooltip box), and `--lr-focus-ring-width`/
`--lr-focus-ring-offset` (the real `[part="canvas"]:focus-visible` DOM outline, stroked in the
same color as `--lr-heatmap-focus-ring-color`).

**Optional peer deps:** none.

```html
<lr-heatmap value-label="requests"></lr-heatmap>
<script>
  const hm = document.querySelector("lr-heatmap");
  hm.data = {
    kind: "matrix",
    rowLabels: ["Mon", "Tue", "Wed"],
    colLabels: ["00h", "06h", "12h", "18h"],
    values: [
      [3, 8, 12, 4],
      [1, 2, 9, 5],
      [0, 4, 6, 2],
    ],
  };
</script>
```

```html
<!-- Calendar mode: a GitHub-contributions-style day grid -->
<lr-heatmap value-label="commits"></lr-heatmap>
<script>
  document.querySelector("lr-heatmap").data = {
    kind: "calendar",
    days: [
      { date: "2026-01-01", value: 3 },
      { date: "2026-01-02", value: 0 },
    ],
    firstDayOfWeek: 1,
  };
</script>
```

**9.0 migration:** replace the independent `mode`, `rowLabels`, `colLabels`, `values`, `days`,
`firstDayOfWeek`, `columnX`, `rowY`, `weekdayLabelText`, and `monthLabelText` members with one
`data` assignment as shown above. There are no runtime aliases: this removes stale cross-mode state
and gives every scale/count/paint/selection/event path the same bounded projection. Replace the old
magic `value-label="value"` localization sentinel by removing the attribute; a supplied `"value"`
is now literal.

**Known gotchas:**

- Matrix projection is capped at 10,000 renderable cells. Calendar input is capped at 10,000
  records and 530 weeks, and the color-step/legend-stop/annotation projections are each capped at
  256 entries. `[part="projection-limit"]` and the generated accessible summary disclose every
  truncation. The semantic grid mounts at most 400 cell buttons while preserving full navigation.
- Calendar duplicates use a first-valid-wins ISO-date identity. Invalid dates are dropped. `data.columnX`
  and `data.rowY` results must be finite, nonnegative, monotonic and non-overlapping; an invalid,
  throwing or hostile result safely falls back to the normal coordinate for that position.
- Annotation, controlled selection and keyboard focus use independent concentric canvas rings;
  selection/focus also use distinct dash patterns so forced-color rendering does not collapse the
  three public states into one overwritten outline.
- The legend is a wrapping flex row. Long unbroken stop labels, the trailing `valueLabel`, and
  annotation labels wrap within the host instead of forcing the heatmap wider than its allocation.
- `legendStops` _replaces_ the lo/hi gradient bar rather than adding to it: supplying it removes
  `[part="legend-lo"]`, `[part="legend-hi"]` and the bar from the DOM, so a stylesheet targeting
  those parts silently stops applying. It is also presentation-only — it never feeds back into the
  cell colors, so the stops and a `cellColor` callback have to be kept in agreement by the consumer
  (the point of the property is that they _can_ be, from one shared function).
- the `ResizeObserver` only actually resizes the drawn grid **when `fit-to-width` is set**, in either
  mode. Without it (the default), `draw()` sizes the canvas as `PAD_LEFT + cols * cellSize` (matrix
  mode) or `CAL_PAD_LEFT + weekCount * cellSize` (calendar mode), never from the host's measured
  width, so a container-resize redraw is a geometric no-op; the stylesheet's
  `canvas { inline-size: 100% }` is also dead code in that case, since `draw()` unconditionally sets
  an inline `canvas.style.width/height` that wins over it.
- `maxCellSize`/`minCellSize` are no-ops without `fit-to-width` — an explicit `cellSize` is an exact
  request and is never clamped. And the canvas is sized _from the clamped_ cell size, so a capped
  grid deliberately leaves the host's remaining width unfilled: the canvas simply ends early rather
  than stretching to fill. `fit-to-width` derives that cell size from the host's own measured
  `clientWidth`, so do **not** make the host itself shrink-to-fit (`display: inline-block`,
  `inline-size: fit-content`, floating it, ...) to chase the capped canvas — that makes the host's
  width a function of its own rendered content while the content's width is derived from the host's
  width, a circular sizing dependency the browser cannot resolve stably. Keep the host at its normal
  block-level, fully-sized width and position the canvas within it instead:
  `::part(canvas) { margin-inline: auto }` centers it, `::part(canvas) { margin-inline-start: auto }`
  end-aligns it. Reach for `::part(canvas)`, not `::part(base)`: `[part="base"]` is a flex column, so
  `::part(base) { align-items: center }` also centers the canvas, but shrink-wraps every other base
  child along with it — the legend row included, which loses its full-width wrap layout and
  collapses to the canvas's own (possibly capped) inline size.
- the host is `role="group"` (not `role="img"`) with a dimensions+range summary `aria-label`
  (calendar mode: a day-count + range summary instead). In default canvas mode,
  `[part="canvas"]` is itself a named `role="application"`, focusable, keyboard-operable,
  per-cell-interactive control (roving arrow-key focus, shared light-DOM announcements,
  `lr-cell-click`). `role="img"` would flatten that interactive subtree for some assistive tech.
  With `accessibleCells`, the canvas becomes `aria-hidden` and the native cell-button overlay owns
  the interactive semantics instead.
- `NaN`/non-finite cell values in matrix mode are correctly treated as no-data now (alongside `-1`),
  and repeated DPR crossings (moving the window across displays with different pixel ratios) no
  longer leak a `MediaQueryList` listener per crossing — both previously-known issues are fixed.
- calendar mode's date labels (used by the default `cellText` template and the tooltip/announcement
  text) now format via `effectiveLocale` (`toLocaleString(effectiveLocale, ...)`) instead of a hardcoded
  `'en'` — fixed. The canvas-drawn axis chrome is now locale-aware too: month labels use
  `toLocaleString(effectiveLocale, ...)` (previously hardcoded `'en'`) and weekday labels are derived via
  `Intl.DateTimeFormat(effectiveLocale, { weekday: 'short' })` (previously a literal English `['', 'Mon',
'', 'Wed', '', 'Fri', '']` array) — same sparse every-other-day spacing, just locale-correct text.
- this component's legend can show more than a label (a value caption, `legendStops` swatches,
  annotation entries, a custom slot) and can now be turned off outright with `withoutLegend`.
  `lr-sequence-strip`'s `showLegend` key is swatch+label only, the same "legend needs more than a
  label" shape `lr-context-meter`'s `legendDisplay` addresses for that component; unaddressed here,
  since fixing it belongs with whichever component actually has the gap.

---
