@aceshooting/lyra-ui 0.1.3 → 0.1.4

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 (80) hide show
  1. package/README.md +2 -8
  2. package/custom-elements.json +4222 -3430
  3. package/dist/components/chart/bar-chart.js +17 -4
  4. package/dist/components/chart/box-plot.d.ts +8 -1
  5. package/dist/components/chart/box-plot.js +40 -4
  6. package/dist/components/chart/box-plot.styles.js +4 -0
  7. package/dist/components/chart/bubble-chart.d.ts +1 -2
  8. package/dist/components/chart/bubble-chart.js +17 -4
  9. package/dist/components/chart/chart-loader.d.ts +21 -4
  10. package/dist/components/chart/chart-loader.js +39 -12
  11. package/dist/components/chart/chart.d.ts +9 -3
  12. package/dist/components/chart/chart.js +42 -21
  13. package/dist/components/chart/doughnut-chart.d.ts +1 -2
  14. package/dist/components/chart/doughnut-chart.js +17 -4
  15. package/dist/components/chart/histogram-bin.js +12 -2
  16. package/dist/components/chart/histogram.d.ts +2 -2
  17. package/dist/components/chart/histogram.js +16 -2
  18. package/dist/components/chart/line-chart.js +17 -4
  19. package/dist/components/chart/pie-chart.d.ts +1 -2
  20. package/dist/components/chart/pie-chart.js +17 -4
  21. package/dist/components/chart/polar-area-chart.d.ts +1 -2
  22. package/dist/components/chart/polar-area-chart.js +17 -4
  23. package/dist/components/chart/radar-chart.d.ts +1 -2
  24. package/dist/components/chart/radar-chart.js +17 -4
  25. package/dist/components/chart/scatter-chart.js +17 -4
  26. package/dist/components/combobox/combobox.js +3 -4
  27. package/dist/components/combobox/combobox.stories.d.ts +6 -0
  28. package/dist/components/combobox/combobox.stories.js +27 -0
  29. package/dist/components/combobox/combobox.styles.js +2 -2
  30. package/dist/components/date-picker/date-input.stories.d.ts +5 -0
  31. package/dist/components/date-picker/date-input.stories.js +12 -0
  32. package/dist/components/date-picker/date-input.styles.js +2 -2
  33. package/dist/components/date-picker/date-picker.stories.d.ts +6 -0
  34. package/dist/components/date-picker/date-picker.stories.js +13 -0
  35. package/dist/components/empty/empty.d.ts +1 -1
  36. package/dist/components/empty/empty.js +1 -1
  37. package/dist/components/export-button/export-button.d.ts +2 -2
  38. package/dist/components/export-button/export-button.js +2 -2
  39. package/dist/components/file-input/accept.d.ts +1 -2
  40. package/dist/components/file-input/accept.js +1 -2
  41. package/dist/components/file-input/file-input.d.ts +2 -2
  42. package/dist/components/file-input/file-input.js +2 -2
  43. package/dist/components/flag/flag.stories.d.ts +5 -0
  44. package/dist/components/flag/flag.stories.js +17 -0
  45. package/dist/components/flag/flag.styles.js +1 -1
  46. package/dist/components/gauge/gauge.d.ts +1 -1
  47. package/dist/components/gauge/gauge.js +1 -1
  48. package/dist/components/graph/graph.d.ts +33 -4
  49. package/dist/components/graph/graph.js +146 -30
  50. package/dist/components/heatmap/calendar-grid.d.ts +31 -0
  51. package/dist/components/heatmap/calendar-grid.js +52 -0
  52. package/dist/components/heatmap/heatmap-scale.d.ts +8 -0
  53. package/dist/components/heatmap/heatmap-scale.js +21 -0
  54. package/dist/components/heatmap/heatmap.d.ts +19 -0
  55. package/dist/components/heatmap/heatmap.js +133 -20
  56. package/dist/components/heatmap/heatmap.styles.js +4 -0
  57. package/dist/components/map/map.d.ts +15 -0
  58. package/dist/components/map/map.js +89 -7
  59. package/dist/components/playback/playback.d.ts +2 -2
  60. package/dist/components/playback/playback.js +2 -2
  61. package/dist/components/skeleton/skeleton.d.ts +2 -2
  62. package/dist/components/skeleton/skeleton.js +2 -2
  63. package/dist/components/sparkline/sparkline.stories.d.ts +7 -0
  64. package/dist/components/sparkline/sparkline.stories.js +17 -0
  65. package/dist/components/stat/stat.d.ts +1 -1
  66. package/dist/components/stat/stat.js +6 -7
  67. package/dist/components/table/table.d.ts +1 -2
  68. package/dist/components/table/table.js +1 -2
  69. package/dist/components/table/table.styles.js +2 -3
  70. package/dist/components/time-range/time-range.js +4 -4
  71. package/dist/components/toast/toast.stories.d.ts +5 -0
  72. package/dist/components/toast/toast.stories.js +32 -0
  73. package/dist/components/tree/tree-node.d.ts +2 -2
  74. package/dist/components/tree/tree-node.js +2 -2
  75. package/dist/internal/form-associated.js +1 -2
  76. package/dist/internal/icons.js +1 -1
  77. package/dist/internal/tokens.styles.js +3 -3
  78. package/llms-full.txt +59 -83
  79. package/llms.txt +1 -2
  80. package/package.json +4 -1
@@ -8,14 +8,19 @@ import { html } from 'lit';
8
8
  import { property, query } from 'lit/decorators.js';
9
9
  import { LyraElement } from '../../internal/lyra-element.js';
10
10
  import { defineElement } from '../../internal/prefix.js';
11
- import { linearAlpha, sqrtStep } from './heatmap-scale.js';
11
+ import { linearAlpha, minMax, sqrtStep } from './heatmap-scale.js';
12
12
  import { styles } from './heatmap.styles.js';
13
+ import { buildCalendarGrid, quartileBucket } from './calendar-grid.js';
13
14
  const PAD_LEFT = 60;
14
15
  const PAD_TOP = 20;
15
- const NO_DATA_FILL = 'rgba(128,128,128,0.25)';
16
+ const FALLBACK_NO_DATA_FILL = 'rgba(128,128,128,0.25)';
16
17
  const RAMP_STEPS = 7;
17
18
  const FALLBACK_SCALE_LO = '#cde2fb';
18
19
  const FALLBACK_SCALE_HI = '#0969da';
20
+ const CAL_PAD_LEFT = 28;
21
+ const CAL_LABEL_H = 16;
22
+ const CAL_CELL = 11;
23
+ const CAL_GAP = 2;
19
24
  const HEX_RE = /^([0-9a-f]{3}|[0-9a-f]{6})$/i;
20
25
  const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i;
21
26
  /**
@@ -117,6 +122,18 @@ export class LyraHeatmap extends LyraElement {
117
122
  this.cellSize = 22;
118
123
  this.valueLabel = 'value';
119
124
  this.scale = 'linear';
125
+ /**
126
+ * When set, `cellSize` is derived from the host's measured `clientWidth`
127
+ * on every draw (including ResizeObserver-triggered redraws) instead of
128
+ * the fixed `cell-size` attribute, so the grid actually fills the
129
+ * available width. Without this, canvas dimensions are computed purely
130
+ * from `PAD_LEFT + cols * cellSize`, so a resize-triggered redraw is a
131
+ * geometric no-op.
132
+ */
133
+ this.fitToWidth = false;
134
+ this.mode = 'matrix';
135
+ this.days = [];
136
+ this.bucketCount = 5;
120
137
  this.onDprChange = () => {
121
138
  this.watchDpr();
122
139
  this.draw();
@@ -135,16 +152,31 @@ export class LyraHeatmap extends LyraElement {
135
152
  this.dprQuery?.removeEventListener('change', this.onDprChange);
136
153
  }
137
154
  watchDpr() {
155
+ // A MediaQueryList's `matches` is fixed at creation time, so crossing the
156
+ // DPR threshold it was built for means building a fresh one for the new
157
+ // ratio — remove the previous instance's listener first, or it leaks
158
+ // (disconnectedCallback only ever cleans up whichever is current).
159
+ this.dprQuery?.removeEventListener('change', this.onDprChange);
138
160
  this.dprQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
139
161
  this.dprQuery.addEventListener('change', this.onDprChange);
140
162
  }
141
163
  willUpdate() {
142
- const rows = this.rowLabels.length;
143
- const cols = this.colLabels.length;
144
- const flat = this.values.flat().filter((v) => v >= 0);
145
- const range = flat.length ? `${Math.min(...flat)}–${Math.max(...flat)}` : 'no data';
164
+ const bounds = this.valueRange();
165
+ const range = bounds ? `${bounds[0]}–${bounds[1]}` : 'no data';
146
166
  this.setAttribute('role', 'img');
147
- this.setAttribute('aria-label', `Heatmap of ${rows} × ${cols} cells, ${this.valueLabel} range ${range}`);
167
+ if (this.mode === 'calendar') {
168
+ this.setAttribute('aria-label', `Calendar heatmap of ${this.days.length} days, ${this.valueLabel} range ${range}`);
169
+ }
170
+ else {
171
+ const rows = this.rowLabels.length;
172
+ const cols = this.colLabels.length;
173
+ this.setAttribute('aria-label', `Heatmap of ${rows} × ${cols} cells, ${this.valueLabel} range ${range}`);
174
+ }
175
+ }
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() {
178
+ const source = this.mode === 'calendar' ? this.days.map((d) => d.value) : this.values.flat();
179
+ return minMax(source.filter((v) => Number.isFinite(v) && v >= 0));
148
180
  }
149
181
  updated() {
150
182
  this.draw();
@@ -160,13 +192,74 @@ export class LyraHeatmap extends LyraElement {
160
192
  labelColor() {
161
193
  return getComputedStyle(this).getPropertyValue('--lyra-color-text-quiet').trim() || '#6b7280';
162
194
  }
195
+ /** Reads the customizable no-data cell fill off the host's computed style. */
196
+ noDataFill() {
197
+ return getComputedStyle(this).getPropertyValue('--lyra-heatmap-no-data-fill').trim() || FALLBACK_NO_DATA_FILL;
198
+ }
163
199
  draw() {
200
+ if (this.mode === 'calendar')
201
+ this.drawCalendar();
202
+ else
203
+ this.drawMatrix();
204
+ }
205
+ drawCalendar() {
206
+ if (!this.canvas)
207
+ return;
208
+ const { cells, weekCount, monthLabels } = buildCalendarGrid(this.days);
209
+ const w = CAL_PAD_LEFT + Math.max(1, weekCount) * (CAL_CELL + CAL_GAP);
210
+ const h = CAL_LABEL_H + 7 * (CAL_CELL + CAL_GAP);
211
+ const dpr = window.devicePixelRatio || 1;
212
+ this.canvas.width = w * dpr;
213
+ this.canvas.height = h * dpr;
214
+ this.canvas.style.width = `${w}px`;
215
+ this.canvas.style.height = `${h}px`;
216
+ const ctx = this.canvas.getContext('2d');
217
+ if (!ctx)
218
+ return;
219
+ ctx.scale(dpr, dpr);
220
+ ctx.clearRect(0, 0, w, h);
221
+ const sortedValues = cells
222
+ .map((c) => c.value)
223
+ .filter((v) => Number.isFinite(v) && v >= 0)
224
+ .sort((a, b) => a - b);
225
+ const [scaleLo, scaleHi] = this.scaleEndpoints();
226
+ const loRgb = resolveRgb(scaleLo, FALLBACK_SCALE_LO);
227
+ const hiRgb = resolveRgb(scaleHi, FALLBACK_SCALE_HI);
228
+ const buckets = Math.max(2, this.bucketCount);
229
+ const ramp = Array.from({ length: buckets }, (_, i) => mixRgb(loRgb, hiRgb, i / (buckets - 1)));
230
+ const noDataFill = this.noDataFill();
231
+ for (const cell of cells) {
232
+ const x = CAL_PAD_LEFT + cell.week * (CAL_CELL + CAL_GAP);
233
+ const y = CAL_LABEL_H + cell.weekday * (CAL_CELL + CAL_GAP);
234
+ ctx.fillStyle =
235
+ cell.value < 0 || !Number.isFinite(cell.value)
236
+ ? noDataFill
237
+ : ramp[quartileBucket(cell.value, sortedValues, buckets)];
238
+ ctx.fillRect(x, y, CAL_CELL, CAL_CELL);
239
+ }
240
+ ctx.fillStyle = this.labelColor();
241
+ ctx.font = '10px sans-serif';
242
+ for (const m of monthLabels) {
243
+ ctx.fillText(m.label, CAL_PAD_LEFT + m.week * (CAL_CELL + CAL_GAP), CAL_LABEL_H - 4);
244
+ }
245
+ const WEEKDAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
246
+ WEEKDAY_LABELS.forEach((label, weekday) => {
247
+ if (label)
248
+ ctx.fillText(label, 2, CAL_LABEL_H + weekday * (CAL_CELL + CAL_GAP) + CAL_CELL - 1);
249
+ });
250
+ }
251
+ drawMatrix() {
164
252
  if (!this.canvas)
165
253
  return;
166
254
  const rows = this.rowLabels.length;
167
255
  const cols = this.colLabels.length;
168
- const w = PAD_LEFT + cols * this.cellSize;
169
- const h = PAD_TOP + rows * this.cellSize;
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
+ }
261
+ const w = PAD_LEFT + cols * cellSize;
262
+ const h = PAD_TOP + rows * cellSize;
170
263
  const dpr = window.devicePixelRatio || 1;
171
264
  this.canvas.width = w * dpr;
172
265
  this.canvas.height = h * dpr;
@@ -177,24 +270,29 @@ export class LyraHeatmap extends LyraElement {
177
270
  return;
178
271
  ctx.scale(dpr, dpr);
179
272
  ctx.clearRect(0, 0, w, h);
180
- const flat = this.values.flat().filter((v) => v >= 0);
181
- const lo = flat.length ? Math.min(...flat) : 0;
182
- const hi = flat.length ? Math.max(...flat) : 1;
273
+ const bounds = this.valueRange();
274
+ const lo = bounds ? bounds[0] : 0;
275
+ const hi = bounds ? bounds[1] : 1;
183
276
  const [scaleLo, scaleHi] = this.scaleEndpoints();
184
277
  const loRgb = resolveRgb(scaleLo, FALLBACK_SCALE_LO);
185
278
  const hiRgb = resolveRgb(scaleHi, FALLBACK_SCALE_HI);
186
279
  const ramp = Array.from({ length: RAMP_STEPS }, (_, i) => mixRgb(loRgb, hiRgb, i / (RAMP_STEPS - 1)));
280
+ const noDataFill = this.noDataFill();
187
281
  for (let r = 0; r < rows; r++) {
188
282
  for (let c = 0; c < cols; c++) {
189
283
  const v = this.values[r]?.[c] ?? -1;
190
- const x = PAD_LEFT + c * this.cellSize;
191
- const y = PAD_TOP + r * this.cellSize;
192
- if (v < 0) {
193
- ctx.fillStyle = NO_DATA_FILL;
284
+ const x = PAD_LEFT + c * cellSize;
285
+ const y = PAD_TOP + r * cellSize;
286
+ if (v < 0 || !Number.isFinite(v)) {
287
+ // `v < 0` alone is false for NaN, which would otherwise fall through to
288
+ // the ramp branches below, compute an unparsable rgb(NaN, NaN, NaN)
289
+ // fillStyle, and silently paint with whatever color the previous cell
290
+ // left in `ctx.fillStyle` (canvas ignores unparsable assignments).
291
+ ctx.fillStyle = noDataFill;
194
292
  }
195
293
  else if (this.scale === 'sqrt') {
196
294
  const step = sqrtStep(v, hi, RAMP_STEPS);
197
- ctx.fillStyle = step < 0 ? NO_DATA_FILL : ramp[step];
295
+ ctx.fillStyle = step < 0 ? noDataFill : ramp[step];
198
296
  }
199
297
  else {
200
298
  // linearAlpha() returns a 0.1-1.0 ramp position; reused here as a
@@ -204,24 +302,27 @@ export class LyraHeatmap extends LyraElement {
204
302
  const t = linearAlpha(v, lo, hi);
205
303
  ctx.fillStyle = mixRgb(loRgb, hiRgb, t);
206
304
  }
207
- ctx.fillRect(x, y, this.cellSize - 1, this.cellSize - 1);
305
+ ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
208
306
  }
209
307
  }
210
308
  ctx.fillStyle = this.labelColor();
211
309
  ctx.font = '10px sans-serif';
212
310
  this.rowLabels.forEach((label, r) => {
213
- ctx.fillText(label, 4, PAD_TOP + r * this.cellSize + this.cellSize / 2 + 3);
311
+ ctx.fillText(label, 4, PAD_TOP + r * cellSize + cellSize / 2 + 3);
214
312
  });
215
313
  this.colLabels.forEach((label, c) => {
216
- ctx.fillText(label, PAD_LEFT + c * this.cellSize + 2, PAD_TOP - 6);
314
+ ctx.fillText(label, PAD_LEFT + c * cellSize + 2, PAD_TOP - 6);
217
315
  });
218
316
  }
219
317
  render() {
318
+ const range = this.valueRange();
220
319
  return html `
221
320
  <div part="base">
222
321
  <canvas part="canvas"></canvas>
223
322
  <div part="legend">
323
+ <span part="legend-lo">${range ? range[0] : ''}</span>
224
324
  <span class="bar"></span>
325
+ <span part="legend-hi">${range ? range[1] : ''}</span>
225
326
  <span>${this.valueLabel}</span>
226
327
  </div>
227
328
  </div>
@@ -246,6 +347,18 @@ __decorate([
246
347
  __decorate([
247
348
  property()
248
349
  ], LyraHeatmap.prototype, "scale", void 0);
350
+ __decorate([
351
+ property({ type: Boolean, attribute: 'fit-to-width' })
352
+ ], LyraHeatmap.prototype, "fitToWidth", void 0);
353
+ __decorate([
354
+ property()
355
+ ], LyraHeatmap.prototype, "mode", void 0);
356
+ __decorate([
357
+ property({ attribute: false })
358
+ ], LyraHeatmap.prototype, "days", void 0);
359
+ __decorate([
360
+ property({ attribute: 'bucket-count', type: Number })
361
+ ], LyraHeatmap.prototype, "bucketCount", void 0);
249
362
  __decorate([
250
363
  query('canvas')
251
364
  ], LyraHeatmap.prototype, "canvas", void 0);
@@ -9,6 +9,10 @@ export const styles = css `
9
9
  the ramp without raw hex leaking into the public API. */
10
10
  --lyra-heatmap-scale-lo: #cde2fb;
11
11
  --lyra-heatmap-scale-hi: #0969da;
12
+ /* No-data cell fill (the -1 sentinel / NaN case) — same resolve-via-
13
+ getComputedStyle pattern as the ramp endpoints above, so hosts can
14
+ retheme it instead of it being a hardcoded literal in heatmap.ts. */
15
+ --lyra-heatmap-no-data-fill: rgba(128, 128, 128, 0.25);
12
16
  }
13
17
  [part='base'] {
14
18
  display: flex;
@@ -12,6 +12,13 @@ export interface ChoroplethLayer {
12
12
  field: string;
13
13
  stops: [number, string][];
14
14
  }
15
+ export interface MapMarker {
16
+ id?: string;
17
+ lngLat: [number, number];
18
+ color?: string;
19
+ label?: string;
20
+ html?: string;
21
+ }
15
22
  /**
16
23
  * `<lyra-map>` — a maplibre-gl wrapper with a declarative legend and
17
24
  * choropleth GeoJSON layer, plus a raw `map` escape hatch. Requires the
@@ -29,17 +36,25 @@ export declare class LyraMap extends LyraElement {
29
36
  mapStyle: StyleSpecification | string;
30
37
  legend: LegendEntry[];
31
38
  choropleth?: ChoroplethLayer;
39
+ markers: MapMarker[];
32
40
  /** True until the lazy-loaded `maplibre-gl` peer dependency has settled (success or failure). */
33
41
  private loading;
34
42
  private containerEl?;
35
43
  private _map?;
36
44
  private _styleLoaded;
45
+ private _appliedChoroplethSourceId?;
46
+ private _appliedFillLayerId?;
47
+ private _maplibreModule;
48
+ private _markerInstances;
37
49
  /** The raw `maplibregl.Map` instance — escape hatch for anything this wrapper doesn't expose. */
38
50
  get map(): MaplibreMap | undefined;
39
51
  connectedCallback(): void;
40
52
  disconnectedCallback(): void;
41
53
  protected updated(changed: PropertyValues): void;
42
54
  private applyChoropleth;
55
+ /** Removes whatever choropleth layer/source is currently applied, if any. */
56
+ private removeChoropleth;
57
+ private applyMarkers;
43
58
  render(): TemplateResult;
44
59
  }
45
60
  declare global {
@@ -41,6 +41,7 @@ export class LyraMap extends LyraElement {
41
41
  this.zoom = 2;
42
42
  this.mapStyle = DEFAULT_STYLE;
43
43
  this.legend = [];
44
+ this.markers = [];
44
45
  /** True until the lazy-loaded `maplibre-gl` peer dependency has settled (success or failure). */
45
46
  this.loading = true;
46
47
  // Tracks whether the style has fired its initial 'load' (i.e. addSource/
@@ -50,6 +51,7 @@ export class LyraMap extends LyraElement {
50
51
  // own GeoJSON source is added — which would wrongly block subsequent
51
52
  // choropleth updates from ever re-running.
52
53
  this._styleLoaded = false;
54
+ this._markerInstances = new Map();
53
55
  }
54
56
  static { this.styles = [LyraElement.styles, styles]; }
55
57
  /** The raw `maplibregl.Map` instance — escape hatch for anything this wrapper doesn't expose. */
@@ -62,6 +64,7 @@ export class LyraMap extends LyraElement {
62
64
  this.loading = false;
63
65
  if (!mod)
64
66
  return;
67
+ this._maplibreModule = mod;
65
68
  // `[part="container"]` only exists once `loading` flips to `false` and
66
69
  // Lit re-renders — wait for that render to land before querying it.
67
70
  await this.updateComplete;
@@ -80,14 +83,11 @@ export class LyraMap extends LyraElement {
80
83
  this._map.on('load', () => {
81
84
  this._styleLoaded = true;
82
85
  this.applyChoropleth();
86
+ this.applyMarkers();
83
87
  this.emit('lyra-map-load');
84
88
  });
85
89
  this._map.on('click', (e) => {
86
- // Guard on the fill layer actually existing, not just `this.choropleth` being
87
- // set: applyChoropleth() only adds it once the style has finished loading, so
88
- // a click landing in that window (choropleth set before the map's own 'load')
89
- // would otherwise query a layer id maplibre-gl doesn't know about yet.
90
- const fillLayerId = this.choropleth ? `${this.choropleth.sourceId}-fill` : undefined;
90
+ const fillLayerId = this._appliedFillLayerId;
91
91
  const features = fillLayerId && this._map.getLayer(fillLayerId)
92
92
  ? this._map.queryRenderedFeatures(e.point, { layers: [fillLayerId] })
93
93
  : [];
@@ -103,24 +103,53 @@ export class LyraMap extends LyraElement {
103
103
  this._map?.remove();
104
104
  this._map = undefined;
105
105
  this._styleLoaded = false;
106
+ this._appliedChoroplethSourceId = undefined;
107
+ this._appliedFillLayerId = undefined;
108
+ for (const marker of this._markerInstances.values())
109
+ marker.remove();
110
+ this._markerInstances.clear();
106
111
  }
107
112
  updated(changed) {
108
113
  if (this.loading)
109
114
  this.setAttribute('aria-busy', 'true');
110
115
  else
111
116
  this.removeAttribute('aria-busy');
112
- if (changed.has('choropleth') && this._styleLoaded)
117
+ if (changed.has('mapStyle') && this._map) {
118
+ // A style change wipes every layer/source maplibre-gl knows about, so
119
+ // the previously-applied choropleth bookkeeping is stale the instant
120
+ // setStyle() is called — clear it and re-apply once the new style's
121
+ // own 'style.load' fires (mirrors the constructor's 'load' handshake).
122
+ this._styleLoaded = false;
123
+ this._appliedChoroplethSourceId = undefined;
124
+ this._appliedFillLayerId = undefined;
125
+ this._map.setStyle(this.mapStyle);
126
+ this._map.once('style.load', () => {
127
+ this._styleLoaded = true;
128
+ this.applyChoropleth();
129
+ });
130
+ }
131
+ else if (changed.has('choropleth') && this._styleLoaded) {
113
132
  this.applyChoropleth();
133
+ }
114
134
  if (changed.has('center') && this._map)
115
135
  this._map.setCenter(this.center);
116
136
  if (changed.has('zoom') && this._map)
117
137
  this._map.setZoom(this.zoom);
138
+ if (changed.has('markers') && this._map)
139
+ this.applyMarkers();
118
140
  }
119
141
  applyChoropleth() {
120
- if (!this.choropleth || !this._map)
142
+ if (!this._map)
121
143
  return;
144
+ if (!this.choropleth) {
145
+ this.removeChoropleth();
146
+ return;
147
+ }
122
148
  const { sourceId, geojson, field, stops } = this.choropleth;
123
149
  const fillLayerId = `${sourceId}-fill`;
150
+ if (this._appliedChoroplethSourceId && this._appliedChoroplethSourceId !== sourceId) {
151
+ this.removeChoropleth();
152
+ }
124
153
  const colorExpr = ['interpolate', ['linear'], ['get', field]];
125
154
  for (const [value, color] of stops)
126
155
  colorExpr.push(value, color);
@@ -141,6 +170,56 @@ export class LyraMap extends LyraElement {
141
170
  paint: { 'fill-color': colorExpr, 'fill-opacity': 0.75 },
142
171
  });
143
172
  }
173
+ this._appliedChoroplethSourceId = sourceId;
174
+ this._appliedFillLayerId = fillLayerId;
175
+ }
176
+ /** Removes whatever choropleth layer/source is currently applied, if any. */
177
+ removeChoropleth() {
178
+ if (!this._map || !this._appliedChoroplethSourceId)
179
+ return;
180
+ if (this._appliedFillLayerId && this._map.getLayer(this._appliedFillLayerId)) {
181
+ this._map.removeLayer(this._appliedFillLayerId);
182
+ }
183
+ if (this._map.getSource(this._appliedChoroplethSourceId)) {
184
+ this._map.removeSource(this._appliedChoroplethSourceId);
185
+ }
186
+ this._appliedChoroplethSourceId = undefined;
187
+ this._appliedFillLayerId = undefined;
188
+ }
189
+ async applyMarkers() {
190
+ if (!this._map)
191
+ return;
192
+ const mod = await loadMaplibre();
193
+ if (!mod)
194
+ return;
195
+ const visible = new Set();
196
+ for (const m of this.markers) {
197
+ const key = m.id ?? `${m.lngLat[0]},${m.lngLat[1]}`;
198
+ visible.add(key);
199
+ const existing = this._markerInstances.get(key);
200
+ if (!existing) {
201
+ const marker = new mod.Marker(m.color ? { color: m.color } : undefined).setLngLat(m.lngLat);
202
+ if (m.html || m.label) {
203
+ const popup = new mod.Popup({ offset: 12 });
204
+ if (m.html)
205
+ popup.setHTML(m.html);
206
+ else if (m.label)
207
+ popup.setText(m.label);
208
+ marker.setPopup(popup);
209
+ }
210
+ marker.addTo(this._map);
211
+ this._markerInstances.set(key, marker);
212
+ }
213
+ else {
214
+ existing.setLngLat(m.lngLat);
215
+ }
216
+ }
217
+ for (const [key, marker] of this._markerInstances) {
218
+ if (!visible.has(key)) {
219
+ marker.remove();
220
+ this._markerInstances.delete(key);
221
+ }
222
+ }
144
223
  }
145
224
  render() {
146
225
  // Lit's own whitespace/comment marker nodes around the `${...}` binding mean
@@ -180,6 +259,9 @@ __decorate([
180
259
  __decorate([
181
260
  property({ attribute: false })
182
261
  ], LyraMap.prototype, "choropleth", void 0);
262
+ __decorate([
263
+ property({ attribute: false })
264
+ ], LyraMap.prototype, "markers", void 0);
183
265
  __decorate([
184
266
  state()
185
267
  ], LyraMap.prototype, "loading", void 0);
@@ -2,8 +2,8 @@ import { type TemplateResult, type PropertyValues } from 'lit';
2
2
  import { LyraElement } from '../../internal/lyra-element.js';
3
3
  /**
4
4
  * `<lyra-playback>` — steps an index through `[0, length)` on a fixed
5
- * interval (play/pause), like the manual play-timers duplicated across
6
- * per-repo time-series dashboards.
5
+ * interval (play/pause), the common building block behind ad-hoc
6
+ * play-timers in time-series dashboards.
7
7
  *
8
8
  * @customElement lyra-playback
9
9
  * @event lyra-play
@@ -12,8 +12,8 @@ import { playIcon, pauseIcon } from '../../internal/icons.js';
12
12
  import { styles } from './playback.styles.js';
13
13
  /**
14
14
  * `<lyra-playback>` — steps an index through `[0, length)` on a fixed
15
- * interval (play/pause), like the manual play-timers duplicated across
16
- * per-repo time-series dashboards.
15
+ * interval (play/pause), the common building block behind ad-hoc
16
+ * play-timers in time-series dashboards.
17
17
  *
18
18
  * @customElement lyra-playback
19
19
  * @event lyra-play
@@ -3,8 +3,8 @@ import { LyraElement } from '../../internal/lyra-element.js';
3
3
  export type SkeletonVariant = 'text' | 'circle' | 'rect';
4
4
  export type SkeletonEffect = 'pulse' | 'sheen';
5
5
  /**
6
- * `<lyra-skeleton>` — a loading placeholder. First-party invention; every
7
- * surveyed repo reinvents this as a bespoke `animate-pulse` div.
6
+ * `<lyra-skeleton>` — a loading placeholder. First-party invention, standing
7
+ * in for the bespoke `animate-pulse` div most dashboards hand-roll.
8
8
  *
9
9
  * @customElement lyra-skeleton
10
10
  * @csspart base - The placeholder shape.
@@ -11,8 +11,8 @@ import { defineElement } from '../../internal/prefix.js';
11
11
  import { srOnly } from '../../internal/a11y.js';
12
12
  import { styles } from './skeleton.styles.js';
13
13
  /**
14
- * `<lyra-skeleton>` — a loading placeholder. First-party invention; every
15
- * surveyed repo reinvents this as a bespoke `animate-pulse` div.
14
+ * `<lyra-skeleton>` — a loading placeholder. First-party invention, standing
15
+ * in for the bespoke `animate-pulse` div most dashboards hand-roll.
16
16
  *
17
17
  * @customElement lyra-skeleton
18
18
  * @csspart base - The placeholder shape.
@@ -0,0 +1,7 @@
1
+ import type { Meta, StoryObj } from '@storybook/web-components-vite';
2
+ declare const meta: Meta;
3
+ export default meta;
4
+ type Story = StoryObj;
5
+ export declare const Line: Story;
6
+ export declare const Area: Story;
7
+ export declare const Bar: Story;
@@ -0,0 +1,17 @@
1
+ import { html } from 'lit';
2
+ const DATA = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
3
+ const meta = {
4
+ title: 'Sparkline',
5
+ component: 'lyra-sparkline',
6
+ tags: ['autodocs'],
7
+ };
8
+ export default meta;
9
+ export const Line = {
10
+ render: () => html `<lyra-sparkline type="line" .values=${DATA}></lyra-sparkline>`,
11
+ };
12
+ export const Area = {
13
+ render: () => html `<lyra-sparkline type="area" .values=${DATA}></lyra-sparkline>`,
14
+ };
15
+ export const Bar = {
16
+ render: () => html `<lyra-sparkline type="bar" .values=${DATA}></lyra-sparkline>`,
17
+ };
@@ -4,7 +4,7 @@ export type StatVariant = 'neutral' | 'success' | 'warning' | 'danger';
4
4
  export type StatGoodDirection = 'up' | 'down';
5
5
  /**
6
6
  * `<lyra-stat>` — a KPI/stat card. First-party invention consolidating the
7
- * "metric row" / "KPI card" pattern reinvented in every surveyed repo.
7
+ * "metric row" / "KPI card" pattern common to dashboard UIs.
8
8
  *
9
9
  * @customElement lyra-stat
10
10
  * @slot - Leading icon.
@@ -12,7 +12,7 @@ import { chevronIcon } from '../../internal/icons.js';
12
12
  import { styles } from './stat.styles.js';
13
13
  /**
14
14
  * `<lyra-stat>` — a KPI/stat card. First-party invention consolidating the
15
- * "metric row" / "KPI card" pattern reinvented in every surveyed repo.
15
+ * "metric row" / "KPI card" pattern common to dashboard UIs.
16
16
  *
17
17
  * @customElement lyra-stat
18
18
  * @slot - Leading icon.
@@ -33,8 +33,7 @@ export class LyraStat extends LyraElement {
33
33
  this.goodDirection = 'up';
34
34
  // Same fix `lyra-empty` already established: `[part]:empty` never matches
35
35
  // because the part always contains a literal `<slot>` child. Track real
36
- // slot assignment in JS instead (2026-07-10 audit, "dashboard-atoms"
37
- // §lyra-stat, High).
36
+ // slot assignment in JS instead.
38
37
  this.hasIcon = false;
39
38
  this.hasCaptionSlot = false;
40
39
  this.onIconSlotChange = (e) => {
@@ -55,10 +54,10 @@ export class LyraStat extends LyraElement {
55
54
  const hasTrend = !isNaN(this.trend);
56
55
  const rawDirection = this.trend > 0 ? 'up' : this.trend < 0 ? 'down' : 'flat';
57
56
  const isGood = rawDirection === 'flat' ? null : rawDirection === this.goodDirection;
58
- // 2026-07-10 design review, "dashboard-atoms" §lyra-stat: the trend
59
- // pill rendered literal ▲/▼ glyphsfont-dependent and inconsistent
60
- // with the rest of the icon set swapped for the shared chevronIcon(),
61
- // rotated per direction via CSS on the wrapping [part='trend'].
57
+ // The trend pill previously rendered literal ▲/▼ glyphs — font-dependent
58
+ // and inconsistent with the rest of the icon set swapped for the
59
+ // shared chevronIcon(), rotated per direction via CSS on the wrapping
60
+ // [part='trend'].
62
61
  const arrow = rawDirection === 'flat' ? '–' : chevronIcon();
63
62
  const hasCaption = this.hasCaptionSlot || this.caption.length > 0;
64
63
  return html `
@@ -14,8 +14,7 @@ export interface TableColumn<T> {
14
14
  * Header/row activation is delegated: one `click` and one `keydown`
15
15
  * listener on `<table>` resolve the target via `closest('[data-col-key]'
16
16
  * | '[data-row-key]')` and a key→object lookup map, instead of allocating
17
- * fresh per-column/per-row closures on every render (2026-07-10 audit,
18
- * "dashboard-atoms" §lyra-table, Medium).
17
+ * fresh per-column/per-row closures on every render.
19
18
  *
20
19
  * @customElement lyra-table
21
20
  * @event lyra-sort - A sortable header was activated. `detail: { key }`.
@@ -18,8 +18,7 @@ import '../empty/empty.js';
18
18
  * Header/row activation is delegated: one `click` and one `keydown`
19
19
  * listener on `<table>` resolve the target via `closest('[data-col-key]'
20
20
  * | '[data-row-key]')` and a key→object lookup map, instead of allocating
21
- * fresh per-column/per-row closures on every render (2026-07-10 audit,
22
- * "dashboard-atoms" §lyra-table, Medium).
21
+ * fresh per-column/per-row closures on every render.
23
22
  *
24
23
  * @customElement lyra-table
25
24
  * @event lyra-sort - A sortable header was activated. `detail: { key }`.
@@ -51,9 +51,8 @@ export const styles = css `
51
51
  }
52
52
  /* Rotate the wrapping part element, not the svg — internal/icons.ts's
53
53
  documented contract ("callers ... rotate the wrapping part element via
54
- CSS transform: rotate(...), not the svg"). Design-review finding on
55
- Task 3 (lyra-table sort indicator): this previously rotated the inner
56
- <svg> directly. */
54
+ CSS transform: rotate(...), not the svg"). This previously rotated the
55
+ inner <svg> directly. */
57
56
  [part='sort-icon'][data-dir='asc'] {
58
57
  transform: rotate(-90deg);
59
58
  }
@@ -147,10 +147,10 @@ export class LyraTimeRange extends LyraElement {
147
147
  this.end = Math.min(hi, Math.max(lo, this.end));
148
148
  }
149
149
  // Keep start <= end regardless of which side changed (a controlled
150
- // caller may set only `end`, e.g. two-way-binding an external store
151
- // see finding in 2026-07-09 tier2 review). The handle that just moved
152
- // wins; the other side is pulled to meet it, mirroring the direction
153
- // `clamp()` already uses during interactive dragging.
150
+ // caller may set only `end`, e.g. two-way-binding an external store).
151
+ // The handle that just moved wins; the other side is pulled to meet it,
152
+ // mirroring the direction `clamp()` already uses during interactive
153
+ // dragging.
154
154
  if (changed.has('start') || changed.has('min') || changed.has('max')) {
155
155
  this.start = Math.min(this.start, this.end);
156
156
  }
@@ -0,0 +1,5 @@
1
+ import type { Meta, StoryObj } from '@storybook/web-components-vite';
2
+ declare const meta: Meta;
3
+ export default meta;
4
+ type Story = StoryObj;
5
+ export declare const Triggers: Story;