@aceshooting/lyra-ui 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +9 -10
  2. package/custom-elements.json +15118 -8342
  3. package/dist/components/chart/chart.d.ts +57 -1
  4. package/dist/components/chart/chart.js +202 -12
  5. package/dist/components/chart/chart.stories.d.ts +32 -0
  6. package/dist/components/chart/chart.stories.js +141 -0
  7. package/dist/components/chart/chart.styles.js +16 -0
  8. package/dist/components/chart/histogram.d.ts +2 -0
  9. package/dist/components/chart/histogram.js +18 -2
  10. package/dist/components/chart/line-chart.stories.d.ts +17 -0
  11. package/dist/components/chart/line-chart.stories.js +75 -0
  12. package/dist/components/chart/lite-chart.d.ts +83 -0
  13. package/dist/components/chart/lite-chart.js +424 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +14 -0
  15. package/dist/components/chart/lite-chart.stories.js +114 -0
  16. package/dist/components/chart/lite-chart.styles.d.ts +1 -0
  17. package/dist/components/chart/lite-chart.styles.js +83 -0
  18. package/dist/components/combobox/combobox.d.ts +9 -0
  19. package/dist/components/combobox/combobox.js +76 -13
  20. package/dist/components/combobox/combobox.stories.d.ts +18 -0
  21. package/dist/components/combobox/combobox.stories.js +75 -0
  22. package/dist/components/date-picker/calendar-core.js +13 -2
  23. package/dist/components/date-picker/date-input.d.ts +9 -1
  24. package/dist/components/date-picker/date-input.js +55 -7
  25. package/dist/components/date-picker/date-input.stories.d.ts +5 -0
  26. package/dist/components/date-picker/date-input.stories.js +36 -0
  27. package/dist/components/date-picker/date-picker.d.ts +11 -3
  28. package/dist/components/date-picker/date-picker.js +87 -37
  29. package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
  30. package/dist/components/date-picker/date-picker.stories.js +12 -0
  31. package/dist/components/date-picker/date-picker.styles.js +1 -1
  32. package/dist/components/empty/empty.d.ts +12 -1
  33. package/dist/components/empty/empty.js +20 -3
  34. package/dist/components/empty/empty.stories.d.ts +2 -0
  35. package/dist/components/empty/empty.stories.js +20 -0
  36. package/dist/components/empty/empty.styles.js +11 -0
  37. package/dist/components/export-button/csv.js +6 -2
  38. package/dist/components/export-button/export-button.d.ts +15 -1
  39. package/dist/components/export-button/export-button.js +89 -12
  40. package/dist/components/export-button/export-button.stories.d.ts +1 -0
  41. package/dist/components/export-button/export-button.stories.js +5 -0
  42. package/dist/components/export-button/export-button.styles.js +11 -3
  43. package/dist/components/file-input/accept.d.ts +8 -2
  44. package/dist/components/file-input/accept.js +9 -3
  45. package/dist/components/file-input/file-input.d.ts +5 -2
  46. package/dist/components/file-input/file-input.js +23 -8
  47. package/dist/components/file-input/file-input.stories.d.ts +2 -0
  48. package/dist/components/file-input/file-input.stories.js +9 -0
  49. package/dist/components/flag/flag.d.ts +69 -3
  50. package/dist/components/flag/flag.js +107 -30
  51. package/dist/components/flag/flag.stories.d.ts +1 -0
  52. package/dist/components/flag/flag.stories.js +14 -0
  53. package/dist/components/flag/flag.styles.js +1 -1
  54. package/dist/components/flag/language-map.d.ts +11 -0
  55. package/dist/components/flag/language-map.js +12 -1
  56. package/dist/components/gauge/gauge.d.ts +2 -0
  57. package/dist/components/gauge/gauge.js +19 -5
  58. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  59. package/dist/components/gauge/gauge.stories.js +6 -0
  60. package/dist/components/graph/graph-loader.d.ts +28 -0
  61. package/dist/components/graph/graph-loader.js +44 -0
  62. package/dist/components/graph/graph.d.ts +41 -15
  63. package/dist/components/graph/graph.js +146 -71
  64. package/dist/components/graph/graph.stories.d.ts +3 -0
  65. package/dist/components/graph/graph.stories.js +38 -0
  66. package/dist/components/graph/graph.styles.js +6 -1
  67. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  68. package/dist/components/heatmap/calendar-grid.js +27 -10
  69. package/dist/components/heatmap/heatmap.d.ts +149 -12
  70. package/dist/components/heatmap/heatmap.js +445 -28
  71. package/dist/components/heatmap/heatmap.stories.d.ts +22 -0
  72. package/dist/components/heatmap/heatmap.stories.js +155 -0
  73. package/dist/components/heatmap/heatmap.styles.js +60 -2
  74. package/dist/components/map/map.d.ts +17 -1
  75. package/dist/components/map/map.js +68 -21
  76. package/dist/components/map/map.stories.d.ts +10 -0
  77. package/dist/components/map/map.stories.js +107 -12
  78. package/dist/components/map/map.styles.js +2 -2
  79. package/dist/components/playback/playback.d.ts +6 -2
  80. package/dist/components/playback/playback.js +36 -12
  81. package/dist/components/playback/playback.stories.d.ts +2 -0
  82. package/dist/components/playback/playback.stories.js +6 -0
  83. package/dist/components/playback/playback.styles.js +10 -2
  84. package/dist/components/select/select.d.ts +125 -0
  85. package/dist/components/select/select.js +480 -0
  86. package/dist/components/select/select.stories.d.ts +21 -0
  87. package/dist/components/select/select.stories.js +91 -0
  88. package/dist/components/select/select.styles.d.ts +1 -0
  89. package/dist/components/select/select.styles.js +212 -0
  90. package/dist/components/skeleton/skeleton.js +4 -7
  91. package/dist/components/sparkline/sparkline.d.ts +1 -1
  92. package/dist/components/sparkline/sparkline.js +40 -13
  93. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  94. package/dist/components/sparkline/sparkline.stories.js +22 -0
  95. package/dist/components/split/split.d.ts +24 -3
  96. package/dist/components/split/split.js +155 -31
  97. package/dist/components/split/split.stories.d.ts +2 -0
  98. package/dist/components/split/split.stories.js +20 -0
  99. package/dist/components/stat/stat.d.ts +43 -1
  100. package/dist/components/stat/stat.js +97 -4
  101. package/dist/components/stat/stat.stories.d.ts +4 -0
  102. package/dist/components/stat/stat.stories.js +66 -1
  103. package/dist/components/stat/stat.styles.js +64 -0
  104. package/dist/components/table/table.d.ts +64 -2
  105. package/dist/components/table/table.js +213 -17
  106. package/dist/components/table/table.stories.d.ts +6 -0
  107. package/dist/components/table/table.stories.js +45 -0
  108. package/dist/components/table/table.styles.js +51 -4
  109. package/dist/components/time-range/time-range.d.ts +34 -5
  110. package/dist/components/time-range/time-range.js +217 -36
  111. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  112. package/dist/components/time-range/time-range.stories.js +13 -0
  113. package/dist/components/time-range/time-range.styles.js +44 -2
  114. package/dist/components/toast/toast-item.d.ts +14 -2
  115. package/dist/components/toast/toast-item.js +134 -17
  116. package/dist/components/toast/toast-item.styles.js +28 -2
  117. package/dist/components/toast/toast.stories.d.ts +3 -0
  118. package/dist/components/toast/toast.stories.js +41 -1
  119. package/dist/components/toast/toast.styles.js +11 -11
  120. package/dist/components/toast/toaster.d.ts +1 -1
  121. package/dist/components/toast/toaster.js +13 -6
  122. package/dist/components/tree/tree-node.d.ts +13 -7
  123. package/dist/components/tree/tree-node.js +24 -10
  124. package/dist/components/tree/tree.d.ts +64 -15
  125. package/dist/components/tree/tree.js +113 -22
  126. package/dist/components/tree/tree.stories.d.ts +4 -0
  127. package/dist/components/tree/tree.stories.js +31 -1
  128. package/dist/components/tree/update-cascade.d.ts +13 -0
  129. package/dist/components/tree/update-cascade.js +13 -0
  130. package/dist/components/widget/widget.d.ts +25 -1
  131. package/dist/components/widget/widget.js +164 -6
  132. package/dist/components/widget/widget.stories.d.ts +3 -0
  133. package/dist/components/widget/widget.stories.js +43 -0
  134. package/dist/components/widget/widget.styles.js +20 -3
  135. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  136. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  137. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  138. package/dist/components/word-cloud/word-cloud.js +255 -0
  139. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  140. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  141. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  142. package/dist/components/word-cloud/word-cloud.styles.js +55 -0
  143. package/dist/internal/a11y.js +2 -1
  144. package/dist/internal/rtl.d.ts +7 -0
  145. package/dist/internal/rtl.js +9 -0
  146. package/dist/lyra.d.ts +9 -1
  147. package/dist/lyra.js +6 -0
  148. package/llms-full.txt +962 -276
  149. package/llms.txt +12 -9
  150. package/package.json +6 -4
@@ -10,38 +10,39 @@ import { styleMap } from 'lit/directives/style-map.js';
10
10
  import { LyraElement } from '../../internal/lyra-element.js';
11
11
  import { defineElement } from '../../internal/prefix.js';
12
12
  import { styles } from './graph.styles.js';
13
+ import { loadD3 } from './graph-loader.js';
13
14
  import '../skeleton/skeleton.js';
14
- let d3Modules;
15
+ function prefersReducedMotion() {
16
+ return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
17
+ }
15
18
  /**
16
- * Lazily loads the optional peer dependencies `d3-force`/`d3-drag`/`d3-zoom`/
17
- * `d3-selection` once per page. Resolves to `null` (with a one-time warning)
18
- * if they aren't installed mirrors `<lyra-flag>`'s peer-dependency pattern.
19
+ * Tiny deterministic PRNG (mulberry32, public-domain) used only when `seed`
20
+ * is set, so a node's initial x/y is reproducible instead of whatever
21
+ * forceSimulation() would otherwise randomize it to. This is the whole
22
+ * algorithm — not worth a dependency for.
19
23
  */
20
- function loadD3() {
21
- if (!d3Modules) {
22
- d3Modules = Promise.all([
23
- import('d3-force'),
24
- import('d3-drag'),
25
- import('d3-zoom'),
26
- import('d3-selection'),
27
- ])
28
- .then(([force, dragMod, zoomMod, selectionMod]) => ({
29
- forceSimulation: force.forceSimulation,
30
- forceLink: force.forceLink,
31
- forceManyBody: force.forceManyBody,
32
- forceCenter: force.forceCenter,
33
- forceCollide: force.forceCollide,
34
- drag: dragMod.drag,
35
- zoom: zoomMod.zoom,
36
- select: selectionMod.select,
37
- }))
38
- .catch(() => {
39
- console.warn('<lyra-graph> needs the optional peer dependencies `d3-force`, `d3-drag`, ' +
40
- '`d3-zoom`, and `d3-selection` — install them with `pnpm add d3-force d3-drag d3-zoom d3-selection`.');
41
- return null;
42
- });
24
+ function mulberry32(seed) {
25
+ let a = seed >>> 0;
26
+ return () => {
27
+ a = (a + 0x6d2b79f5) | 0;
28
+ let t = Math.imul(a ^ (a >>> 15), a | 1);
29
+ t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t;
30
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
31
+ };
32
+ }
33
+ /**
34
+ * Derives a per-node mulberry32 seed from the component's `seed` and the
35
+ * node's **id** — hashing by id (a simple FNV-1a-style mix), not by the
36
+ * node's index in `nodes`, is what keeps a seeded layout reproducible no
37
+ * matter how a caller orders/reorders the `nodes` array between renders.
38
+ */
39
+ function hashNodeSeed(seed, id) {
40
+ let h = seed >>> 0;
41
+ for (let i = 0; i < id.length; i++) {
42
+ h = Math.imul(h ^ id.charCodeAt(i), 2654435761);
43
+ h ^= h >>> 15;
43
44
  }
44
- return d3Modules;
45
+ return h >>> 0;
45
46
  }
46
47
  const MIN_RADIUS = 6;
47
48
  const MAX_RADIUS = 24;
@@ -64,6 +65,10 @@ function sanitizeNodeColor(color) {
64
65
  * Requires the optional peer deps `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection`
65
66
  * (lazy-loaded; a consumer who never uses this component pays zero d3 cost).
66
67
  *
68
+ * Set `seed` for a deterministic layout: node initial positions become
69
+ * reproducible (keyed by node id) and the settle happens synchronously
70
+ * instead of animating, like `prefers-reduced-motion`.
71
+ *
67
72
  * @customElement lyra-graph
68
73
  * @event lyra-node-click - `detail: { id }`.
69
74
  * @event lyra-link-click - `detail: { source, target }`.
@@ -84,8 +89,6 @@ export class LyraGraph extends LyraElement {
84
89
  this.loading = true;
85
90
  this.simNodes = [];
86
91
  this.simLinks = [];
87
- /** Current pan/zoom transform (SVG `transform` attribute syntax), driven by d3-zoom. */
88
- this.transform = '';
89
92
  /** Node `<circle>`s already wired up with d3-drag; cleared on every simulation rebuild
90
93
  * so DOM elements Lit reuses across a rebuild get rebound to their fresh datum. */
91
94
  this.boundNodeEls = new WeakSet();
@@ -137,11 +140,15 @@ export class LyraGraph extends LyraElement {
137
140
  if (!this.d3)
138
141
  return;
139
142
  if (!changed.has('nodes') && !changed.has('links')) {
143
+ // These two branches are independent (not else-if): a consumer can set
144
+ // width/height and chargeStrength/linkDistance in the same reactive
145
+ // update batch, and both retunes must apply — not just whichever branch
146
+ // happens to come first.
140
147
  if (changed.has('width') || changed.has('height')) {
141
148
  this.simulation?.force('center', this.d3.forceCenter(this.width / 2, this.height / 2));
142
149
  this.simulation?.alpha(0.1).restart();
143
150
  }
144
- else if (changed.has('chargeStrength') || changed.has('linkDistance')) {
151
+ if (changed.has('chargeStrength') || changed.has('linkDistance')) {
145
152
  // Without this branch, chargeStrength/linkDistance only took effect
146
153
  // the next time nodes/links also changed (rebuildSimulation() reads
147
154
  // them fresh) — retune the already-created force objects in place
@@ -157,35 +164,48 @@ export class LyraGraph extends LyraElement {
157
164
  // rebuildSimulation()'s doc comment) — never on a tick — so gating the
158
165
  // node/link querySelectorAll scan on that is equivalent to "structurally
159
166
  // changed" without needing a separate flag.
160
- this.applyInteractions(changed.has('simNodes') || changed.has('simLinks'));
167
+ this.applyInteractions(changed);
161
168
  }
162
169
  /**
163
170
  * Imperatively wires up d3-zoom (pan/zoom on the `<svg>`) and d3-drag
164
- * (per-node drag) against the just-rendered DOM. The zoom bind is a
165
- * one-time no-op guard (`zoomedEl`); the node-drag bind + node/link/label
166
- * element caching for `onTick()` only run when `structural` is true (a
167
- * fresh structural render just happened), not on every call otherwise
168
- * this would re-scan the DOM via `querySelectorAll` on every Lit update,
169
- * which used to include every single simulation tick. `<circle>`s already
170
- * bound are skipped (`boundNodeEls`) a WeakSet reset on every
171
- * `rebuildSimulation()` so DOM nodes Lit reuses across a rebuild get
172
- * rebound against their new datum instead of a stale one.
171
+ * (per-node drag) against the just-rendered DOM. The zoom bind itself is a
172
+ * one-time guard (`zoomedEl`) but the bound `zoomBehavior`'s
173
+ * `scaleExtent` is re-read from `minZoom`/`maxZoom` on every call so a
174
+ * post-mount change to either still takes effect. The node-drag bind +
175
+ * node/link/label element caching for `onTick()` only run when
176
+ * `changed` indicates a fresh structural render just happened, not on
177
+ * every call otherwise this would re-scan the DOM via
178
+ * `querySelectorAll` on every Lit update, which used to include every
179
+ * single simulation tick. `<circle>`s already bound are skipped
180
+ * (`boundNodeEls`) — a WeakSet reset on every `rebuildSimulation()` so DOM
181
+ * nodes Lit reuses across a rebuild get rebound against their new datum
182
+ * instead of a stale one. The zoom handler writes the resulting transform
183
+ * straight to the cached `gEl` (bound once alongside `zoomedEl`, since the
184
+ * outer `<g>` is a static part of the template and never recreated by Lit)
185
+ * instead of assigning a Lit reactive property — panning/zooming fires
186
+ * continuously while dragging, and reassigning a `@state()` there would
187
+ * force a full re-render (recomputing every node/link template) on every
188
+ * single event, the same class of cost `onTick()` already avoids for ticks.
173
189
  */
174
- applyInteractions(structural) {
190
+ applyInteractions(changed) {
175
191
  if (!this.d3)
176
192
  return;
177
193
  const svgEl = this.renderRoot.querySelector('svg');
178
194
  if (svgEl && svgEl !== this.zoomedEl) {
179
195
  this.zoomedEl = svgEl;
180
- const zoomBehavior = this.d3
196
+ this.gEl = this.renderRoot.querySelector('g') ?? undefined;
197
+ this.zoomBehavior = this.d3
181
198
  .zoom()
182
199
  .scaleExtent([this.minZoom, this.maxZoom])
183
200
  .on('zoom', (event) => {
184
- this.transform = event.transform.toString();
201
+ this.gEl?.setAttribute('transform', event.transform.toString());
185
202
  });
186
- this.d3.select(svgEl).call(zoomBehavior);
203
+ this.d3.select(svgEl).call(this.zoomBehavior);
187
204
  }
188
- if (!structural)
205
+ else if (this.zoomBehavior && (changed.has('minZoom') || changed.has('maxZoom'))) {
206
+ this.zoomBehavior.scaleExtent([this.minZoom, this.maxZoom]);
207
+ }
208
+ if (!(changed.has('simNodes') || changed.has('simLinks')))
189
209
  return;
190
210
  const nodeEls = Array.from(this.renderRoot.querySelectorAll('[part="node"]'));
191
211
  this.nodeEls = nodeEls;
@@ -223,24 +243,25 @@ export class LyraGraph extends LyraElement {
223
243
  * Runs on every d3-force simulation tick (up to ~300 while a graph settles
224
244
  * on load, continuously while a node is being dragged via
225
245
  * `alphaTarget(0.3)`). Writes positions straight to the already-rendered
226
- * DOM via direct d3-selection `.attr()` calls instead of reassigning the
227
- * reactive `simNodes`/`simLinks` properties, which would force a full Lit
228
- * re-render (and, before the `structural` gate above, an unconditional
229
- * `querySelectorAll` scan) on every single frame.
246
+ * DOM via `setAttribute()` instead of reassigning the reactive
247
+ * `simNodes`/`simLinks` properties, which would force a full Lit re-render
248
+ * (and, before the structural-render gate in `applyInteractions()`, an
249
+ * unconditional `querySelectorAll` scan) on every single frame. Writing attributes
250
+ * directly (rather than wrapping each element in a d3 selection just to
251
+ * call `.attr()`) avoids allocating a throwaway Selection per element on
252
+ * this component's highest-frequency code path.
230
253
  */
231
254
  onTick() {
232
- if (!this.d3)
233
- return;
234
- const d3 = this.d3;
235
255
  this.simNodes.forEach((n, i) => {
236
256
  const circle = this.nodeEls[i];
237
- if (circle)
238
- d3.select(circle).attr('cx', n.x ?? 0).attr('cy', n.y ?? 0);
257
+ if (circle) {
258
+ circle.setAttribute('cx', String(n.x ?? 0));
259
+ circle.setAttribute('cy', String(n.y ?? 0));
260
+ }
239
261
  const label = this.nodeLabelEls[i];
240
262
  if (label) {
241
- d3.select(label)
242
- .attr('x', (n.x ?? 0) + this.nodeRadius(n) + 2)
243
- .attr('y', n.y ?? 0);
263
+ label.setAttribute('x', String((n.x ?? 0) + this.nodeRadius(n) + 2));
264
+ label.setAttribute('y', String(n.y ?? 0));
244
265
  }
245
266
  });
246
267
  this.simLinks.forEach((l, i) => {
@@ -249,11 +270,10 @@ export class LyraGraph extends LyraElement {
249
270
  return;
250
271
  const source = l.source;
251
272
  const target = l.target;
252
- d3.select(line)
253
- .attr('x1', source.x ?? 0)
254
- .attr('y1', source.y ?? 0)
255
- .attr('x2', target.x ?? 0)
256
- .attr('y2', target.y ?? 0);
273
+ line.setAttribute('x1', String(source.x ?? 0));
274
+ line.setAttribute('y1', String(source.y ?? 0));
275
+ line.setAttribute('x2', String(target.x ?? 0));
276
+ line.setAttribute('y2', String(target.y ?? 0));
257
277
  });
258
278
  }
259
279
  rebuildSimulation() {
@@ -261,20 +281,61 @@ export class LyraGraph extends LyraElement {
261
281
  return;
262
282
  this.simulation?.stop();
263
283
  this.boundNodeEls = new WeakSet();
264
- const nodes = this.nodes.map((n) => ({ ...n }));
284
+ // Carry over each existing SimNode's settled position/velocity (and any
285
+ // in-progress drag fx/fy) by id instead of starting every node fresh —
286
+ // otherwise any structural nodes/links change (e.g. appending one new
287
+ // node to a live/streaming graph) would discard every already-settled
288
+ // node's (x, y) and restart the whole force layout's ~300-tick random-
289
+ // start settle animation from scratch. Only nodes with no previous
290
+ // counterpart (genuinely new ids) get forceSimulation()'s default
291
+ // fresh random start below.
292
+ const prevById = new Map(this.simNodes.map((n) => [n.id, n]));
293
+ const nodes = this.nodes.map((n) => {
294
+ const prev = prevById.get(n.id);
295
+ return prev ? { ...prev, ...n } : { ...n };
296
+ });
265
297
  const byId = new Map(nodes.map((n) => [n.id, n]));
266
298
  const links = this.links
267
299
  .filter((l) => byId.has(l.source) && byId.has(l.target))
268
300
  .map((l) => ({ ...l, source: byId.get(l.source), target: byId.get(l.target) }));
301
+ // Give brand-new nodes (no carried-over position from prevById above) a
302
+ // deterministic starting x/y, keyed by node id, instead of leaving them
303
+ // for forceSimulation() below to randomize. Nodes that already have a
304
+ // position are left untouched — same "only randomize nodes without x/y"
305
+ // rule forceSimulation() itself follows — so an incremental update to a
306
+ // seeded, already-settled graph doesn't reshuffle existing nodes.
307
+ const seed = this.seed;
308
+ if (seed != null) {
309
+ for (const n of nodes) {
310
+ if (n.x != null && n.y != null)
311
+ continue;
312
+ const rng = mulberry32(hashNodeSeed(seed, n.id));
313
+ n.x = rng() * this.width;
314
+ n.y = rng() * this.height;
315
+ }
316
+ }
269
317
  this.linkForce = this.d3.forceLink(links).distance(this.linkDistance);
270
318
  this.chargeForce = this.d3.forceManyBody().strength(this.chargeStrength);
271
- this.simulation = this.d3
319
+ const simulation = this.d3
272
320
  .forceSimulation(nodes)
273
321
  .force('link', this.linkForce)
274
322
  .force('charge', this.chargeForce)
275
323
  .force('center', this.d3.forceCenter(this.width / 2, this.height / 2))
276
324
  .force('collide', this.d3.forceCollide().radius((n) => this.nodeRadius(n) + 10))
277
325
  .on('tick', () => this.onTick());
326
+ this.simulation = simulation;
327
+ if (prefersReducedMotion() || this.seed != null) {
328
+ // Converge synchronously instead of animating the settle over ~300
329
+ // rendered frames — the simulation is already stopped (alpha at
330
+ // alphaMin) by the time the DOM for this rebuild is first painted.
331
+ // A seed converging synchronously (not just deterministically-seeded)
332
+ // is what makes its end state reproducible: two runs must agree on the
333
+ // exact same number of ticks, not merely start from the same x/y.
334
+ // User-initiated motion (dragging a node) is unaffected.
335
+ simulation.stop();
336
+ while (simulation.alpha() > simulation.alphaMin())
337
+ simulation.tick();
338
+ }
278
339
  // Assign these SAME array/object references (not copies) exactly once
279
340
  // for this structural rebuild: forceSimulation(nodes) above already
280
341
  // initialized their .x/.y synchronously, so this one Lit re-render
@@ -307,19 +368,33 @@ export class LyraGraph extends LyraElement {
307
368
  }
308
369
  return html `
309
370
  <div part="base">
310
- <svg part="svg" viewBox="0 0 ${this.width} ${this.height}">
311
- <g transform=${this.transform}>
371
+ <svg
372
+ part="svg"
373
+ role="group"
374
+ aria-label="Node-link diagram with ${this.simNodes.length} nodes and ${this.simLinks.length} links"
375
+ viewBox="0 0 ${this.width} ${this.height}"
376
+ >
377
+ <g transform="">
312
378
  ${this.simLinks.map((l) => {
313
379
  const source = l.source;
314
380
  const target = l.target;
315
381
  return svg `<line
316
382
  part="link"
383
+ role="button"
384
+ tabindex="0"
385
+ aria-label="Link from ${source.label ?? source.id} to ${target.label ?? target.id}"
317
386
  stroke-width=${l.width ?? 1.5}
318
387
  x1=${source.x ?? 0}
319
388
  y1=${source.y ?? 0}
320
389
  x2=${target.x ?? 0}
321
390
  y2=${target.y ?? 0}
322
391
  @click=${() => this.onLinkClick(l)}
392
+ @keydown=${(e) => {
393
+ if (e.key === 'Enter' || e.key === ' ') {
394
+ e.preventDefault();
395
+ this.onLinkClick(l);
396
+ }
397
+ }}
323
398
  ></line>`;
324
399
  })}
325
400
  ${this.simNodes.map((n) => {
@@ -343,7 +418,7 @@ export class LyraGraph extends LyraElement {
343
418
  }}
344
419
  ></circle>
345
420
  ${n.label
346
- ? svg `<text part="label" x=${(n.x ?? 0) + this.nodeRadius(n) + 2} y=${n.y ?? 0}>${n.label}</text>`
421
+ ? svg `<text part="label" aria-hidden="true" x=${(n.x ?? 0) + this.nodeRadius(n) + 2} y=${n.y ?? 0}>${n.label}</text>`
347
422
  : ''}
348
423
  </g>`;
349
424
  })}
@@ -377,6 +452,9 @@ __decorate([
377
452
  __decorate([
378
453
  property({ type: Number, attribute: 'max-zoom' })
379
454
  ], LyraGraph.prototype, "maxZoom", void 0);
455
+ __decorate([
456
+ property({ type: Number })
457
+ ], LyraGraph.prototype, "seed", void 0);
380
458
  __decorate([
381
459
  state()
382
460
  ], LyraGraph.prototype, "loading", void 0);
@@ -386,7 +464,4 @@ __decorate([
386
464
  __decorate([
387
465
  state()
388
466
  ], LyraGraph.prototype, "simLinks", void 0);
389
- __decorate([
390
- state()
391
- ], LyraGraph.prototype, "transform", void 0);
392
467
  defineElement('graph', LyraGraph);
@@ -3,3 +3,6 @@ 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 TunedForces: Story;
7
+ export declare const BoundedZoom: Story;
8
+ export declare const SeededLayout: Story;
@@ -28,3 +28,41 @@ export const Default = {
28
28
  ></lyra-graph>
29
29
  `,
30
30
  };
31
+ export const TunedForces = {
32
+ render: () => html `
33
+ <lyra-graph
34
+ width="480"
35
+ height="320"
36
+ style="height: 20rem"
37
+ charge-strength="-900"
38
+ link-distance="200"
39
+ .nodes=${nodes}
40
+ .links=${links}
41
+ ></lyra-graph>
42
+ `,
43
+ };
44
+ export const BoundedZoom = {
45
+ render: () => html `
46
+ <lyra-graph
47
+ width="480"
48
+ height="320"
49
+ style="height: 20rem"
50
+ min-zoom="1"
51
+ max-zoom="2"
52
+ .nodes=${nodes}
53
+ .links=${links}
54
+ ></lyra-graph>
55
+ `,
56
+ };
57
+ export const SeededLayout = {
58
+ render: () => html `
59
+ <p>
60
+ Both graphs below share <code>seed="42"</code> — reload the page or diff a screenshot across builds and their
61
+ node positions are bit-identical, unlike the non-seeded <code>Default</code> story above.
62
+ </p>
63
+ <div style="display: flex; gap: 1rem; flex-wrap: wrap">
64
+ <lyra-graph width="320" height="240" style="height: 15rem" seed="42" .nodes=${nodes} .links=${links}></lyra-graph>
65
+ <lyra-graph width="320" height="240" style="height: 15rem" seed="42" .nodes=${nodes} .links=${links}></lyra-graph>
66
+ </div>
67
+ `,
68
+ };
@@ -11,6 +11,11 @@ export const styles = css `
11
11
  [part='link'] {
12
12
  stroke: var(--lyra-color-border);
13
13
  fill: none;
14
+ cursor: pointer;
15
+ }
16
+ [part='link']:focus-visible {
17
+ outline: var(--lyra-focus-ring-width) solid var(--lyra-focus-ring-color);
18
+ outline-offset: var(--lyra-focus-ring-offset);
14
19
  }
15
20
  [part='node'] {
16
21
  /* --lyra-node-fill is set inline per-node (see graph.ts) from GraphNode.color;
@@ -26,7 +31,7 @@ export const styles = css `
26
31
  outline-offset: var(--lyra-focus-ring-offset);
27
32
  }
28
33
  [part='label'] {
29
- font-size: 10px;
34
+ font-size: 0.625rem;
30
35
  fill: var(--lyra-color-text);
31
36
  font-family: var(--lyra-font);
32
37
  pointer-events: none;
@@ -17,6 +17,13 @@ export declare function parseIsoDate(iso: string): Date;
17
17
  * Lays `days` out onto a GitHub-style Sunday-Saturday x week grid. `days`
18
18
  * need not be sorted or contiguous; the grid spans from the earliest to the
19
19
  * latest date present, anchored at the Sunday on/before the earliest date.
20
+ *
21
+ * An entry whose `date` doesn't parse to a valid calendar date (e.g. `''` or
22
+ * `'2026-03'`) is dropped rather than included: `parseIsoDate` on a malformed
23
+ * string yields an `Invalid Date` (`getTime()` is `NaN`), and letting that
24
+ * single `NaN` reach the min/max below would poison `firstWeekStart` (and
25
+ * therefore every cell's `week`) for the whole grid instead of just the bad
26
+ * entry.
20
27
  */
21
28
  export declare function buildCalendarGrid(days: CalendarDay[]): {
22
29
  cells: CalendarCell[];
@@ -1,3 +1,4 @@
1
+ import { minMax } from './heatmap-scale.js';
1
2
  const MS_PER_DAY = 86_400_000;
2
3
  /** Parses a `yyyy-mm-dd` string as UTC midnight, avoiding local-timezone day-boundary drift. */
3
4
  export function parseIsoDate(iso) {
@@ -8,14 +9,23 @@ export function parseIsoDate(iso) {
8
9
  * Lays `days` out onto a GitHub-style Sunday-Saturday x week grid. `days`
9
10
  * need not be sorted or contiguous; the grid spans from the earliest to the
10
11
  * latest date present, anchored at the Sunday on/before the earliest date.
12
+ *
13
+ * An entry whose `date` doesn't parse to a valid calendar date (e.g. `''` or
14
+ * `'2026-03'`) is dropped rather than included: `parseIsoDate` on a malformed
15
+ * string yields an `Invalid Date` (`getTime()` is `NaN`), and letting that
16
+ * single `NaN` reach the min/max below would poison `firstWeekStart` (and
17
+ * therefore every cell's `week`) for the whole grid instead of just the bad
18
+ * entry.
11
19
  */
12
20
  export function buildCalendarGrid(days) {
13
- if (days.length === 0) {
21
+ const parsed = days
22
+ .map((d) => ({ ...d, dateObj: parseIsoDate(d.date) }))
23
+ .filter((d) => !Number.isNaN(d.dateObj.getTime()));
24
+ if (parsed.length === 0) {
14
25
  return { cells: [], weekCount: 0, firstWeekStart: new Date(0), monthLabels: [] };
15
26
  }
16
- const parsed = days.map((d) => ({ ...d, dateObj: parseIsoDate(d.date) }));
17
- const minTime = Math.min(...parsed.map((d) => d.dateObj.getTime()));
18
- const min = new Date(minTime);
27
+ const timeBounds = minMax(parsed.map((d) => d.dateObj.getTime()));
28
+ const min = new Date(timeBounds[0]);
19
29
  const firstWeekStart = new Date(Date.UTC(min.getUTCFullYear(), min.getUTCMonth(), min.getUTCDate() - min.getUTCDay()));
20
30
  const cells = parsed.map((d) => {
21
31
  const weekday = d.dateObj.getUTCDay();
@@ -23,7 +33,7 @@ export function buildCalendarGrid(days) {
23
33
  const week = Math.floor(daysSinceStart / 7);
24
34
  return { date: d.date, value: d.value, week, weekday };
25
35
  });
26
- const weekCount = Math.max(...cells.map((c) => c.week)) + 1;
36
+ const weekCount = minMax(cells.map((c) => c.week))[1] + 1;
27
37
  const monthLabels = [];
28
38
  let lastMonth = -1;
29
39
  for (const cell of cells.slice().sort((a, b) => a.week - b.week)) {
@@ -43,10 +53,17 @@ export function buildCalendarGrid(days) {
43
53
  export function quartileBucket(value, sorted, bucketCount) {
44
54
  if (sorted.length === 0)
45
55
  return 0;
46
- let count = 0;
47
- for (const v of sorted)
48
- if (v <= value)
49
- count++;
50
- const rank = count / sorted.length;
56
+ // `sorted` is ascending, so the count of elements <= value is found via an
57
+ // upper-bound binary search (O(log n)) instead of scanning the whole array.
58
+ let lo = 0;
59
+ let hi = sorted.length;
60
+ while (lo < hi) {
61
+ const mid = (lo + hi) >>> 1;
62
+ if (sorted[mid] <= value)
63
+ lo = mid + 1;
64
+ else
65
+ hi = mid;
66
+ }
67
+ const rank = lo / sorted.length;
51
68
  return Math.min(bucketCount - 1, Math.floor(rank * bucketCount));
52
69
  }