@aceshooting/lyra-ui 1.0.1 → 1.1.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 (148) hide show
  1. package/README.md +8 -9
  2. package/custom-elements.json +12479 -6535
  3. package/dist/components/chart/chart.d.ts +46 -1
  4. package/dist/components/chart/chart.js +148 -10
  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 +74 -0
  13. package/dist/components/chart/lite-chart.js +384 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +12 -0
  15. package/dist/components/chart/lite-chart.stories.js +98 -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 +75 -12
  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 +48 -3
  50. package/dist/components/flag/flag.js +65 -29
  51. package/dist/components/flag/flag.styles.js +1 -1
  52. package/dist/components/flag/language-map.d.ts +11 -0
  53. package/dist/components/flag/language-map.js +12 -1
  54. package/dist/components/gauge/gauge.d.ts +2 -0
  55. package/dist/components/gauge/gauge.js +19 -5
  56. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  57. package/dist/components/gauge/gauge.stories.js +6 -0
  58. package/dist/components/graph/graph-loader.d.ts +28 -0
  59. package/dist/components/graph/graph-loader.js +44 -0
  60. package/dist/components/graph/graph.d.ts +41 -15
  61. package/dist/components/graph/graph.js +146 -71
  62. package/dist/components/graph/graph.stories.d.ts +3 -0
  63. package/dist/components/graph/graph.stories.js +38 -0
  64. package/dist/components/graph/graph.styles.js +6 -1
  65. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  66. package/dist/components/heatmap/calendar-grid.js +27 -10
  67. package/dist/components/heatmap/heatmap.d.ts +137 -12
  68. package/dist/components/heatmap/heatmap.js +427 -27
  69. package/dist/components/heatmap/heatmap.stories.d.ts +17 -0
  70. package/dist/components/heatmap/heatmap.stories.js +130 -0
  71. package/dist/components/heatmap/heatmap.styles.js +60 -2
  72. package/dist/components/map/map.d.ts +17 -1
  73. package/dist/components/map/map.js +68 -21
  74. package/dist/components/map/map.stories.d.ts +10 -0
  75. package/dist/components/map/map.stories.js +107 -12
  76. package/dist/components/map/map.styles.js +2 -2
  77. package/dist/components/playback/playback.d.ts +6 -2
  78. package/dist/components/playback/playback.js +36 -12
  79. package/dist/components/playback/playback.stories.d.ts +2 -0
  80. package/dist/components/playback/playback.stories.js +6 -0
  81. package/dist/components/playback/playback.styles.js +10 -2
  82. package/dist/components/select/select.d.ts +122 -0
  83. package/dist/components/select/select.js +516 -0
  84. package/dist/components/select/select.stories.d.ts +19 -0
  85. package/dist/components/select/select.stories.js +75 -0
  86. package/dist/components/select/select.styles.d.ts +1 -0
  87. package/dist/components/select/select.styles.js +188 -0
  88. package/dist/components/skeleton/skeleton.js +4 -7
  89. package/dist/components/sparkline/sparkline.d.ts +1 -1
  90. package/dist/components/sparkline/sparkline.js +40 -13
  91. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  92. package/dist/components/sparkline/sparkline.stories.js +22 -0
  93. package/dist/components/split/split.d.ts +24 -3
  94. package/dist/components/split/split.js +155 -31
  95. package/dist/components/split/split.stories.d.ts +2 -0
  96. package/dist/components/split/split.stories.js +20 -0
  97. package/dist/components/stat/stat.d.ts +27 -1
  98. package/dist/components/stat/stat.js +56 -3
  99. package/dist/components/stat/stat.stories.d.ts +3 -0
  100. package/dist/components/stat/stat.stories.js +49 -1
  101. package/dist/components/stat/stat.styles.js +44 -0
  102. package/dist/components/table/table.d.ts +64 -2
  103. package/dist/components/table/table.js +213 -17
  104. package/dist/components/table/table.stories.d.ts +6 -0
  105. package/dist/components/table/table.stories.js +45 -0
  106. package/dist/components/table/table.styles.js +51 -4
  107. package/dist/components/time-range/time-range.d.ts +34 -5
  108. package/dist/components/time-range/time-range.js +217 -36
  109. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  110. package/dist/components/time-range/time-range.stories.js +13 -0
  111. package/dist/components/time-range/time-range.styles.js +44 -2
  112. package/dist/components/toast/toast-item.d.ts +14 -2
  113. package/dist/components/toast/toast-item.js +134 -17
  114. package/dist/components/toast/toast-item.styles.js +28 -2
  115. package/dist/components/toast/toast.stories.d.ts +3 -0
  116. package/dist/components/toast/toast.stories.js +41 -1
  117. package/dist/components/toast/toast.styles.js +11 -11
  118. package/dist/components/toast/toaster.d.ts +1 -1
  119. package/dist/components/toast/toaster.js +13 -6
  120. package/dist/components/tree/tree-node.d.ts +13 -7
  121. package/dist/components/tree/tree-node.js +24 -10
  122. package/dist/components/tree/tree.d.ts +64 -15
  123. package/dist/components/tree/tree.js +113 -22
  124. package/dist/components/tree/tree.stories.d.ts +4 -0
  125. package/dist/components/tree/tree.stories.js +31 -1
  126. package/dist/components/tree/update-cascade.d.ts +13 -0
  127. package/dist/components/tree/update-cascade.js +13 -0
  128. package/dist/components/widget/widget.d.ts +15 -1
  129. package/dist/components/widget/widget.js +140 -5
  130. package/dist/components/widget/widget.stories.d.ts +1 -0
  131. package/dist/components/widget/widget.stories.js +10 -0
  132. package/dist/components/widget/widget.styles.js +11 -1
  133. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  134. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  135. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  136. package/dist/components/word-cloud/word-cloud.js +255 -0
  137. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  138. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  139. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  140. package/dist/components/word-cloud/word-cloud.styles.js +54 -0
  141. package/dist/internal/a11y.js +2 -1
  142. package/dist/internal/rtl.d.ts +7 -0
  143. package/dist/internal/rtl.js +9 -0
  144. package/dist/lyra.d.ts +9 -1
  145. package/dist/lyra.js +6 -0
  146. package/llms-full.txt +892 -276
  147. package/llms.txt +12 -9
  148. 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,42 @@ 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.
174
+ * @csspart base, canvas, tooltip, live-region, legend, legend-lo, legend-hi, legend-annotation
115
175
  */
116
176
  export class LyraHeatmap extends LyraElement {
117
177
  constructor() {
@@ -133,13 +193,47 @@ export class LyraHeatmap extends LyraElement {
133
193
  this.fitToWidth = false;
134
194
  this.mode = 'matrix';
135
195
  this.days = [];
136
- this.bucketCount = 5;
196
+ this.bucketCount = DEFAULT_BUCKET_COUNT;
197
+ /** Cells to ring-highlight — `row`/`col` in matrix mode, `date` in calendar mode. See `HeatmapAnnotation`. */
198
+ this.annotations = [];
199
+ /** The current value range, refreshed once per update cycle by `willUpdate()`. See `computeValueRange()`. */
200
+ this.cachedValueRange = null;
201
+ /** The cell currently under the pointer (`null` when not hovering one) — drives `[part="tooltip"]`. */
202
+ this.hoverCell = null;
203
+ /** The roving keyboard-focus cell cursor, moved by arrow keys — drives the
204
+ * canvas-drawn focus ring and the `[part="live-region"]` announcement. */
205
+ this.focusedCell = null;
206
+ /** Text of the visually-hidden `aria-live="polite"` status announcement, refreshed on every focus move. */
207
+ this.liveText = '';
137
208
  this.onDprChange = () => {
138
209
  this.watchDpr();
139
210
  this.draw();
140
211
  };
212
+ this.onPointerMove = (e) => {
213
+ this.hoverCell = this.hitTest(e.offsetX, e.offsetY);
214
+ };
215
+ this.onPointerLeave = () => {
216
+ this.hoverCell = null;
217
+ };
218
+ this.onCanvasClick = (e) => {
219
+ // Prefer the click's own position; fall back to whatever's already
220
+ // keyboard-focused (e.g. a synthetic click dispatched via
221
+ // HTMLElement.click(), which carries no real coordinates).
222
+ const pos = this.hitTest(e.offsetX, e.offsetY) ?? this.focusedCell;
223
+ if (!pos)
224
+ return;
225
+ this.focusedCell = pos;
226
+ this.announce(pos);
227
+ this.emitCellClick(pos);
228
+ };
229
+ this.onKeyDown = (e) => {
230
+ if (this.mode === 'calendar')
231
+ this.onCalendarKeyDown(e);
232
+ else
233
+ this.onMatrixKeyDown(e);
234
+ };
141
235
  }
142
- static { this.styles = [LyraElement.styles, styles]; }
236
+ static { this.styles = [LyraElement.styles, styles, srOnly]; }
143
237
  connectedCallback() {
144
238
  super.connectedCallback();
145
239
  this.resizeObserver = new ResizeObserver(() => this.draw());
@@ -160,8 +254,21 @@ export class LyraHeatmap extends LyraElement {
160
254
  this.dprQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
161
255
  this.dprQuery.addEventListener('change', this.onDprChange);
162
256
  }
163
- willUpdate() {
164
- const bounds = this.valueRange();
257
+ willUpdate(changed) {
258
+ if (changed.has('mode') || changed.has('rowLabels') || changed.has('colLabels') || changed.has('days')) {
259
+ // The previous focus/hover cursor may no longer address a real cell
260
+ // once the grid's shape (or mode) changes out from under it —
261
+ // stroking a focus ring at a stale (row, col)/(week, weekday), or
262
+ // leaving the live region announcing a stale value, would be actively
263
+ // misleading, so drop both instead. Reactive-property writes belong in
264
+ // willUpdate() (not updated()), which folds them into this same
265
+ // render rather than scheduling a whole extra update pass.
266
+ this.focusedCell = null;
267
+ this.hoverCell = null;
268
+ this.liveText = '';
269
+ }
270
+ this.cachedValueRange = this.computeValueRange();
271
+ const bounds = this.cachedValueRange;
165
272
  const range = bounds ? `${bounds[0]}–${bounds[1]}` : 'no data';
166
273
  this.setAttribute('role', 'img');
167
274
  if (this.mode === 'calendar') {
@@ -173,8 +280,16 @@ export class LyraHeatmap extends LyraElement {
173
280
  this.setAttribute('aria-label', `Heatmap of ${rows} × ${cols} cells, ${this.valueLabel} range ${range}`);
174
281
  }
175
282
  }
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() {
283
+ /**
284
+ * The real (non-no-data) value range across `values` (or `days` in
285
+ * calendar mode), or `null` if there is none. Only called from
286
+ * `willUpdate()`, which caches the result in `cachedValueRange` for
287
+ * `render()` and `drawMatrix()` to reuse — `willUpdate()`, `render()`, and
288
+ * `updated()` (which triggers `draw()`) all run within the same Lit update
289
+ * cycle against the same `values`/`days`, so a single scan per cycle
290
+ * suffices instead of one per consumer.
291
+ */
292
+ computeValueRange() {
178
293
  const source = this.mode === 'calendar' ? this.days.map((d) => d.value) : this.values.flat();
179
294
  return minMax(source.filter((v) => Number.isFinite(v) && v >= 0));
180
295
  }
@@ -192,10 +307,22 @@ export class LyraHeatmap extends LyraElement {
192
307
  labelColor() {
193
308
  return getComputedStyle(this).getPropertyValue('--lyra-color-text-quiet').trim() || '#6b7280';
194
309
  }
310
+ /** Reads the customizable canvas axis/label font off the host's computed style. */
311
+ labelFont() {
312
+ return getComputedStyle(this).getPropertyValue('--lyra-heatmap-label-font').trim() || FALLBACK_LABEL_FONT;
313
+ }
195
314
  /** Reads the customizable no-data cell fill off the host's computed style. */
196
315
  noDataFill() {
197
316
  return getComputedStyle(this).getPropertyValue('--lyra-heatmap-no-data-fill').trim() || FALLBACK_NO_DATA_FILL;
198
317
  }
318
+ /** Reads the customizable canvas-drawn keyboard-focus-ring stroke color off the host's computed style. */
319
+ focusRingColor() {
320
+ return (getComputedStyle(this).getPropertyValue('--lyra-heatmap-focus-ring-color').trim() || FALLBACK_FOCUS_RING_COLOR);
321
+ }
322
+ /** Reads the customizable canvas-drawn annotation-ring stroke color off the host's computed style. */
323
+ annotationColor() {
324
+ return (getComputedStyle(this).getPropertyValue('--lyra-heatmap-annotation-color').trim() || FALLBACK_ANNOTATION_COLOR);
325
+ }
199
326
  draw() {
200
327
  if (this.mode === 'calendar')
201
328
  this.drawCalendar();
@@ -225,7 +352,7 @@ export class LyraHeatmap extends LyraElement {
225
352
  const [scaleLo, scaleHi] = this.scaleEndpoints();
226
353
  const loRgb = resolveRgb(scaleLo, FALLBACK_SCALE_LO);
227
354
  const hiRgb = resolveRgb(scaleHi, FALLBACK_SCALE_HI);
228
- const buckets = Math.max(2, this.bucketCount);
355
+ const buckets = resolveBucketCount(this.bucketCount);
229
356
  const ramp = Array.from({ length: buckets }, (_, i) => mixRgb(loRgb, hiRgb, i / (buckets - 1)));
230
357
  const noDataFill = this.noDataFill();
231
358
  for (const cell of cells) {
@@ -237,8 +364,37 @@ export class LyraHeatmap extends LyraElement {
237
364
  : ramp[quartileBucket(cell.value, sortedValues, buckets)];
238
365
  ctx.fillRect(x, y, CAL_CELL, CAL_CELL);
239
366
  }
367
+ // Annotation ring overlay, stroked after the fill pass so it reads
368
+ // clearly over the data-driven cell color — see drawMatrix()'s twin of
369
+ // this block for the matrix-mode equivalent.
370
+ if (this.annotations.length) {
371
+ ctx.lineWidth = RING_LINE_WIDTH;
372
+ ctx.strokeStyle = this.annotationColor();
373
+ for (const ann of this.annotations) {
374
+ if (ann.date == null)
375
+ continue;
376
+ const match = cells.find((c) => c.date === ann.date);
377
+ if (!match)
378
+ continue;
379
+ const x = CAL_PAD_LEFT + match.week * (CAL_CELL + CAL_GAP);
380
+ const y = CAL_LABEL_H + match.weekday * (CAL_CELL + CAL_GAP);
381
+ ctx.strokeRect(x + 0.5, y + 0.5, CAL_CELL - 1, CAL_CELL - 1);
382
+ }
383
+ }
384
+ // Keyboard focus ring, redrawn on top of the fill pass (and any
385
+ // annotation rings) on every draw.
386
+ if (this.focusedCell && 'week' in this.focusedCell) {
387
+ const { week, weekday } = this.focusedCell;
388
+ if (week < weekCount && weekday < 7) {
389
+ ctx.lineWidth = RING_LINE_WIDTH;
390
+ ctx.strokeStyle = this.focusRingColor();
391
+ const x = CAL_PAD_LEFT + week * (CAL_CELL + CAL_GAP);
392
+ const y = CAL_LABEL_H + weekday * (CAL_CELL + CAL_GAP);
393
+ ctx.strokeRect(x + 0.5, y + 0.5, CAL_CELL - 1, CAL_CELL - 1);
394
+ }
395
+ }
240
396
  ctx.fillStyle = this.labelColor();
241
- ctx.font = '10px sans-serif';
397
+ ctx.font = this.labelFont();
242
398
  for (const m of monthLabels) {
243
399
  ctx.fillText(m.label, CAL_PAD_LEFT + m.week * (CAL_CELL + CAL_GAP), CAL_LABEL_H - 4);
244
400
  }
@@ -248,16 +404,26 @@ export class LyraHeatmap extends LyraElement {
248
404
  ctx.fillText(label, 2, CAL_LABEL_H + weekday * (CAL_CELL + CAL_GAP) + CAL_CELL - 1);
249
405
  });
250
406
  }
407
+ /**
408
+ * Effective per-cell size in matrix mode — `fitToWidth` derives it from
409
+ * the host's measured width, otherwise it's the fixed `cellSize`
410
+ * property. Shared by `drawMatrix()` and the pointer/keyboard hit-testing
411
+ * below (`hitTestMatrix()`, `cellRect()`) so they always agree on exactly
412
+ * the same geometry as what's actually painted.
413
+ */
414
+ matrixCellSize(cols) {
415
+ if (this.fitToWidth && cols > 0) {
416
+ const hostWidth = this.clientWidth || PAD_LEFT + cols * this.cellSize;
417
+ return Math.max(4, (hostWidth - PAD_LEFT) / cols);
418
+ }
419
+ return this.cellSize;
420
+ }
251
421
  drawMatrix() {
252
422
  if (!this.canvas)
253
423
  return;
254
424
  const rows = this.rowLabels.length;
255
425
  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
- }
426
+ const cellSize = this.matrixCellSize(cols);
261
427
  const w = PAD_LEFT + cols * cellSize;
262
428
  const h = PAD_TOP + rows * cellSize;
263
429
  const dpr = window.devicePixelRatio || 1;
@@ -270,7 +436,7 @@ export class LyraHeatmap extends LyraElement {
270
436
  return;
271
437
  ctx.scale(dpr, dpr);
272
438
  ctx.clearRect(0, 0, w, h);
273
- const bounds = this.valueRange();
439
+ const bounds = this.cachedValueRange;
274
440
  const lo = bounds ? bounds[0] : 0;
275
441
  const hi = bounds ? bounds[1] : 1;
276
442
  const [scaleLo, scaleHi] = this.scaleEndpoints();
@@ -305,8 +471,40 @@ export class LyraHeatmap extends LyraElement {
305
471
  ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
306
472
  }
307
473
  }
474
+ // Annotation ring overlay, stroked after the fill pass so it reads
475
+ // clearly over the data-driven cell color. Uses a dedicated
476
+ // --lyra-heatmap-annotation-color token (defaults to --lyra-color-danger)
477
+ // rather than any of the sequential ramp colors, since it needs to stay
478
+ // visible regardless of which point on that ramp it's drawn over.
479
+ if (this.annotations.length) {
480
+ ctx.lineWidth = RING_LINE_WIDTH;
481
+ ctx.strokeStyle = this.annotationColor();
482
+ for (const ann of this.annotations) {
483
+ if (ann.row == null || ann.col == null)
484
+ continue;
485
+ if (ann.row < 0 || ann.row >= rows || ann.col < 0 || ann.col >= cols)
486
+ continue;
487
+ const x = PAD_LEFT + ann.col * cellSize;
488
+ const y = PAD_TOP + ann.row * cellSize;
489
+ ctx.strokeRect(x + 1, y + 1, cellSize - 3, cellSize - 3);
490
+ }
491
+ }
492
+ // Keyboard focus ring, redrawn on top of the fill pass (and any
493
+ // annotation rings) on every draw — canvas has no persistent DOM focus
494
+ // affordance of its own for an individual cell, only a `:focus-visible`
495
+ // outline around the whole element (see heatmap.styles.ts).
496
+ if (this.focusedCell && 'row' in this.focusedCell) {
497
+ const { row, col } = this.focusedCell;
498
+ if (row < rows && col < cols) {
499
+ ctx.lineWidth = RING_LINE_WIDTH;
500
+ ctx.strokeStyle = this.focusRingColor();
501
+ const x = PAD_LEFT + col * cellSize;
502
+ const y = PAD_TOP + row * cellSize;
503
+ ctx.strokeRect(x + 1, y + 1, cellSize - 3, cellSize - 3);
504
+ }
505
+ }
308
506
  ctx.fillStyle = this.labelColor();
309
- ctx.font = '10px sans-serif';
507
+ ctx.font = this.labelFont();
310
508
  this.rowLabels.forEach((label, r) => {
311
509
  ctx.fillText(label, 4, PAD_TOP + r * cellSize + cellSize / 2 + 3);
312
510
  });
@@ -314,16 +512,206 @@ export class LyraHeatmap extends LyraElement {
314
512
  ctx.fillText(label, PAD_LEFT + c * cellSize + 2, PAD_TOP - 6);
315
513
  });
316
514
  }
515
+ /**
516
+ * Maps a pointer position, in canvas-local CSS px (e.g.
517
+ * `PointerEvent.offsetX/offsetY`), to the cell underneath it — or `null`
518
+ * if the pointer is outside the grid. Dispatches on `mode` so callers
519
+ * don't have to.
520
+ */
521
+ hitTest(x, y) {
522
+ return this.mode === 'calendar' ? this.hitTestCalendar(x, y) : this.hitTestMatrix(x, y);
523
+ }
524
+ hitTestMatrix(x, y) {
525
+ const rows = this.rowLabels.length;
526
+ const cols = this.colLabels.length;
527
+ if (rows === 0 || cols === 0)
528
+ return null;
529
+ const cellSize = this.matrixCellSize(cols);
530
+ const col = Math.floor((x - PAD_LEFT) / cellSize);
531
+ const row = Math.floor((y - PAD_TOP) / cellSize);
532
+ if (row < 0 || row >= rows || col < 0 || col >= cols)
533
+ return null;
534
+ return { row, col };
535
+ }
536
+ hitTestCalendar(x, y) {
537
+ const { weekCount } = buildCalendarGrid(this.days);
538
+ if (weekCount === 0)
539
+ return null;
540
+ const week = Math.floor((x - CAL_PAD_LEFT) / (CAL_CELL + CAL_GAP));
541
+ const weekday = Math.floor((y - CAL_LABEL_H) / (CAL_CELL + CAL_GAP));
542
+ if (week < 0 || week >= weekCount || weekday < 0 || weekday > 6)
543
+ return null;
544
+ return { week, weekday };
545
+ }
546
+ /**
547
+ * Resolves a (week, weekday) grid position to its real calendar date and
548
+ * value. Computed from grid geometry (`firstWeekStart` + offset), not
549
+ * just looked up in `cells` — so a position with no matching entry in
550
+ * `days` (a gap in a sparse calendar) still resolves to a real ISO date,
551
+ * with a `-1` "no data" sentinel value (the same convention `values`' `-1`
552
+ * sentinel uses in matrix mode), instead of being unresolvable.
553
+ */
554
+ calendarCellAt(pos) {
555
+ const { cells, firstWeekStart } = buildCalendarGrid(this.days);
556
+ const match = cells.find((c) => c.week === pos.week && c.weekday === pos.weekday);
557
+ if (match)
558
+ return { date: match.date, value: match.value };
559
+ const date = new Date(firstWeekStart.getTime() + (pos.week * 7 + pos.weekday) * MS_PER_DAY);
560
+ return { date: date.toISOString().slice(0, 10), value: -1 };
561
+ }
562
+ /**
563
+ * Pixel rect (canvas-local CSS px) of a cell — shared by `tooltipStyle()`
564
+ * so the tooltip's position always agrees with the same geometry
565
+ * `drawMatrix()`/`drawCalendar()`/`hitTest*()` use.
566
+ */
567
+ cellRect(pos) {
568
+ if ('week' in pos) {
569
+ return {
570
+ x: CAL_PAD_LEFT + pos.week * (CAL_CELL + CAL_GAP),
571
+ y: CAL_LABEL_H + pos.weekday * (CAL_CELL + CAL_GAP),
572
+ w: CAL_CELL,
573
+ h: CAL_CELL,
574
+ };
575
+ }
576
+ const cellSize = this.matrixCellSize(this.colLabels.length);
577
+ return { x: PAD_LEFT + pos.col * cellSize, y: PAD_TOP + pos.row * cellSize, w: cellSize - 1, h: cellSize - 1 };
578
+ }
579
+ /**
580
+ * Human-readable "<label>: <value>" text for a cell — shared by the hover
581
+ * tooltip and the keyboard-focus live-region announcement, so both always
582
+ * describe a cell the same way.
583
+ */
584
+ cellText(pos) {
585
+ return 'week' in pos ? this.calendarCellText(pos) : this.matrixCellText(pos);
586
+ }
587
+ matrixCellText(pos) {
588
+ const rowLabel = this.rowLabels[pos.row] ?? `row ${pos.row + 1}`;
589
+ const colLabel = this.colLabels[pos.col] ?? `col ${pos.col + 1}`;
590
+ const v = this.values[pos.row]?.[pos.col];
591
+ const valueText = v == null || v < 0 || !Number.isFinite(v) ? 'no data' : String(v);
592
+ return `Row ${rowLabel}, Col ${colLabel}: ${valueText}`;
593
+ }
594
+ calendarCellText(pos) {
595
+ const { date, value } = this.calendarCellAt(pos);
596
+ const label = parseIsoDate(date).toLocaleString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' });
597
+ const valueText = value < 0 || !Number.isFinite(value) ? 'no data' : String(value);
598
+ return `${label}: ${valueText}`;
599
+ }
600
+ /** Refreshes the visually-hidden live-region text for a newly-focused cell. */
601
+ announce(pos) {
602
+ this.liveText = this.cellText(pos);
603
+ }
604
+ emitCellClick(pos) {
605
+ if ('week' in pos) {
606
+ const { date, value } = this.calendarCellAt(pos);
607
+ this.emit('lyra-cell-click', { date, value });
608
+ }
609
+ else {
610
+ const value = this.values[pos.row]?.[pos.col] ?? -1;
611
+ this.emit('lyra-cell-click', { row: pos.row, col: pos.col, value });
612
+ }
613
+ }
614
+ /** Inline `style` for `[part="tooltip"]`, centered above the given cell. */
615
+ tooltipStyle(pos) {
616
+ const rect = this.cellRect(pos);
617
+ return { left: `${rect.x + rect.w / 2}px`, top: `${rect.y}px` };
618
+ }
619
+ onMatrixKeyDown(e) {
620
+ const rows = this.rowLabels.length;
621
+ const cols = this.colLabels.length;
622
+ if (rows === 0 || cols === 0)
623
+ return;
624
+ if (e.key === 'Enter' || e.key === ' ') {
625
+ e.preventDefault();
626
+ if (this.focusedCell)
627
+ this.emitCellClick(this.focusedCell);
628
+ return;
629
+ }
630
+ if (!ARROW_KEYS.has(e.key))
631
+ return;
632
+ e.preventDefault();
633
+ // The first arrow keypress just moves focus onto the grid (at the
634
+ // top-left cell) rather than also applying that keypress's delta —
635
+ // otherwise ArrowRight from "unfocused" would land on column 1,
636
+ // silently skipping column 0.
637
+ if (!this.focusedCell || !('row' in this.focusedCell)) {
638
+ const next = { row: 0, col: 0 };
639
+ this.focusedCell = next;
640
+ this.announce(next);
641
+ return;
642
+ }
643
+ const { row, col } = this.focusedCell;
644
+ let next = { row, col };
645
+ if (e.key === 'ArrowUp')
646
+ next = { row: Math.max(0, row - 1), col };
647
+ else if (e.key === 'ArrowDown')
648
+ next = { row: Math.min(rows - 1, row + 1), col };
649
+ else if (e.key === 'ArrowLeft')
650
+ next = { row, col: Math.max(0, col - 1) };
651
+ else if (e.key === 'ArrowRight')
652
+ next = { row, col: Math.min(cols - 1, col + 1) };
653
+ this.focusedCell = next;
654
+ this.announce(next);
655
+ }
656
+ onCalendarKeyDown(e) {
657
+ const { weekCount } = buildCalendarGrid(this.days);
658
+ if (weekCount === 0)
659
+ return;
660
+ if (e.key === 'Enter' || e.key === ' ') {
661
+ e.preventDefault();
662
+ if (this.focusedCell)
663
+ this.emitCellClick(this.focusedCell);
664
+ return;
665
+ }
666
+ if (!ARROW_KEYS.has(e.key))
667
+ return;
668
+ e.preventDefault();
669
+ if (!this.focusedCell || !('week' in this.focusedCell)) {
670
+ const next = { week: 0, weekday: 0 };
671
+ this.focusedCell = next;
672
+ this.announce(next);
673
+ return;
674
+ }
675
+ const { week, weekday } = this.focusedCell;
676
+ let next = { week, weekday };
677
+ if (e.key === 'ArrowUp')
678
+ next = { week, weekday: Math.max(0, weekday - 1) };
679
+ else if (e.key === 'ArrowDown')
680
+ next = { week, weekday: Math.min(6, weekday + 1) };
681
+ else if (e.key === 'ArrowLeft')
682
+ next = { week: Math.max(0, week - 1), weekday };
683
+ else if (e.key === 'ArrowRight')
684
+ next = { week: Math.min(weekCount - 1, week + 1), weekday };
685
+ this.focusedCell = next;
686
+ this.announce(next);
687
+ }
317
688
  render() {
318
- const range = this.valueRange();
689
+ const range = this.cachedValueRange;
690
+ const labeledAnnotations = this.annotations.filter((a) => a.label);
319
691
  return html `
320
692
  <div part="base">
321
- <canvas part="canvas"></canvas>
693
+ <canvas
694
+ part="canvas"
695
+ tabindex="0"
696
+ @pointermove=${this.onPointerMove}
697
+ @pointerleave=${this.onPointerLeave}
698
+ @click=${this.onCanvasClick}
699
+ @keydown=${this.onKeyDown}
700
+ ></canvas>
701
+ <div
702
+ part="tooltip"
703
+ ?hidden=${!this.hoverCell}
704
+ style=${styleMap(this.hoverCell ? this.tooltipStyle(this.hoverCell) : {})}
705
+ >
706
+ ${this.hoverCell ? this.cellText(this.hoverCell) : ''}
707
+ </div>
708
+ <div part="live-region" class="sr-only" role="status" aria-live="polite">${this.liveText}</div>
322
709
  <div part="legend">
323
710
  <span part="legend-lo">${range ? range[0] : ''}</span>
324
711
  <span class="bar"></span>
325
712
  <span part="legend-hi">${range ? range[1] : ''}</span>
326
713
  <span>${this.valueLabel}</span>
714
+ ${labeledAnnotations.map((a) => html `<span part="legend-annotation"><span class="ring-swatch"></span>${a.label}</span>`)}
327
715
  </div>
328
716
  </div>
329
717
  `;
@@ -359,7 +747,19 @@ __decorate([
359
747
  __decorate([
360
748
  property({ attribute: 'bucket-count', type: Number })
361
749
  ], LyraHeatmap.prototype, "bucketCount", void 0);
750
+ __decorate([
751
+ property({ attribute: false })
752
+ ], LyraHeatmap.prototype, "annotations", void 0);
362
753
  __decorate([
363
754
  query('canvas')
364
755
  ], LyraHeatmap.prototype, "canvas", void 0);
756
+ __decorate([
757
+ state()
758
+ ], LyraHeatmap.prototype, "hoverCell", void 0);
759
+ __decorate([
760
+ state()
761
+ ], LyraHeatmap.prototype, "focusedCell", void 0);
762
+ __decorate([
763
+ state()
764
+ ], LyraHeatmap.prototype, "liveText", void 0);
365
765
  defineElement('heatmap', LyraHeatmap);
@@ -3,3 +3,20 @@ 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
+ /** `annotations` in calendar mode match by ISO `date` instead of `row`/`col`. */
22
+ export declare const CalendarAnnotations: Story;