@aceshooting/lyra-ui 1.0.0 → 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 +10463 -4519
  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
@@ -9,8 +9,45 @@ import { property, state } from 'lit/decorators.js';
9
9
  import { LyraElement } from '../../internal/lyra-element.js';
10
10
  import { defineElement } from '../../internal/prefix.js';
11
11
  import { lockScroll } from '../../internal/scroll-lock.js';
12
+ import { nextId } from '../../internal/a11y.js';
12
13
  import { chevronIcon, closeIcon, expandIcon } from '../../internal/icons.js';
13
14
  import { styles } from './widget.styles.js';
15
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
16
+ // Shadow-piercing so a slotted custom element's real focusable target (e.g.
17
+ // an <input> inside its own shadow root) is found even though the host tag
18
+ // itself doesn't match FOCUSABLE_SELECTOR.
19
+ function collectFocusable(el) {
20
+ const result = [];
21
+ if (el.matches(FOCUSABLE_SELECTOR)) {
22
+ result.push(el);
23
+ }
24
+ if (el instanceof HTMLSlotElement) {
25
+ for (const assigned of el.assignedElements({ flatten: true })) {
26
+ result.push(...collectFocusable(assigned));
27
+ }
28
+ return result;
29
+ }
30
+ const container = el.shadowRoot ?? el;
31
+ for (const child of Array.from(container.children)) {
32
+ result.push(...collectFocusable(child));
33
+ }
34
+ return result;
35
+ }
36
+ /**
37
+ * Whether `el` is actually laid out/paintable — used to drop e.g. a
38
+ * `[collapsed]` body's slotted content from the fullscreen tab order.
39
+ * `el.offsetParent !== null` is the usual shorthand for this, but it's
40
+ * unreliable here: it resolves `null` for an element whose nearest
41
+ * *positioned* ancestor (fullscreen's `[part="base"] { position: fixed }`)
42
+ * lives across a slot-projection boundary from the element itself (true for
43
+ * every element this scans, since they're all slotted content), even though
44
+ * the element is genuinely rendered. `checkVisibility()` (falling back to
45
+ * `getClientRects().length` on engines without it) correctly follows
46
+ * flattened-tree/slot assignment instead.
47
+ */
48
+ function isRendered(el) {
49
+ return el.checkVisibility ? el.checkVisibility() : el.getClientRects().length > 0;
50
+ }
14
51
  /**
15
52
  * `<lyra-widget>` — a titled panel shell with an optional collapse toggle and
16
53
  * an optional fullscreen-expand toggle. Fullscreen promotes the same host
@@ -22,7 +59,16 @@ import { styles } from './widget.styles.js';
22
59
  * @slot actions - Header action controls, rendered before the collapse/expand buttons.
23
60
  * @event lyra-collapse-change - `detail: boolean` (the new `collapsed` state).
24
61
  * @event lyra-fullscreen-change - `detail: boolean` (the new `fullscreen` state).
25
- * @csspart base, header, title, label, sublabel, actions, body, backdrop
62
+ * @csspart base - The panel root (dialog role + backdrop when fullscreen).
63
+ * @csspart header - The header row containing the title, actions, and toggle buttons.
64
+ * @csspart title - The wrapper around the label/sublabel.
65
+ * @csspart label - The panel title text.
66
+ * @csspart sublabel - The panel subtitle text.
67
+ * @csspart actions - The wrapper around the `actions` slot.
68
+ * @csspart collapse-button - The collapse/expand toggle button.
69
+ * @csspart fullscreen-button - The fullscreen toggle button.
70
+ * @csspart body - The wrapper around the default slot (the panel body).
71
+ * @csspart backdrop - The fullscreen scrim behind the panel.
26
72
  */
27
73
  export class LyraWidget extends LyraElement {
28
74
  constructor() {
@@ -34,6 +80,7 @@ export class LyraWidget extends LyraElement {
34
80
  this.expandable = false;
35
81
  this.fullscreen = false;
36
82
  this.hasActionsSlot = false;
83
+ this.bodyId = nextId('widget-body');
37
84
  this.onActionsSlotChange = (e) => {
38
85
  this.hasActionsSlot = e.target.assignedElements({ flatten: true }).length > 0;
39
86
  };
@@ -50,11 +97,32 @@ export class LyraWidget extends LyraElement {
50
97
  }
51
98
  };
52
99
  this.onDocKeyDown = (e) => {
53
- if (e.key === 'Escape' && this.fullscreen) {
100
+ if (!this.fullscreen)
101
+ return;
102
+ if (e.key === 'Escape') {
54
103
  e.preventDefault();
55
104
  this.fullscreen = false;
56
105
  this.emit('lyra-fullscreen-change', false);
57
106
  this.lastTrigger?.focus();
107
+ return;
108
+ }
109
+ if (e.key !== 'Tab')
110
+ return;
111
+ const focusable = this.getFocusableElements();
112
+ if (focusable.length === 0) {
113
+ e.preventDefault();
114
+ return;
115
+ }
116
+ const first = focusable[0];
117
+ const last = focusable[focusable.length - 1];
118
+ const active = this.getActiveElement();
119
+ if (e.shiftKey && active === first) {
120
+ e.preventDefault();
121
+ last.focus();
122
+ }
123
+ else if (!e.shiftKey && active === last) {
124
+ e.preventDefault();
125
+ first.focus();
58
126
  }
59
127
  };
60
128
  this.onBackdropClick = () => {
@@ -62,7 +130,6 @@ export class LyraWidget extends LyraElement {
62
130
  return;
63
131
  this.fullscreen = false;
64
132
  this.emit('lyra-fullscreen-change', false);
65
- document.removeEventListener('keydown', this.onDocKeyDown);
66
133
  this.lastTrigger?.focus();
67
134
  };
68
135
  }
@@ -83,18 +150,85 @@ export class LyraWidget extends LyraElement {
83
150
  }
84
151
  }
85
152
  }
153
+ // Runs after render (not willUpdate) so `[part="base"]`'s fullscreen-only
154
+ // tabindex has already landed in the DOM before the fallback .focus() call
155
+ // below can rely on it. WAI-ARIA APG's dialog pattern requires opening a
156
+ // modal to move focus inside it; without this it only happened to work
157
+ // because a mouse click natively focuses the button that triggered it --
158
+ // not true for a directly-set `fullscreen` property, and not guaranteed
159
+ // for every input method/browser even in the click case.
160
+ updated(changed) {
161
+ if (changed.has('fullscreen') && this.fullscreen) {
162
+ const first = this.getFocusableElements()[0];
163
+ if (first) {
164
+ first.focus();
165
+ }
166
+ else {
167
+ this.shadowRoot?.querySelector('[part="base"]')?.focus();
168
+ }
169
+ }
170
+ }
171
+ connectedCallback() {
172
+ super.connectedCallback();
173
+ // A reconnect (e.g. a drag-and-drop reparent that keeps this same
174
+ // element instance) fires disconnectedCallback then connectedCallback
175
+ // synchronously with no update in between, so willUpdate never reruns
176
+ // to notice `fullscreen` is still true. Restore the scroll lock/trap it
177
+ // dropped. `hasUpdated` excludes the initial mount, where willUpdate's
178
+ // first pass already establishes them from the starting property value.
179
+ if (this.hasUpdated && this.fullscreen && !this.releaseScrollLock) {
180
+ this.releaseScrollLock = lockScroll();
181
+ document.addEventListener('keydown', this.onDocKeyDown);
182
+ }
183
+ }
86
184
  disconnectedCallback() {
87
185
  super.disconnectedCallback();
88
186
  this.releaseScrollLock?.();
89
187
  this.releaseScrollLock = undefined;
90
188
  document.removeEventListener('keydown', this.onDocKeyDown);
91
189
  }
190
+ // Bounds Tab/Shift+Tab to the panel while fullscreen (a modal presentation)
191
+ // so keyboard focus can't escape to page content hidden behind the
192
+ // backdrop. Order follows the header's actions slot, then the collapse/
193
+ // fullscreen buttons, then the body slot -- the same order the flattened
194
+ // tree already tabs through.
195
+ getFocusableElements() {
196
+ const root = this.shadowRoot;
197
+ if (!root)
198
+ return [];
199
+ const fromSlot = (selector) => {
200
+ const slot = root.querySelector(selector);
201
+ return slot ? slot.assignedElements({ flatten: true }).flatMap(collectFocusable) : [];
202
+ };
203
+ const shadowButtons = Array.from(root.querySelectorAll('[part="collapse-button"], [part="fullscreen-button"]'));
204
+ return [
205
+ ...fromSlot('slot[name="actions"]'),
206
+ ...shadowButtons,
207
+ ...fromSlot('[part="body"] slot:not([name])'),
208
+ ].filter(isRendered);
209
+ }
210
+ getActiveElement() {
211
+ let active = document.activeElement;
212
+ while (active) {
213
+ const inner = active.shadowRoot?.activeElement ?? null;
214
+ if (!inner)
215
+ break;
216
+ active = inner;
217
+ }
218
+ return active;
219
+ }
92
220
  render() {
93
221
  const hasLabel = this.label.length > 0;
94
222
  const hasSublabel = this.sublabel.length > 0;
95
223
  return html `
96
224
  ${this.fullscreen ? html `<div part="backdrop" @click=${this.onBackdropClick}></div>` : nothing}
97
- <div part="base">
225
+ <div
226
+ part="base"
227
+ role=${this.fullscreen ? 'dialog' : nothing}
228
+ aria-modal=${this.fullscreen ? 'true' : nothing}
229
+ aria-label=${this.fullscreen ? (hasLabel ? this.label : 'Fullscreen panel') : nothing}
230
+ tabindex=${this.fullscreen ? '-1' : nothing}
231
+ >
98
232
  <div part="header">
99
233
  <div part="title">
100
234
  ${hasLabel ? html `<span part="label">${this.label}</span>` : nothing}
@@ -109,6 +243,7 @@ export class LyraWidget extends LyraElement {
109
243
  type="button"
110
244
  aria-expanded=${this.collapsed ? 'false' : 'true'}
111
245
  aria-label=${this.collapsed ? 'Expand panel' : 'Collapse panel'}
246
+ aria-controls=${this.bodyId}
112
247
  @click=${this.toggleCollapsed}
113
248
  >
114
249
  <span style="display:inline-flex;transform:rotate(${this.collapsed ? '0deg' : '90deg'})"
@@ -128,7 +263,7 @@ export class LyraWidget extends LyraElement {
128
263
  </button>`
129
264
  : nothing}
130
265
  </div>
131
- <div part="body" ?hidden=${this.collapsed}>
266
+ <div part="body" id=${this.bodyId} ?hidden=${this.collapsed}>
132
267
  <slot></slot>
133
268
  </div>
134
269
  </div>
@@ -4,3 +4,4 @@ export default meta;
4
4
  type Story = StoryObj;
5
5
  export declare const Default: Story;
6
6
  export declare const CollapsedInitially: Story;
7
+ export declare const FullscreenInitially: Story;
@@ -29,3 +29,13 @@ export const CollapsedInitially = {
29
29
  </lyra-widget>
30
30
  `,
31
31
  };
32
+ export const FullscreenInitially = {
33
+ render: () => html `
34
+ <lyra-widget label="Load profile" sublabel="Last 7 days" expandable fullscreen style="max-width: 28rem;">
35
+ <span slot="actions"><button>Refresh</button></span>
36
+ <div style="padding: 1rem;">
37
+ <p style="margin: 0;">Rendered already fullscreen — backdrop, fixed panel, and dialog semantics.</p>
38
+ </div>
39
+ </lyra-widget>
40
+ `,
41
+ };
@@ -2,6 +2,10 @@ import { css } from 'lit';
2
2
  export const styles = css `
3
3
  :host {
4
4
  display: block;
5
+ /* Fullscreen scrim color -- component-specific so a host can retheme it
6
+ without a raw literal leaking into the public API (no shared
7
+ --wa-*-overlay token exists in the design system to resolve through). */
8
+ --lyra-widget-overlay-color: rgb(0 0 0 / 0.5);
5
9
  }
6
10
  [part='base'] {
7
11
  display: flex;
@@ -74,7 +78,7 @@ export const styles = css `
74
78
  [part='backdrop'] {
75
79
  position: fixed;
76
80
  inset: 0;
77
- background: rgb(0 0 0 / 0.5);
81
+ background: var(--lyra-widget-overlay-color);
78
82
  z-index: 999;
79
83
  }
80
84
  :host([fullscreen]) [part='base'] {
@@ -87,4 +91,10 @@ export const styles = css `
87
91
  overflow: auto;
88
92
  block-size: 100%;
89
93
  }
94
+ @media (prefers-reduced-motion: reduce) {
95
+ [part='collapse-button'],
96
+ [part='fullscreen-button'] {
97
+ transition: none !important;
98
+ }
99
+ }
90
100
  `;
@@ -0,0 +1,63 @@
1
+ /** One word to place, before layout. */
2
+ export interface WordCloudWord {
3
+ text: string;
4
+ weight: number;
5
+ color?: string;
6
+ group?: string;
7
+ }
8
+ /** A word after layout — its original data (weight untouched, even if it was
9
+ * negative/non-finite in the input — see `effectiveWeight` internally) plus
10
+ * computed geometry. */
11
+ export interface PlacedWord extends WordCloudWord {
12
+ /** Index of this word in the original (pre-sort) `words` array — stable
13
+ * across layout re-runs, so callers can key a color/group map off it
14
+ * instead of off placement order (which is sorted by weight). */
15
+ originalIndex: number;
16
+ /** Center x, in an unbounded coordinate space centered on the origin. */
17
+ x: number;
18
+ /** Center y, in the same space as `x`. */
19
+ y: number;
20
+ fontSize: number;
21
+ /** `true` if rotated 90°. */
22
+ rotated: boolean;
23
+ /** Unrotated rendered width, in px. */
24
+ width: number;
25
+ /** Unrotated rendered height, in px (== fontSize; layout doesn't model ascent/descent). */
26
+ height: number;
27
+ }
28
+ export interface WordCloudLayoutOptions {
29
+ minFontSize: number;
30
+ maxFontSize: number;
31
+ scale: 'linear' | 'sqrt';
32
+ orientations: 'horizontal' | 'mixed';
33
+ /** Measures the rendered width of `text` set at `fontSize`, e.g. via a canvas 2D
34
+ * context — the font string passed to the context must match the actual
35
+ * rendered `[part="word"]` font (weight included), or collision boxes end
36
+ * up narrower than what's actually painted. */
37
+ measureText: (text: string, fontSize: number) => number;
38
+ /** `[0, 1)` — defaults to `Math.random`; inject a stub for deterministic tests. */
39
+ random?: () => number;
40
+ }
41
+ export interface WordCloudLayoutResult {
42
+ placed: PlacedWord[];
43
+ /** Words left out of `placed`, for any of three reasons: blank/whitespace-only
44
+ * `text`; capacity overflow (input has more than `MAX_WORDS` entries, so the
45
+ * lowest-weight excess is dropped, regardless of where it fell in the input
46
+ * array); or the spiral search exhausted its radius bound (pathological
47
+ * inputs only, e.g. one huge word repeated many times). */
48
+ skipped: WordCloudWord[];
49
+ /** Gap-padded bounding box of `placed` — 0 if nothing was placed. */
50
+ width: number;
51
+ height: number;
52
+ }
53
+ /** DOM-node/compute-time safety cap — mirrors lyra-sparkline's `MAX_BARS`. */
54
+ export declare const MAX_WORDS = 150;
55
+ /** Minimum gap enforced both between placed words and around the returned bounding box. */
56
+ export declare const GAP = 3;
57
+ /**
58
+ * Places `words` via an Archimedean-spiral search (the standard word-cloud
59
+ * layout heuristic — heaviest words placed first, each one walking an
60
+ * outward spiral from the center until it finds a gap that doesn't overlap
61
+ * any word already placed). Deterministic given a deterministic `random`.
62
+ */
63
+ export declare function layoutWordCloud(words: WordCloudWord[], options: WordCloudLayoutOptions): WordCloudLayoutResult;
@@ -0,0 +1,145 @@
1
+ /** DOM-node/compute-time safety cap — mirrors lyra-sparkline's `MAX_BARS`. */
2
+ export const MAX_WORDS = 150;
3
+ /** Minimum gap enforced both between placed words and around the returned bounding box. */
4
+ export const GAP = 3;
5
+ const ROTATE_PROBABILITY = 0.25;
6
+ const ANGLE_STEP = 0.15;
7
+ const RADIUS_STEP_PER_RADIAN = 3;
8
+ /** Multiplier on the summed word-box area used to bound the spiral search —
9
+ * generous enough that only a pathological input (e.g. one giant word
10
+ * repeated many times) ever exhausts it. */
11
+ const MAX_RADIUS_AREA_FACTOR = 6;
12
+ /** Clamps a (possibly negative/non-finite) input weight for scale math only —
13
+ * never fed back to callers, who should still see their own original `weight`. */
14
+ function effectiveWeight(weight) {
15
+ return Number.isFinite(weight) ? Math.max(0, weight) : 0;
16
+ }
17
+ function scaledWeight(weight, minWeight, maxWeight, scale) {
18
+ const span = maxWeight - minWeight || 1;
19
+ const t = Math.min(1, Math.max(0, (weight - minWeight) / span));
20
+ return scale === 'sqrt' ? Math.sqrt(t) : t;
21
+ }
22
+ function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
23
+ return Math.abs(ax - bx) < (aw + bw) / 2 + GAP && Math.abs(ay - by) < (ah + bh) / 2 + GAP;
24
+ }
25
+ /**
26
+ * Places `words` via an Archimedean-spiral search (the standard word-cloud
27
+ * layout heuristic — heaviest words placed first, each one walking an
28
+ * outward spiral from the center until it finds a gap that doesn't overlap
29
+ * any word already placed). Deterministic given a deterministic `random`.
30
+ */
31
+ export function layoutWordCloud(words, options) {
32
+ const { minFontSize, maxFontSize, scale, orientations, measureText } = options;
33
+ const random = options.random ?? Math.random;
34
+ const skipped = [];
35
+ const decorated = words
36
+ .map((w, originalIndex) => ({ ...w, originalIndex }))
37
+ .filter((w) => {
38
+ const blank = w.text.trim().length === 0;
39
+ if (blank)
40
+ skipped.push(w);
41
+ return !blank;
42
+ });
43
+ // Cap placement eligibility by actual weight (heaviest MAX_WORDS survive),
44
+ // not by array position — a heavy word late in the input must not be
45
+ // dropped just because lighter words happened to come first.
46
+ const byWeightDesc = [...decorated].sort((a, b) => effectiveWeight(b.weight) - effectiveWeight(a.weight));
47
+ const eligible = byWeightDesc.slice(0, MAX_WORDS);
48
+ skipped.push(...byWeightDesc.slice(MAX_WORDS));
49
+ if (eligible.length === 0)
50
+ return { placed: [], skipped, width: 0, height: 0 };
51
+ let minWeight = effectiveWeight(eligible[0].weight);
52
+ let maxWeight = minWeight;
53
+ for (const w of eligible) {
54
+ const ew = effectiveWeight(w.weight);
55
+ if (ew < minWeight)
56
+ minWeight = ew;
57
+ if (ew > maxWeight)
58
+ maxWeight = ew;
59
+ }
60
+ // eligible is already weight-descending (a suffix of byWeightDesc), which
61
+ // is also the placement order the algorithm wants (heaviest first).
62
+ const placed = [];
63
+ let totalArea = 0;
64
+ for (const word of eligible) {
65
+ const t = scaledWeight(effectiveWeight(word.weight), minWeight, maxWeight, scale);
66
+ const fontSize = minFontSize + t * (maxFontSize - minFontSize);
67
+ const measuredWidth = Math.max(1, measureText(word.text, fontSize));
68
+ const measuredHeight = fontSize;
69
+ const rotated = orientations === 'mixed' && random() < ROTATE_PROBABILITY;
70
+ const boxW = rotated ? measuredHeight : measuredWidth;
71
+ const boxH = rotated ? measuredWidth : measuredHeight;
72
+ totalArea += boxW * boxH;
73
+ const maxRadius = Math.sqrt(totalArea * MAX_RADIUS_AREA_FACTOR) + Math.max(boxW, boxH);
74
+ let theta = 0;
75
+ let radius = 0;
76
+ let x = 0;
77
+ let y = 0;
78
+ let foundSpot = placed.length === 0;
79
+ while (!foundSpot && radius < maxRadius) {
80
+ x = radius * Math.cos(theta);
81
+ y = radius * Math.sin(theta);
82
+ let collides = false;
83
+ for (const other of placed) {
84
+ const otherW = other.rotated ? other.height : other.width;
85
+ const otherH = other.rotated ? other.width : other.height;
86
+ if (rectsOverlap(x, y, boxW, boxH, other.x, other.y, otherW, otherH)) {
87
+ collides = true;
88
+ break;
89
+ }
90
+ }
91
+ if (!collides) {
92
+ foundSpot = true;
93
+ break;
94
+ }
95
+ theta += ANGLE_STEP;
96
+ radius += RADIUS_STEP_PER_RADIAN * ANGLE_STEP;
97
+ }
98
+ if (!foundSpot) {
99
+ skipped.push(word);
100
+ continue;
101
+ }
102
+ placed.push({
103
+ ...word,
104
+ x,
105
+ y,
106
+ fontSize,
107
+ rotated,
108
+ width: measuredWidth,
109
+ height: measuredHeight,
110
+ });
111
+ }
112
+ if (placed.length === 0)
113
+ return { placed, skipped, width: 0, height: 0 };
114
+ let minX = Infinity;
115
+ let maxX = -Infinity;
116
+ let minY = Infinity;
117
+ let maxY = -Infinity;
118
+ for (const p of placed) {
119
+ const w = p.rotated ? p.height : p.width;
120
+ const h = p.rotated ? p.width : p.height;
121
+ minX = Math.min(minX, p.x - w / 2);
122
+ maxX = Math.max(maxX, p.x + w / 2);
123
+ minY = Math.min(minY, p.y - h / 2);
124
+ maxY = Math.max(maxY, p.y + h / 2);
125
+ }
126
+ // Pad the bounding box itself by GAP too, matching the same margin already
127
+ // enforced between words (see rectsOverlap) — otherwise the outermost
128
+ // words sit flush against the SVG's own edge with no room for error.
129
+ minX -= GAP;
130
+ minY -= GAP;
131
+ maxX += GAP;
132
+ maxY += GAP;
133
+ // Shift every word into a top-left-origin coordinate space so callers can
134
+ // render straight into `viewBox="0 0 width height"` with no further offset math.
135
+ for (const p of placed) {
136
+ p.x -= minX;
137
+ p.y -= minY;
138
+ }
139
+ return {
140
+ placed,
141
+ skipped,
142
+ width: maxX - minX,
143
+ height: maxY - minY,
144
+ };
145
+ }
@@ -0,0 +1,65 @@
1
+ import { type PropertyValues, type TemplateResult } from 'lit';
2
+ import { LyraElement } from '../../internal/lyra-element.js';
3
+ import { type WordCloudWord } from './word-cloud-layout.js';
4
+ export type { WordCloudWord };
5
+ /**
6
+ * `<lyra-word-cloud>` — a zero-dependency SVG word/tag cloud. First-party
7
+ * invention (no Web Awesome equivalent). Each word's rendered size is scaled
8
+ * from its `weight` and placed via an outward Archimedean-spiral search (the
9
+ * standard word-cloud heuristic: heaviest words placed first, each one
10
+ * spiraling out from the center until it clears every word already placed).
11
+ *
12
+ * Unlike sibling `lyra-sparkline`/`lyra-heatmap` (one `role="img"` glyph
13
+ * standing in for an aggregate value), the individual words here are the
14
+ * meaningful interactive content — but with up to `MAX_WORDS` of them, making
15
+ * every single one its own tab stop would be a poor keyboard experience.
16
+ * Instead, like `lyra-heatmap`'s cells, the whole `[part="svg"]` is one tab
17
+ * stop with roving arrow-key focus (Home/End jump to the first/last word,
18
+ * Enter/Space activates the focused one), a drawn `[part="focus-ring"]`, and
19
+ * a visually-hidden `aria-live="polite"` status announcement.
20
+ *
21
+ * @customElement lyra-word-cloud
22
+ * @event lyra-word-click - Fired on click, or Enter/Space on the focused word.
23
+ * `detail: { text, weight, group }`.
24
+ * @csspart base, svg, word, focus-ring, live-region, empty
25
+ */
26
+ export declare class LyraWordCloud extends LyraElement {
27
+ static styles: import("lit").CSSResultGroup[];
28
+ /** The words to lay out. Re-laid-out whenever this (or a sizing property) changes. */
29
+ words: WordCloudWord[];
30
+ /** Font size, in px, for the lowest-weight word. */
31
+ minFontSize: number;
32
+ /** Font size, in px, for the highest-weight word. */
33
+ maxFontSize: number;
34
+ /** `sqrt` compresses the weight->font-size mapping so one heavy word doesn't dwarf the rest. */
35
+ scale: 'linear' | 'sqrt';
36
+ /** `mixed` lets some words render rotated 90° for denser packing. */
37
+ orientations: 'horizontal' | 'mixed';
38
+ /** Custom categorical palette, cycled by word index (or by `group`, see `words`). Defaults to the `--lyra-word-cloud-color-*` tokens. */
39
+ palette?: string[];
40
+ private svgEl?;
41
+ private cachedLayout;
42
+ /** Roving-focus cursor -- an index into `navOrder()`, not into `cachedLayout.placed`. */
43
+ private focusedIndex;
44
+ /** Text of the visually-hidden `aria-live="polite"` status announcement. */
45
+ private liveText;
46
+ private measureText;
47
+ private fontFamily;
48
+ private paletteColors;
49
+ /** Stable keyboard tab order -- the order words were declared in `words`,
50
+ * independent of the weight-sorted placement order. */
51
+ private navOrder;
52
+ protected willUpdate(changed: PropertyValues): void;
53
+ private activate;
54
+ private announce;
55
+ private onWordClick;
56
+ private onKeyDown;
57
+ /** Axis-aligned focus-ring rect for `w`, already accounting for its rotation. */
58
+ private focusRingRect;
59
+ render(): TemplateResult;
60
+ }
61
+ declare global {
62
+ interface HTMLElementTagNameMap {
63
+ 'lyra-word-cloud': LyraWordCloud;
64
+ }
65
+ }