@aceshooting/lyra-ui 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +9 -10
  2. package/custom-elements.json +15118 -8342
  3. package/dist/components/chart/chart.d.ts +57 -1
  4. package/dist/components/chart/chart.js +202 -12
  5. package/dist/components/chart/chart.stories.d.ts +32 -0
  6. package/dist/components/chart/chart.stories.js +141 -0
  7. package/dist/components/chart/chart.styles.js +16 -0
  8. package/dist/components/chart/histogram.d.ts +2 -0
  9. package/dist/components/chart/histogram.js +18 -2
  10. package/dist/components/chart/line-chart.stories.d.ts +17 -0
  11. package/dist/components/chart/line-chart.stories.js +75 -0
  12. package/dist/components/chart/lite-chart.d.ts +83 -0
  13. package/dist/components/chart/lite-chart.js +424 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +14 -0
  15. package/dist/components/chart/lite-chart.stories.js +114 -0
  16. package/dist/components/chart/lite-chart.styles.d.ts +1 -0
  17. package/dist/components/chart/lite-chart.styles.js +83 -0
  18. package/dist/components/combobox/combobox.d.ts +9 -0
  19. package/dist/components/combobox/combobox.js +76 -13
  20. package/dist/components/combobox/combobox.stories.d.ts +18 -0
  21. package/dist/components/combobox/combobox.stories.js +75 -0
  22. package/dist/components/date-picker/calendar-core.js +13 -2
  23. package/dist/components/date-picker/date-input.d.ts +9 -1
  24. package/dist/components/date-picker/date-input.js +55 -7
  25. package/dist/components/date-picker/date-input.stories.d.ts +5 -0
  26. package/dist/components/date-picker/date-input.stories.js +36 -0
  27. package/dist/components/date-picker/date-picker.d.ts +11 -3
  28. package/dist/components/date-picker/date-picker.js +87 -37
  29. package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
  30. package/dist/components/date-picker/date-picker.stories.js +12 -0
  31. package/dist/components/date-picker/date-picker.styles.js +1 -1
  32. package/dist/components/empty/empty.d.ts +12 -1
  33. package/dist/components/empty/empty.js +20 -3
  34. package/dist/components/empty/empty.stories.d.ts +2 -0
  35. package/dist/components/empty/empty.stories.js +20 -0
  36. package/dist/components/empty/empty.styles.js +11 -0
  37. package/dist/components/export-button/csv.js +6 -2
  38. package/dist/components/export-button/export-button.d.ts +15 -1
  39. package/dist/components/export-button/export-button.js +89 -12
  40. package/dist/components/export-button/export-button.stories.d.ts +1 -0
  41. package/dist/components/export-button/export-button.stories.js +5 -0
  42. package/dist/components/export-button/export-button.styles.js +11 -3
  43. package/dist/components/file-input/accept.d.ts +8 -2
  44. package/dist/components/file-input/accept.js +9 -3
  45. package/dist/components/file-input/file-input.d.ts +5 -2
  46. package/dist/components/file-input/file-input.js +23 -8
  47. package/dist/components/file-input/file-input.stories.d.ts +2 -0
  48. package/dist/components/file-input/file-input.stories.js +9 -0
  49. package/dist/components/flag/flag.d.ts +69 -3
  50. package/dist/components/flag/flag.js +107 -30
  51. package/dist/components/flag/flag.stories.d.ts +1 -0
  52. package/dist/components/flag/flag.stories.js +14 -0
  53. package/dist/components/flag/flag.styles.js +1 -1
  54. package/dist/components/flag/language-map.d.ts +11 -0
  55. package/dist/components/flag/language-map.js +12 -1
  56. package/dist/components/gauge/gauge.d.ts +2 -0
  57. package/dist/components/gauge/gauge.js +19 -5
  58. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  59. package/dist/components/gauge/gauge.stories.js +6 -0
  60. package/dist/components/graph/graph-loader.d.ts +28 -0
  61. package/dist/components/graph/graph-loader.js +44 -0
  62. package/dist/components/graph/graph.d.ts +41 -15
  63. package/dist/components/graph/graph.js +146 -71
  64. package/dist/components/graph/graph.stories.d.ts +3 -0
  65. package/dist/components/graph/graph.stories.js +38 -0
  66. package/dist/components/graph/graph.styles.js +6 -1
  67. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  68. package/dist/components/heatmap/calendar-grid.js +27 -10
  69. package/dist/components/heatmap/heatmap.d.ts +149 -12
  70. package/dist/components/heatmap/heatmap.js +445 -28
  71. package/dist/components/heatmap/heatmap.stories.d.ts +22 -0
  72. package/dist/components/heatmap/heatmap.stories.js +155 -0
  73. package/dist/components/heatmap/heatmap.styles.js +60 -2
  74. package/dist/components/map/map.d.ts +17 -1
  75. package/dist/components/map/map.js +68 -21
  76. package/dist/components/map/map.stories.d.ts +10 -0
  77. package/dist/components/map/map.stories.js +107 -12
  78. package/dist/components/map/map.styles.js +2 -2
  79. package/dist/components/playback/playback.d.ts +6 -2
  80. package/dist/components/playback/playback.js +36 -12
  81. package/dist/components/playback/playback.stories.d.ts +2 -0
  82. package/dist/components/playback/playback.stories.js +6 -0
  83. package/dist/components/playback/playback.styles.js +10 -2
  84. package/dist/components/select/select.d.ts +125 -0
  85. package/dist/components/select/select.js +480 -0
  86. package/dist/components/select/select.stories.d.ts +21 -0
  87. package/dist/components/select/select.stories.js +91 -0
  88. package/dist/components/select/select.styles.d.ts +1 -0
  89. package/dist/components/select/select.styles.js +212 -0
  90. package/dist/components/skeleton/skeleton.js +4 -7
  91. package/dist/components/sparkline/sparkline.d.ts +1 -1
  92. package/dist/components/sparkline/sparkline.js +40 -13
  93. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  94. package/dist/components/sparkline/sparkline.stories.js +22 -0
  95. package/dist/components/split/split.d.ts +24 -3
  96. package/dist/components/split/split.js +155 -31
  97. package/dist/components/split/split.stories.d.ts +2 -0
  98. package/dist/components/split/split.stories.js +20 -0
  99. package/dist/components/stat/stat.d.ts +43 -1
  100. package/dist/components/stat/stat.js +97 -4
  101. package/dist/components/stat/stat.stories.d.ts +4 -0
  102. package/dist/components/stat/stat.stories.js +66 -1
  103. package/dist/components/stat/stat.styles.js +64 -0
  104. package/dist/components/table/table.d.ts +64 -2
  105. package/dist/components/table/table.js +213 -17
  106. package/dist/components/table/table.stories.d.ts +6 -0
  107. package/dist/components/table/table.stories.js +45 -0
  108. package/dist/components/table/table.styles.js +51 -4
  109. package/dist/components/time-range/time-range.d.ts +34 -5
  110. package/dist/components/time-range/time-range.js +217 -36
  111. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  112. package/dist/components/time-range/time-range.stories.js +13 -0
  113. package/dist/components/time-range/time-range.styles.js +44 -2
  114. package/dist/components/toast/toast-item.d.ts +14 -2
  115. package/dist/components/toast/toast-item.js +134 -17
  116. package/dist/components/toast/toast-item.styles.js +28 -2
  117. package/dist/components/toast/toast.stories.d.ts +3 -0
  118. package/dist/components/toast/toast.stories.js +41 -1
  119. package/dist/components/toast/toast.styles.js +11 -11
  120. package/dist/components/toast/toaster.d.ts +1 -1
  121. package/dist/components/toast/toaster.js +13 -6
  122. package/dist/components/tree/tree-node.d.ts +13 -7
  123. package/dist/components/tree/tree-node.js +24 -10
  124. package/dist/components/tree/tree.d.ts +64 -15
  125. package/dist/components/tree/tree.js +113 -22
  126. package/dist/components/tree/tree.stories.d.ts +4 -0
  127. package/dist/components/tree/tree.stories.js +31 -1
  128. package/dist/components/tree/update-cascade.d.ts +13 -0
  129. package/dist/components/tree/update-cascade.js +13 -0
  130. package/dist/components/widget/widget.d.ts +25 -1
  131. package/dist/components/widget/widget.js +164 -6
  132. package/dist/components/widget/widget.stories.d.ts +3 -0
  133. package/dist/components/widget/widget.stories.js +43 -0
  134. package/dist/components/widget/widget.styles.js +20 -3
  135. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  136. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  137. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  138. package/dist/components/word-cloud/word-cloud.js +255 -0
  139. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  140. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  141. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  142. package/dist/components/word-cloud/word-cloud.styles.js +55 -0
  143. package/dist/internal/a11y.js +2 -1
  144. package/dist/internal/rtl.d.ts +7 -0
  145. package/dist/internal/rtl.js +9 -0
  146. package/dist/lyra.d.ts +9 -1
  147. package/dist/lyra.js +6 -0
  148. package/llms-full.txt +962 -276
  149. package/llms.txt +12 -9
  150. package/package.json +6 -4
@@ -5,22 +5,32 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { html } from 'lit';
8
- import { property, query } from 'lit/decorators.js';
8
+ import { property, query, state } from 'lit/decorators.js';
9
+ import { styleMap } from 'lit/directives/style-map.js';
9
10
  import { LyraElement } from '../../internal/lyra-element.js';
10
11
  import { defineElement } from '../../internal/prefix.js';
12
+ import { srOnly } from '../../internal/a11y.js';
11
13
  import { linearAlpha, minMax, sqrtStep } from './heatmap-scale.js';
12
14
  import { styles } from './heatmap.styles.js';
13
- import { buildCalendarGrid, quartileBucket } from './calendar-grid.js';
15
+ import { buildCalendarGrid, parseIsoDate, quartileBucket } from './calendar-grid.js';
14
16
  const PAD_LEFT = 60;
15
17
  const PAD_TOP = 20;
16
18
  const FALLBACK_NO_DATA_FILL = 'rgba(128,128,128,0.25)';
17
19
  const RAMP_STEPS = 7;
18
20
  const FALLBACK_SCALE_LO = '#cde2fb';
19
21
  const FALLBACK_SCALE_HI = '#0969da';
22
+ const FALLBACK_LABEL_FONT = '10px sans-serif';
20
23
  const CAL_PAD_LEFT = 28;
21
24
  const CAL_LABEL_H = 16;
22
25
  const CAL_CELL = 11;
23
26
  const CAL_GAP = 2;
27
+ const DEFAULT_BUCKET_COUNT = 5;
28
+ /** Ring stroke width for both the annotation overlay and the keyboard focus ring. */
29
+ const RING_LINE_WIDTH = 2;
30
+ const FALLBACK_FOCUS_RING_COLOR = '#0969da';
31
+ const FALLBACK_ANNOTATION_COLOR = '#cf222e';
32
+ const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
33
+ const MS_PER_DAY = 86_400_000;
24
34
  const HEX_RE = /^([0-9a-f]{3}|[0-9a-f]{6})$/i;
25
35
  const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i;
26
36
  /**
@@ -56,6 +66,30 @@ function warnInvalidColor(color) {
56
66
  console.warn(`<lyra-heatmap> could not parse "${color}" (set via --lyra-heatmap-scale-lo/-hi) as a CSS ` +
57
67
  'color; falling back to the default ramp endpoint.');
58
68
  }
69
+ const warnedInvalidBucketCounts = new Set();
70
+ function warnInvalidBucketCount(value) {
71
+ if (warnedInvalidBucketCounts.has(value))
72
+ return;
73
+ warnedInvalidBucketCounts.add(value);
74
+ console.warn(`<lyra-heatmap> received a non-finite bucket-count (${value}); falling back to ${DEFAULT_BUCKET_COUNT}.`);
75
+ }
76
+ /**
77
+ * Normalizes `bucketCount` to a finite integer >= 2. `Math.max(2, bucketCount)`
78
+ * alone stays `NaN` for a non-numeric `bucket-count` attribute (`Math.max`
79
+ * propagates `NaN`), which silently zeroes out the ramp array and makes every
80
+ * cell keep whatever `fillStyle` the previous draw left. A fractional count
81
+ * is floored rather than passed through as-is, so the ramp array's length
82
+ * (built via `Array.from({ length: buckets }, ...)`, which truncates a
83
+ * fractional `length`) always agrees with the index `quartileBucket` computes
84
+ * from the same `buckets` value.
85
+ */
86
+ function resolveBucketCount(bucketCount) {
87
+ if (!Number.isFinite(bucketCount)) {
88
+ warnInvalidBucketCount(bucketCount);
89
+ return DEFAULT_BUCKET_COUNT;
90
+ }
91
+ return Math.max(2, Math.floor(bucketCount));
92
+ }
59
93
  /**
60
94
  * Resolves any syntactically valid CSS `<color>` — hex, `rgb()`, `hsl()`,
61
95
  * `oklch()`, a named color, etc. — to an `[r, g, b]` triple.
@@ -102,16 +136,44 @@ export function mixColor(fromColor, toColor, t) {
102
136
  return mixRgb(resolveRgb(fromColor, FALLBACK_SCALE_LO), resolveRgb(toColor, FALLBACK_SCALE_HI), t);
103
137
  }
104
138
  /**
105
- * `<lyra-heatmap>` — a Canvas matrix heatmap with a DPR-aware, resize-aware
106
- * redraw loop. `-1` cells are treated as "no data". The sequential color
107
- * ramp's endpoints are read from the `--lyra-heatmap-scale-lo`/`-hi` custom
108
- * properties (declared in `heatmap.styles.ts`) so hosts can retheme it
109
- * canvas can't consume `var()` directly, so they're resolved once per draw
110
- * via `getComputedStyle`, then normalized to RGB by `resolveRgb()` (any
111
- * valid CSS color syntax, not just hex see its doc comment).
139
+ * `<lyra-heatmap>` — a Canvas heatmap with a DPR-aware, resize-aware redraw
140
+ * loop, in one of two `mode`s:
141
+ *
142
+ * - `"matrix"` (default): a `rowLabels` x `colLabels` grid of `values`. `-1`
143
+ * (or any non-finite value) is treated as "no data". `scale="sqrt"`
144
+ * compresses the ramp via `sqrtStep()` so one heavy cell doesn't wash out
145
+ * the rest; the default `"linear"` scale maps linearly instead. `scale`
146
+ * only affects matrix mode — calendar mode always buckets by quartile.
147
+ * - `"calendar"`: a GitHub-style Sunday-Saturday x week grid built from
148
+ * `days`, colored by `quartileBucket()` into `bucketCount` buckets. As in
149
+ * matrix mode, a cell whose `value` is negative or non-finite is treated
150
+ * as "no data" rather than being bucketed.
151
+ *
152
+ * The sequential color ramp's endpoints are read from the
153
+ * `--lyra-heatmap-scale-lo`/`-hi` custom properties (declared in
154
+ * `heatmap.styles.ts`) so hosts can retheme it — canvas can't consume
155
+ * `var()` directly, so they're resolved once per draw via
156
+ * `getComputedStyle`, then normalized to RGB by `resolveRgb()` (any valid
157
+ * CSS color syntax, not just hex — see its doc comment).
158
+ *
159
+ * Every cell is independently addressable: a `pointermove` hit test over the
160
+ * canvas shows `[part="tooltip"]` with that cell's label + value (hidden on
161
+ * `pointerleave`); the canvas is `tabindex="0"` with arrow-key roving focus
162
+ * (a stroked ring redrawn over the focused cell on every draw, plus a
163
+ * visually-hidden `aria-live="polite"` status announcement — avoids a
164
+ * DOM-node-per-cell overlay, which would be hundreds of nodes for a year
165
+ * calendar); and a click, or Enter/Space on the focused cell, fires
166
+ * `lyra-cell-click`. `annotations` additionally strokes a ring around
167
+ * specific cells (e.g. to call out an anomaly), each one optionally
168
+ * surfaced in the legend too via `[part="legend-annotation"]`.
112
169
  *
113
170
  * @customElement lyra-heatmap
114
- * @csspart base, canvas, legend
171
+ * @event lyra-cell-click - Fired on click, or Enter/Space on the
172
+ * focused/hovered cell. `detail: { row, col, value }` in matrix mode,
173
+ * `detail: { date, value }` in calendar mode. `cellText` overrides the
174
+ * built-in English "Row X, Col Y: value" / "Mon DD: value" template used for
175
+ * both the hover tooltip and the keyboard live-region announcement.
176
+ * @csspart base, canvas, tooltip, live-region, legend, legend-lo, legend-hi, legend-annotation
115
177
  */
116
178
  export class LyraHeatmap extends LyraElement {
117
179
  constructor() {
@@ -133,13 +195,47 @@ export class LyraHeatmap extends LyraElement {
133
195
  this.fitToWidth = false;
134
196
  this.mode = 'matrix';
135
197
  this.days = [];
136
- this.bucketCount = 5;
198
+ this.bucketCount = DEFAULT_BUCKET_COUNT;
199
+ /** Cells to ring-highlight — `row`/`col` in matrix mode, `date` in calendar mode. See `HeatmapAnnotation`. */
200
+ this.annotations = [];
201
+ /** The current value range, refreshed once per update cycle by `willUpdate()`. See `computeValueRange()`. */
202
+ this.cachedValueRange = null;
203
+ /** The cell currently under the pointer (`null` when not hovering one) — drives `[part="tooltip"]`. */
204
+ this.hoverCell = null;
205
+ /** The roving keyboard-focus cell cursor, moved by arrow keys — drives the
206
+ * canvas-drawn focus ring and the `[part="live-region"]` announcement. */
207
+ this.focusedCell = null;
208
+ /** Text of the visually-hidden `aria-live="polite"` status announcement, refreshed on every focus move. */
209
+ this.liveText = '';
137
210
  this.onDprChange = () => {
138
211
  this.watchDpr();
139
212
  this.draw();
140
213
  };
214
+ this.onPointerMove = (e) => {
215
+ this.hoverCell = this.hitTest(e.offsetX, e.offsetY);
216
+ };
217
+ this.onPointerLeave = () => {
218
+ this.hoverCell = null;
219
+ };
220
+ this.onCanvasClick = (e) => {
221
+ // Prefer the click's own position; fall back to whatever's already
222
+ // keyboard-focused (e.g. a synthetic click dispatched via
223
+ // HTMLElement.click(), which carries no real coordinates).
224
+ const pos = this.hitTest(e.offsetX, e.offsetY) ?? this.focusedCell;
225
+ if (!pos)
226
+ return;
227
+ this.focusedCell = pos;
228
+ this.announce(pos);
229
+ this.emitCellClick(pos);
230
+ };
231
+ this.onKeyDown = (e) => {
232
+ if (this.mode === 'calendar')
233
+ this.onCalendarKeyDown(e);
234
+ else
235
+ this.onMatrixKeyDown(e);
236
+ };
141
237
  }
142
- static { this.styles = [LyraElement.styles, styles]; }
238
+ static { this.styles = [LyraElement.styles, styles, srOnly]; }
143
239
  connectedCallback() {
144
240
  super.connectedCallback();
145
241
  this.resizeObserver = new ResizeObserver(() => this.draw());
@@ -160,10 +256,23 @@ export class LyraHeatmap extends LyraElement {
160
256
  this.dprQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
161
257
  this.dprQuery.addEventListener('change', this.onDprChange);
162
258
  }
163
- willUpdate() {
164
- const bounds = this.valueRange();
259
+ willUpdate(changed) {
260
+ if (changed.has('mode') || changed.has('rowLabels') || changed.has('colLabels') || changed.has('days')) {
261
+ // The previous focus/hover cursor may no longer address a real cell
262
+ // once the grid's shape (or mode) changes out from under it —
263
+ // stroking a focus ring at a stale (row, col)/(week, weekday), or
264
+ // leaving the live region announcing a stale value, would be actively
265
+ // misleading, so drop both instead. Reactive-property writes belong in
266
+ // willUpdate() (not updated()), which folds them into this same
267
+ // render rather than scheduling a whole extra update pass.
268
+ this.focusedCell = null;
269
+ this.hoverCell = null;
270
+ this.liveText = '';
271
+ }
272
+ this.cachedValueRange = this.computeValueRange();
273
+ const bounds = this.cachedValueRange;
165
274
  const range = bounds ? `${bounds[0]}–${bounds[1]}` : 'no data';
166
- this.setAttribute('role', 'img');
275
+ this.setAttribute('role', 'group');
167
276
  if (this.mode === 'calendar') {
168
277
  this.setAttribute('aria-label', `Calendar heatmap of ${this.days.length} days, ${this.valueLabel} range ${range}`);
169
278
  }
@@ -173,8 +282,16 @@ export class LyraHeatmap extends LyraElement {
173
282
  this.setAttribute('aria-label', `Heatmap of ${rows} × ${cols} cells, ${this.valueLabel} range ${range}`);
174
283
  }
175
284
  }
176
- /** The real (non-no-data) value range across `values` (or `days` in calendar mode), or `null` if there is none. Shared by `willUpdate()`, `draw()`, and the legend. */
177
- valueRange() {
285
+ /**
286
+ * The real (non-no-data) value range across `values` (or `days` in
287
+ * calendar mode), or `null` if there is none. Only called from
288
+ * `willUpdate()`, which caches the result in `cachedValueRange` for
289
+ * `render()` and `drawMatrix()` to reuse — `willUpdate()`, `render()`, and
290
+ * `updated()` (which triggers `draw()`) all run within the same Lit update
291
+ * cycle against the same `values`/`days`, so a single scan per cycle
292
+ * suffices instead of one per consumer.
293
+ */
294
+ computeValueRange() {
178
295
  const source = this.mode === 'calendar' ? this.days.map((d) => d.value) : this.values.flat();
179
296
  return minMax(source.filter((v) => Number.isFinite(v) && v >= 0));
180
297
  }
@@ -192,10 +309,22 @@ export class LyraHeatmap extends LyraElement {
192
309
  labelColor() {
193
310
  return getComputedStyle(this).getPropertyValue('--lyra-color-text-quiet').trim() || '#6b7280';
194
311
  }
312
+ /** Reads the customizable canvas axis/label font off the host's computed style. */
313
+ labelFont() {
314
+ return getComputedStyle(this).getPropertyValue('--lyra-heatmap-label-font').trim() || FALLBACK_LABEL_FONT;
315
+ }
195
316
  /** Reads the customizable no-data cell fill off the host's computed style. */
196
317
  noDataFill() {
197
318
  return getComputedStyle(this).getPropertyValue('--lyra-heatmap-no-data-fill').trim() || FALLBACK_NO_DATA_FILL;
198
319
  }
320
+ /** Reads the customizable canvas-drawn keyboard-focus-ring stroke color off the host's computed style. */
321
+ focusRingColor() {
322
+ return (getComputedStyle(this).getPropertyValue('--lyra-heatmap-focus-ring-color').trim() || FALLBACK_FOCUS_RING_COLOR);
323
+ }
324
+ /** Reads the customizable canvas-drawn annotation-ring stroke color off the host's computed style. */
325
+ annotationColor() {
326
+ return (getComputedStyle(this).getPropertyValue('--lyra-heatmap-annotation-color').trim() || FALLBACK_ANNOTATION_COLOR);
327
+ }
199
328
  draw() {
200
329
  if (this.mode === 'calendar')
201
330
  this.drawCalendar();
@@ -225,7 +354,7 @@ export class LyraHeatmap extends LyraElement {
225
354
  const [scaleLo, scaleHi] = this.scaleEndpoints();
226
355
  const loRgb = resolveRgb(scaleLo, FALLBACK_SCALE_LO);
227
356
  const hiRgb = resolveRgb(scaleHi, FALLBACK_SCALE_HI);
228
- const buckets = Math.max(2, this.bucketCount);
357
+ const buckets = resolveBucketCount(this.bucketCount);
229
358
  const ramp = Array.from({ length: buckets }, (_, i) => mixRgb(loRgb, hiRgb, i / (buckets - 1)));
230
359
  const noDataFill = this.noDataFill();
231
360
  for (const cell of cells) {
@@ -237,8 +366,37 @@ export class LyraHeatmap extends LyraElement {
237
366
  : ramp[quartileBucket(cell.value, sortedValues, buckets)];
238
367
  ctx.fillRect(x, y, CAL_CELL, CAL_CELL);
239
368
  }
369
+ // Annotation ring overlay, stroked after the fill pass so it reads
370
+ // clearly over the data-driven cell color — see drawMatrix()'s twin of
371
+ // this block for the matrix-mode equivalent.
372
+ if (this.annotations.length) {
373
+ ctx.lineWidth = RING_LINE_WIDTH;
374
+ ctx.strokeStyle = this.annotationColor();
375
+ for (const ann of this.annotations) {
376
+ if (ann.date == null)
377
+ continue;
378
+ const match = cells.find((c) => c.date === ann.date);
379
+ if (!match)
380
+ continue;
381
+ const x = CAL_PAD_LEFT + match.week * (CAL_CELL + CAL_GAP);
382
+ const y = CAL_LABEL_H + match.weekday * (CAL_CELL + CAL_GAP);
383
+ ctx.strokeRect(x + 0.5, y + 0.5, CAL_CELL - 1, CAL_CELL - 1);
384
+ }
385
+ }
386
+ // Keyboard focus ring, redrawn on top of the fill pass (and any
387
+ // annotation rings) on every draw.
388
+ if (this.focusedCell && 'week' in this.focusedCell) {
389
+ const { week, weekday } = this.focusedCell;
390
+ if (week < weekCount && weekday < 7) {
391
+ ctx.lineWidth = RING_LINE_WIDTH;
392
+ ctx.strokeStyle = this.focusRingColor();
393
+ const x = CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP);
394
+ const y = CAL_LABEL_H + weekday * (CAL_CELL + CAL_GAP);
395
+ ctx.strokeRect(x + 0.5, y + 0.5, CAL_CELL - 1, CAL_CELL - 1);
396
+ }
397
+ }
240
398
  ctx.fillStyle = this.labelColor();
241
- ctx.font = '10px sans-serif';
399
+ ctx.font = this.labelFont();
242
400
  for (const m of monthLabels) {
243
401
  ctx.fillText(m.label, CAL_PAD_LEFT + m.week * (CAL_CELL + CAL_GAP), CAL_LABEL_H - 4);
244
402
  }
@@ -248,16 +406,26 @@ export class LyraHeatmap extends LyraElement {
248
406
  ctx.fillText(label, 2, CAL_LABEL_H + weekday * (CAL_CELL + CAL_GAP) + CAL_CELL - 1);
249
407
  });
250
408
  }
409
+ /**
410
+ * Effective per-cell size in matrix mode — `fitToWidth` derives it from
411
+ * the host's measured width, otherwise it's the fixed `cellSize`
412
+ * property. Shared by `drawMatrix()` and the pointer/keyboard hit-testing
413
+ * below (`hitTestMatrix()`, `cellRect()`) so they always agree on exactly
414
+ * the same geometry as what's actually painted.
415
+ */
416
+ matrixCellSize(cols) {
417
+ if (this.fitToWidth && cols > 0) {
418
+ const hostWidth = this.clientWidth || PAD_LEFT + cols * this.cellSize;
419
+ return Math.max(4, (hostWidth - PAD_LEFT) / cols);
420
+ }
421
+ return this.cellSize;
422
+ }
251
423
  drawMatrix() {
252
424
  if (!this.canvas)
253
425
  return;
254
426
  const rows = this.rowLabels.length;
255
427
  const cols = this.colLabels.length;
256
- let cellSize = this.cellSize;
257
- if (this.fitToWidth && cols > 0) {
258
- const hostWidth = this.clientWidth || PAD_LEFT + cols * this.cellSize;
259
- cellSize = Math.max(4, (hostWidth - PAD_LEFT) / cols);
260
- }
428
+ const cellSize = this.matrixCellSize(cols);
261
429
  const w = PAD_LEFT + cols * cellSize;
262
430
  const h = PAD_TOP + rows * cellSize;
263
431
  const dpr = window.devicePixelRatio || 1;
@@ -270,7 +438,7 @@ export class LyraHeatmap extends LyraElement {
270
438
  return;
271
439
  ctx.scale(dpr, dpr);
272
440
  ctx.clearRect(0, 0, w, h);
273
- const bounds = this.valueRange();
441
+ const bounds = this.cachedValueRange;
274
442
  const lo = bounds ? bounds[0] : 0;
275
443
  const hi = bounds ? bounds[1] : 1;
276
444
  const [scaleLo, scaleHi] = this.scaleEndpoints();
@@ -305,8 +473,40 @@ export class LyraHeatmap extends LyraElement {
305
473
  ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
306
474
  }
307
475
  }
476
+ // Annotation ring overlay, stroked after the fill pass so it reads
477
+ // clearly over the data-driven cell color. Uses a dedicated
478
+ // --lyra-heatmap-annotation-color token (defaults to --lyra-color-danger)
479
+ // rather than any of the sequential ramp colors, since it needs to stay
480
+ // visible regardless of which point on that ramp it's drawn over.
481
+ if (this.annotations.length) {
482
+ ctx.lineWidth = RING_LINE_WIDTH;
483
+ ctx.strokeStyle = this.annotationColor();
484
+ for (const ann of this.annotations) {
485
+ if (ann.row == null || ann.col == null)
486
+ continue;
487
+ if (ann.row < 0 || ann.row >= rows || ann.col < 0 || ann.col >= cols)
488
+ continue;
489
+ const x = PAD_LEFT + ann.col * cellSize;
490
+ const y = PAD_TOP + ann.row * cellSize;
491
+ ctx.strokeRect(x + 1, y + 1, cellSize - 3, cellSize - 3);
492
+ }
493
+ }
494
+ // Keyboard focus ring, redrawn on top of the fill pass (and any
495
+ // annotation rings) on every draw — canvas has no persistent DOM focus
496
+ // affordance of its own for an individual cell, only a `:focus-visible`
497
+ // outline around the whole element (see heatmap.styles.ts).
498
+ if (this.focusedCell && 'row' in this.focusedCell) {
499
+ const { row, col } = this.focusedCell;
500
+ if (row < rows && col < cols) {
501
+ ctx.lineWidth = RING_LINE_WIDTH;
502
+ ctx.strokeStyle = this.focusRingColor();
503
+ const x = PAD_LEFT + col * cellSize;
504
+ const y = PAD_TOP + row * cellSize;
505
+ ctx.strokeRect(x + 1, y + 1, cellSize - 3, cellSize - 3);
506
+ }
507
+ }
308
508
  ctx.fillStyle = this.labelColor();
309
- ctx.font = '10px sans-serif';
509
+ ctx.font = this.labelFont();
310
510
  this.rowLabels.forEach((label, r) => {
311
511
  ctx.fillText(label, 4, PAD_TOP + r * cellSize + cellSize / 2 + 3);
312
512
  });
@@ -314,16 +514,218 @@ export class LyraHeatmap extends LyraElement {
314
514
  ctx.fillText(label, PAD_LEFT + c * cellSize + 2, PAD_TOP - 6);
315
515
  });
316
516
  }
517
+ /**
518
+ * Maps a pointer position, in canvas-local CSS px (e.g.
519
+ * `PointerEvent.offsetX/offsetY`), to the cell underneath it — or `null`
520
+ * if the pointer is outside the grid. Dispatches on `mode` so callers
521
+ * don't have to.
522
+ */
523
+ hitTest(x, y) {
524
+ return this.mode === 'calendar' ? this.hitTestCalendar(x, y) : this.hitTestMatrix(x, y);
525
+ }
526
+ hitTestMatrix(x, y) {
527
+ const rows = this.rowLabels.length;
528
+ const cols = this.colLabels.length;
529
+ if (rows === 0 || cols === 0)
530
+ return null;
531
+ const cellSize = this.matrixCellSize(cols);
532
+ const col = Math.floor((x - PAD_LEFT) / cellSize);
533
+ const row = Math.floor((y - PAD_TOP) / cellSize);
534
+ if (row < 0 || row >= rows || col < 0 || col >= cols)
535
+ return null;
536
+ return { row, col };
537
+ }
538
+ hitTestCalendar(x, y) {
539
+ const { weekCount } = buildCalendarGrid(this.days);
540
+ if (weekCount === 0)
541
+ return null;
542
+ const week = Math.floor((x - CAL_PAD_LEFT) / (CAL_CELL + CAL_GAP));
543
+ const weekday = Math.floor((y - CAL_LABEL_H) / (CAL_CELL + CAL_GAP));
544
+ if (week < 0 || week >= weekCount || weekday < 0 || weekday > 6)
545
+ return null;
546
+ return { week, weekday };
547
+ }
548
+ /**
549
+ * Resolves a (week, weekday) grid position to its real calendar date and
550
+ * value. Computed from grid geometry (`firstWeekStart` + offset), not
551
+ * just looked up in `cells` — so a position with no matching entry in
552
+ * `days` (a gap in a sparse calendar) still resolves to a real ISO date,
553
+ * with a `-1` "no data" sentinel value (the same convention `values`' `-1`
554
+ * sentinel uses in matrix mode), instead of being unresolvable.
555
+ */
556
+ calendarCellAt(pos) {
557
+ const { cells, firstWeekStart } = buildCalendarGrid(this.days);
558
+ const match = cells.find((c) => c.week === pos.week && c.weekday === pos.weekday);
559
+ if (match)
560
+ return { date: match.date, value: match.value };
561
+ const date = new Date(firstWeekStart.getTime() + (pos.week * 7 + pos.weekday) * MS_PER_DAY);
562
+ return { date: date.toISOString().slice(0, 10), value: -1 };
563
+ }
564
+ /**
565
+ * Pixel rect (canvas-local CSS px) of a cell — shared by `tooltipStyle()`
566
+ * so the tooltip's position always agrees with the same geometry
567
+ * `drawMatrix()`/`drawCalendar()`/`hitTest*()` use.
568
+ */
569
+ cellRect(pos) {
570
+ if ('week' in pos) {
571
+ return {
572
+ x: CAL_PAD_LEFT + pos.week * (CAL_CELL + CAL_GAP),
573
+ y: CAL_LABEL_H + pos.weekday * (CAL_CELL + CAL_GAP),
574
+ w: CAL_CELL,
575
+ h: CAL_CELL,
576
+ };
577
+ }
578
+ const cellSize = this.matrixCellSize(this.colLabels.length);
579
+ return { x: PAD_LEFT + pos.col * cellSize, y: PAD_TOP + pos.row * cellSize, w: cellSize - 1, h: cellSize - 1 };
580
+ }
581
+ /**
582
+ * Human-readable "<label>: <value>" text for a cell — shared by the hover
583
+ * tooltip and the keyboard-focus live-region announcement, so both always
584
+ * describe a cell the same way. This is the built-in English fallback used
585
+ * when `cellText` isn't set — see `resolveCellText()`.
586
+ */
587
+ defaultCellText(pos) {
588
+ return 'week' in pos ? this.calendarCellText(pos) : this.matrixCellText(pos);
589
+ }
590
+ matrixCellText(pos) {
591
+ const rowLabel = this.rowLabels[pos.row] ?? `row ${pos.row + 1}`;
592
+ const colLabel = this.colLabels[pos.col] ?? `col ${pos.col + 1}`;
593
+ const v = this.values[pos.row]?.[pos.col];
594
+ const valueText = v == null || v < 0 || !Number.isFinite(v) ? 'no data' : String(v);
595
+ return `Row ${rowLabel}, Col ${colLabel}: ${valueText}`;
596
+ }
597
+ calendarCellText(pos) {
598
+ const { date, value } = this.calendarCellAt(pos);
599
+ const label = parseIsoDate(date).toLocaleString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
600
+ const valueText = value < 0 || !Number.isFinite(value) ? 'no data' : String(value);
601
+ return `${label}: ${valueText}`;
602
+ }
603
+ /** The raw numeric value at a cell position, in either mode — shared by `resolveCellText()`
604
+ * so a custom `cellText` formatter receives the same value the built-in template would use. */
605
+ valueAt(pos) {
606
+ if ('week' in pos)
607
+ return this.calendarCellAt(pos).value;
608
+ return this.values[pos.row]?.[pos.col] ?? -1;
609
+ }
610
+ /** Dispatches to the host-provided `cellText` formatter when set, otherwise the built-in template. */
611
+ resolveCellText(pos) {
612
+ return this.cellText ? this.cellText(pos, this.valueAt(pos)) : this.defaultCellText(pos);
613
+ }
614
+ /** Refreshes the visually-hidden live-region text for a newly-focused cell. */
615
+ announce(pos) {
616
+ this.liveText = this.resolveCellText(pos);
617
+ }
618
+ emitCellClick(pos) {
619
+ if ('week' in pos) {
620
+ const { date, value } = this.calendarCellAt(pos);
621
+ this.emit('lyra-cell-click', { date, value });
622
+ }
623
+ else {
624
+ const value = this.values[pos.row]?.[pos.col] ?? -1;
625
+ this.emit('lyra-cell-click', { row: pos.row, col: pos.col, value });
626
+ }
627
+ }
628
+ /** Inline `style` for `[part="tooltip"]`, centered above the given cell. */
629
+ tooltipStyle(pos) {
630
+ const rect = this.cellRect(pos);
631
+ return { left: `${rect.x + rect.w / 2}px`, top: `${rect.y}px` };
632
+ }
633
+ onMatrixKeyDown(e) {
634
+ const rows = this.rowLabels.length;
635
+ const cols = this.colLabels.length;
636
+ if (rows === 0 || cols === 0)
637
+ return;
638
+ if (e.key === 'Enter' || e.key === ' ') {
639
+ e.preventDefault();
640
+ if (this.focusedCell)
641
+ this.emitCellClick(this.focusedCell);
642
+ return;
643
+ }
644
+ if (!ARROW_KEYS.has(e.key))
645
+ return;
646
+ e.preventDefault();
647
+ // The first arrow keypress just moves focus onto the grid (at the
648
+ // top-left cell) rather than also applying that keypress's delta —
649
+ // otherwise ArrowRight from "unfocused" would land on column 1,
650
+ // silently skipping column 0.
651
+ if (!this.focusedCell || !('row' in this.focusedCell)) {
652
+ const next = { row: 0, col: 0 };
653
+ this.focusedCell = next;
654
+ this.announce(next);
655
+ return;
656
+ }
657
+ const { row, col } = this.focusedCell;
658
+ let next = { row, col };
659
+ if (e.key === 'ArrowUp')
660
+ next = { row: Math.max(0, row - 1), col };
661
+ else if (e.key === 'ArrowDown')
662
+ next = { row: Math.min(rows - 1, row + 1), col };
663
+ else if (e.key === 'ArrowLeft')
664
+ next = { row, col: Math.max(0, col - 1) };
665
+ else if (e.key === 'ArrowRight')
666
+ next = { row, col: Math.min(cols - 1, col + 1) };
667
+ this.focusedCell = next;
668
+ this.announce(next);
669
+ }
670
+ onCalendarKeyDown(e) {
671
+ const { weekCount } = buildCalendarGrid(this.days);
672
+ if (weekCount === 0)
673
+ return;
674
+ if (e.key === 'Enter' || e.key === ' ') {
675
+ e.preventDefault();
676
+ if (this.focusedCell)
677
+ this.emitCellClick(this.focusedCell);
678
+ return;
679
+ }
680
+ if (!ARROW_KEYS.has(e.key))
681
+ return;
682
+ e.preventDefault();
683
+ if (!this.focusedCell || !('week' in this.focusedCell)) {
684
+ const next = { week: 0, weekday: 0 };
685
+ this.focusedCell = next;
686
+ this.announce(next);
687
+ return;
688
+ }
689
+ const { week, weekday } = this.focusedCell;
690
+ let next = { week, weekday };
691
+ if (e.key === 'ArrowUp')
692
+ next = { week, weekday: Math.max(0, weekday - 1) };
693
+ else if (e.key === 'ArrowDown')
694
+ next = { week, weekday: Math.min(6, weekday + 1) };
695
+ else if (e.key === 'ArrowLeft')
696
+ next = { week: Math.max(0, week - 1), weekday };
697
+ else if (e.key === 'ArrowRight')
698
+ next = { week: Math.min(weekCount - 1, week + 1), weekday };
699
+ this.focusedCell = next;
700
+ this.announce(next);
701
+ }
317
702
  render() {
318
- const range = this.valueRange();
703
+ const range = this.cachedValueRange;
704
+ const labeledAnnotations = this.annotations.filter((a) => a.label);
319
705
  return html `
320
706
  <div part="base">
321
- <canvas part="canvas"></canvas>
707
+ <canvas
708
+ part="canvas"
709
+ tabindex="0"
710
+ @pointermove=${this.onPointerMove}
711
+ @pointerleave=${this.onPointerLeave}
712
+ @click=${this.onCanvasClick}
713
+ @keydown=${this.onKeyDown}
714
+ ></canvas>
715
+ <div
716
+ part="tooltip"
717
+ ?hidden=${!this.hoverCell}
718
+ style=${styleMap(this.hoverCell ? this.tooltipStyle(this.hoverCell) : {})}
719
+ >
720
+ ${this.hoverCell ? this.resolveCellText(this.hoverCell) : ''}
721
+ </div>
722
+ <div part="live-region" class="sr-only" role="status" aria-live="polite">${this.liveText}</div>
322
723
  <div part="legend">
323
724
  <span part="legend-lo">${range ? range[0] : ''}</span>
324
725
  <span class="bar"></span>
325
726
  <span part="legend-hi">${range ? range[1] : ''}</span>
326
727
  <span>${this.valueLabel}</span>
728
+ ${labeledAnnotations.map((a) => html `<span part="legend-annotation"><span class="ring-swatch"></span>${a.label}</span>`)}
327
729
  </div>
328
730
  </div>
329
731
  `;
@@ -359,7 +761,22 @@ __decorate([
359
761
  __decorate([
360
762
  property({ attribute: 'bucket-count', type: Number })
361
763
  ], LyraHeatmap.prototype, "bucketCount", void 0);
764
+ __decorate([
765
+ property({ attribute: false })
766
+ ], LyraHeatmap.prototype, "annotations", void 0);
767
+ __decorate([
768
+ property({ attribute: false })
769
+ ], LyraHeatmap.prototype, "cellText", void 0);
362
770
  __decorate([
363
771
  query('canvas')
364
772
  ], LyraHeatmap.prototype, "canvas", void 0);
773
+ __decorate([
774
+ state()
775
+ ], LyraHeatmap.prototype, "hoverCell", void 0);
776
+ __decorate([
777
+ state()
778
+ ], LyraHeatmap.prototype, "focusedCell", void 0);
779
+ __decorate([
780
+ state()
781
+ ], LyraHeatmap.prototype, "liveText", void 0);
365
782
  defineElement('heatmap', LyraHeatmap);
@@ -3,3 +3,25 @@ declare const meta: Meta;
3
3
  export default meta;
4
4
  type Story = StoryObj;
5
5
  export declare const Default: Story;
6
+ export declare const SqrtScaleFitToWidth: Story;
7
+ export declare const CalendarMode: Story;
8
+ export declare const CustomTheme: Story;
9
+ /**
10
+ * Hover a cell for its tooltip, tab to the grid and use the arrow keys to
11
+ * move the focus ring (a visually-hidden live region announces each move),
12
+ * and click or press Enter/Space on a cell to see its `lyra-cell-click`
13
+ * detail logged below.
14
+ */
15
+ export declare const HoverFocusClick: Story;
16
+ /**
17
+ * Cells flagged via `annotations` get a stroked ring; ones with a `label`
18
+ * also surface a swatch + text entry in the legend.
19
+ */
20
+ export declare const Annotations: Story;
21
+ /**
22
+ * `cellText` overrides the built-in English tooltip/live-region template —
23
+ * here with a French translation — for both matrix and calendar modes.
24
+ */
25
+ export declare const CustomCellText: Story;
26
+ /** `annotations` in calendar mode match by ISO `date` instead of `row`/`col`. */
27
+ export declare const CalendarAnnotations: Story;