@ixfx/components 0.11.4 → 0.11.6

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 (113) hide show
  1. package/bundle/index.d.ts +451 -120
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +2267 -540
  4. package/bundle/index.js.map +1 -1
  5. package/dist/ac-text.d.ts +1 -1
  6. package/dist/{ac-token-TCLAZeVg.d.ts → ac-token--_vCHXWw.d.ts} +2 -2
  7. package/dist/{ac-token-TCLAZeVg.d.ts.map → ac-token--_vCHXWw.d.ts.map} +1 -1
  8. package/dist/ac-token.d.ts +1 -1
  9. package/dist/{button-CTL45aBl.d.ts → button-BYuf72aR.d.ts} +2 -2
  10. package/dist/{button-CTL45aBl.d.ts.map → button-BYuf72aR.d.ts.map} +1 -1
  11. package/dist/button.d.ts +1 -1
  12. package/dist/colour-picker.d.ts +1 -1
  13. package/dist/crumbs.d.ts +3 -3
  14. package/dist/crumbs.js +1 -1
  15. package/dist/{data-grid-CfiaBqTA.d.ts → data-grid-C6EjCe5O.d.ts} +48 -6
  16. package/dist/data-grid-C6EjCe5O.d.ts.map +1 -0
  17. package/dist/data-grid.d.ts +3 -3
  18. package/dist/data-grid.js +67 -3
  19. package/dist/data-grid.js.map +1 -1
  20. package/dist/{detail-list-DxQZkK-C.d.ts → detail-list-CmdCxddo.d.ts} +22 -5
  21. package/dist/detail-list-CmdCxddo.d.ts.map +1 -0
  22. package/dist/detail-list.d.ts +3 -3
  23. package/dist/detail-list.js +67 -4
  24. package/dist/detail-list.js.map +1 -1
  25. package/dist/{grid-list-k89JpiNy.js → grid-list-3fGhdaFA.js} +92 -7
  26. package/dist/grid-list-3fGhdaFA.js.map +1 -0
  27. package/dist/{grid-list-ChgYpUUq.d.ts → grid-list-D6AfWfwn.d.ts} +27 -4
  28. package/dist/grid-list-D6AfWfwn.d.ts.map +1 -0
  29. package/dist/grid-list.d.ts +2 -2
  30. package/dist/grid-list.js +1 -1
  31. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts → grouped-item-lister-DETte0Dh.d.ts} +2 -2
  32. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts.map → grouped-item-lister-DETte0Dh.d.ts.map} +1 -1
  33. package/dist/grouped-item-lister.d.ts +1 -1
  34. package/dist/{icon-DR_3rSyZ.d.ts → icon-CDGprSdE.d.ts} +2 -2
  35. package/dist/icon-CDGprSdE.d.ts.map +1 -0
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/incr-search.d.ts +1 -1
  38. package/dist/{index-CHbuPyLL.d.ts → index-CMsmZ6Or.d.ts} +4 -4
  39. package/dist/{index-CHbuPyLL.d.ts.map → index-CMsmZ6Or.d.ts.map} +1 -1
  40. package/dist/{index-garxxqgV.d.ts → index-CYndvTWH.d.ts} +2 -2
  41. package/dist/{index-garxxqgV.d.ts.map → index-CYndvTWH.d.ts.map} +1 -1
  42. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +2 -2
  43. package/dist/{index-BUB5SICW.d.ts.map → index-CoqtMSd9.d.ts.map} +1 -1
  44. package/dist/{index-CDllxjVG.d.ts → index-DyTSt9OK.d.ts} +4 -4
  45. package/dist/{index-CDllxjVG.d.ts.map → index-DyTSt9OK.d.ts.map} +1 -1
  46. package/dist/{index-CJ5fshD3.d.ts → index-UvnoZUAo.d.ts} +2 -2
  47. package/dist/{index-CJ5fshD3.d.ts.map → index-UvnoZUAo.d.ts.map} +1 -1
  48. package/dist/{index-BOUaDIW-.d.ts → index-tq6f8007.d.ts} +2 -2
  49. package/dist/{index-BOUaDIW-.d.ts.map → index-tq6f8007.d.ts.map} +1 -1
  50. package/dist/index.d.ts +337 -140
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2038 -646
  53. package/dist/index.js.map +1 -1
  54. package/dist/item-appearance-BjcMyauw.js +31 -0
  55. package/dist/item-appearance-BjcMyauw.js.map +1 -0
  56. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +2 -2
  57. package/dist/{labelled-input-base-Dp_9KP0G.d.ts.map → labelled-input-base-DiTme1JO.d.ts.map} +1 -1
  58. package/dist/labelled-radial-input.d.ts +2 -2
  59. package/dist/labelled-radial-input.d.ts.map +1 -1
  60. package/dist/labelled-range-input.d.ts +1 -1
  61. package/dist/labelled-slider-input.d.ts +1 -1
  62. package/dist/{list-native-drag-controller-B9reY4uM.d.ts → list-native-drag-controller-CllsHLxw.d.ts} +2 -2
  63. package/dist/{list-native-drag-controller-B9reY4uM.d.ts.map → list-native-drag-controller-CllsHLxw.d.ts.map} +1 -1
  64. package/dist/{menu-BHT6HRND.js → menu-CsBUyUuS.js} +2 -2
  65. package/dist/{menu-BHT6HRND.js.map → menu-CsBUyUuS.js.map} +1 -1
  66. package/dist/{menu-item-DlxAb0Xr.d.ts → menu-item-BbgdcwBX.d.ts} +11 -2
  67. package/dist/menu-item-BbgdcwBX.d.ts.map +1 -0
  68. package/dist/{menu-item-JcpHCyF0.js → menu-item-CfndBGO8.js} +38 -3
  69. package/dist/menu-item-CfndBGO8.js.map +1 -0
  70. package/dist/menu.d.ts +2 -2
  71. package/dist/menu.js +2 -2
  72. package/dist/miller.d.ts +3 -3
  73. package/dist/miller.d.ts.map +1 -1
  74. package/dist/miller.js +18 -2
  75. package/dist/miller.js.map +1 -1
  76. package/dist/panel.d.ts +1 -1
  77. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-JK3xWVX2.d.ts} +2 -2
  78. package/dist/{split-layout-C5SYbDrq.d.ts.map → split-layout-JK3xWVX2.d.ts.map} +1 -1
  79. package/dist/split-layout.d.ts +1 -1
  80. package/dist/{tab-list-v8mKX7Fu.d.ts → tab-list-pqfKaVAE.d.ts} +2 -2
  81. package/dist/{tab-list-v8mKX7Fu.d.ts.map → tab-list-pqfKaVAE.d.ts.map} +1 -1
  82. package/dist/tabs.d.ts +1 -1
  83. package/dist/{titlebar-B5zSTZnF.d.ts → titlebar-Tqu-VvMU.d.ts} +2 -2
  84. package/dist/{titlebar-B5zSTZnF.d.ts.map → titlebar-Tqu-VvMU.d.ts.map} +1 -1
  85. package/dist/titlebar.d.ts +1 -1
  86. package/dist/{tree-component-CMGeqyjZ.d.ts → tree-component-DukLqesg.d.ts} +2 -2
  87. package/dist/{tree-component-CMGeqyjZ.d.ts.map → tree-component-DukLqesg.d.ts.map} +1 -1
  88. package/dist/tree.d.ts +4 -4
  89. package/dist/{types-3Jfcaxvo.d.ts → types-D-kYF16b.d.ts} +21 -1
  90. package/dist/types-D-kYF16b.d.ts.map +1 -0
  91. package/dist/{vertical-list-R25WqAVO.d.ts → vertical-list-DTp-UIUE.d.ts} +26 -3
  92. package/dist/vertical-list-DTp-UIUE.d.ts.map +1 -0
  93. package/dist/vertical-list.d.ts +2 -2
  94. package/dist/vertical-list.js +53 -4
  95. package/dist/vertical-list.js.map +1 -1
  96. package/docs-user/data-grid.md +51 -1
  97. package/docs-user/detail-list.md +46 -0
  98. package/docs-user/grid-list.md +49 -2
  99. package/docs-user/hyperbolic-tree.md +98 -18
  100. package/docs-user/index.json +2 -2
  101. package/docs-user/menu.md +30 -9
  102. package/docs-user/miller.md +52 -7
  103. package/docs-user/vertical-list.md +63 -0
  104. package/package.json +1 -1
  105. package/dist/data-grid-CfiaBqTA.d.ts.map +0 -1
  106. package/dist/detail-list-DxQZkK-C.d.ts.map +0 -1
  107. package/dist/grid-list-ChgYpUUq.d.ts.map +0 -1
  108. package/dist/grid-list-k89JpiNy.js.map +0 -1
  109. package/dist/icon-DR_3rSyZ.d.ts.map +0 -1
  110. package/dist/menu-item-DlxAb0Xr.d.ts.map +0 -1
  111. package/dist/menu-item-JcpHCyF0.js.map +0 -1
  112. package/dist/types-3Jfcaxvo.d.ts.map +0 -1
  113. package/dist/vertical-list-R25WqAVO.d.ts.map +0 -1
package/bundle/index.js CHANGED
@@ -7248,6 +7248,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7248
7248
  this._hasContextTarget = false;
7249
7249
  this._anchorName = `--mt-${_MenuTrigger._count++}`;
7250
7250
  this._clickListener = this._handleClickOutside.bind(this);
7251
+ this._focusBeforeOpen = null;
7251
7252
  this._handlePopoverToggle = (e) => {
7252
7253
  if (e.newState === `closed`) {
7253
7254
  this._isOpen = false;
@@ -7264,10 +7265,10 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7264
7265
  e.preventDefault();
7265
7266
  e.stopPropagation();
7266
7267
  this._closeMenu();
7267
- this._triggerBtn?.focus();
7268
+ this._restoreFocus();
7268
7269
  } else if (e.key === `Tab`) {
7269
7270
  this._closeMenu();
7270
- this._triggerBtn?.focus();
7271
+ this._restoreFocus();
7271
7272
  }
7272
7273
  };
7273
7274
  }
@@ -7305,6 +7306,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7305
7306
  }
7306
7307
  updated(changedProperties) {
7307
7308
  if (changedProperties.has(`registry`) || changedProperties.has(`keyboardManager`)) this._propagateContext();
7309
+ if (changedProperties.has(`mode`) || changedProperties.has(`_hasContextTarget`)) this.toggleAttribute(`has-context-target`, this.mode === `context` && this._hasContextTarget);
7308
7310
  if (changedProperties.has(`mode`)) this._updatePosition();
7309
7311
  }
7310
7312
  _updatePosition() {
@@ -7370,6 +7372,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7370
7372
  }
7371
7373
  _openMenu() {
7372
7374
  if (this._isOpen || !this._popup?.popover) return;
7375
+ this._focusBeforeOpen = deepActiveElement();
7373
7376
  try {
7374
7377
  this._popup.showPopover({ source: this });
7375
7378
  this._isOpen = true;
@@ -7411,6 +7414,22 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7411
7414
  _getMenuContainer() {
7412
7415
  return ((this.shadowRoot?.querySelector(`slot[name="menu"]`))?.assignedElements({ flatten: true }) ?? []).find((el) => el.tagName === `IXFX-MENU-CONTAINER`) ?? null;
7413
7416
  }
7417
+ /**
7418
+ * Return focus after a keyboard close. The trigger button is the natural
7419
+ * target, but it is not rendered for a context target — then fall back to
7420
+ * whatever was focused at open (don't rely on the browser's popover focus
7421
+ * restoration), and finally the host.
7422
+ */
7423
+ _restoreFocus() {
7424
+ if (this._triggerBtn) {
7425
+ this._triggerBtn.focus();
7426
+ return;
7427
+ }
7428
+ const previous = this._focusBeforeOpen;
7429
+ this._focusBeforeOpen = null;
7430
+ if (previous?.isConnected && previous !== document.body) previous.focus();
7431
+ else this.focus();
7432
+ }
7414
7433
  _handleCommand(e) {
7415
7434
  if (this._popup?.popover) try {
7416
7435
  this._popup.hidePopover();
@@ -7454,6 +7473,16 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7454
7473
  --ixfx-button-padding: var(--ixfx-button-padding, var(--space-s) var(--space-m));
7455
7474
  }
7456
7475
 
7476
+ /* Context mode with a context-target: dissolve the host's box so the
7477
+ slotted surface is itself the item in the consumer's flex/grid layout.
7478
+ Otherwise the host sizes to the surface's full un-scrolled content and
7479
+ a height-constrained parent never clips it. Safe because context opens
7480
+ position the popup at the pointer (fixed), not via the anchor.
7481
+ Override with --menu-trigger-display (e.g. block) to keep a box. */
7482
+ :host([mode="context"][has-context-target]) {
7483
+ display: var(--menu-trigger-display, contents);
7484
+ }
7485
+
7457
7486
  .trigger-area {
7458
7487
  anchor-name: var(--anchor-name);
7459
7488
  /* Block flex (not inline-flex): as an inline-level box this would join
@@ -7581,6 +7610,12 @@ __decorate([state()], MenuTrigger.prototype, "_hasContextTarget", void 0);
7581
7610
  __decorate([query(`.trigger-btn`)], MenuTrigger.prototype, "_triggerBtn", void 0);
7582
7611
  __decorate([query(`.popup`)], MenuTrigger.prototype, "_popup", void 0);
7583
7612
  MenuTrigger = _MenuTrigger = __decorate([safeCustomElement(`ixfx-menu-trigger`)], MenuTrigger);
7613
+ /** `document.activeElement`, followed down through open shadow roots. */
7614
+ function deepActiveElement() {
7615
+ let el = document.activeElement;
7616
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
7617
+ return el instanceof HTMLElement ? el : null;
7618
+ }
7584
7619
  //#endregion
7585
7620
  //#region src/menu/menu-item.ts
7586
7621
  let MenuItem = class MenuItem extends LitElement {
@@ -12013,6 +12048,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12013
12048
  this.path = ``;
12014
12049
  this.separator = `/`;
12015
12050
  this.pathRoot = ``;
12051
+ this.hintPrevious = false;
12016
12052
  this._openIdx = null;
12017
12053
  this._editing = false;
12018
12054
  this._cachedSiblings = /* @__PURE__ */ new Map();
@@ -12024,6 +12060,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12024
12060
  this._openCumPath = null;
12025
12061
  this._dragCumPath = null;
12026
12062
  this._dropCumPath = null;
12063
+ this._hintSegments = null;
12027
12064
  this.#uid = Math.random().toString(36).slice(2, 8);
12028
12065
  this.#clickSuppressUntil = 0;
12029
12066
  this.#onPopoverToggle = (e) => {
@@ -12159,6 +12196,19 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12159
12196
  #clicksSuppressed() {
12160
12197
  return Date.now() < this.#clickSuppressUntil || this._dragCumPath !== null;
12161
12198
  }
12199
+ #isSegmentPrefix(prefix, full) {
12200
+ return prefix.length <= full.length && prefix.every((seg, i) => seg === full[i]);
12201
+ }
12202
+ /**
12203
+ * Called when a real segment click shortens the path. Keeps the deepest
12204
+ * known branch: only overwrites `_hintSegments` when the current hint no
12205
+ * longer covers where we're navigating to.
12206
+ */
12207
+ #recordHintOnShorten(oldSegments, newSegments) {
12208
+ if (!this.hintPrevious) return;
12209
+ const hint = this._hintSegments;
12210
+ if (!(hint !== null && this.#isSegmentPrefix(newSegments, hint))) this._hintSegments = oldSegments;
12211
+ }
12162
12212
  #resolveSegmentIcon(fullPathSegments, index) {
12163
12213
  const direct = this.segmentIconLookup?.(fullPathSegments, index);
12164
12214
  if (typeof direct === `string` && direct.length > 0) return direct;
@@ -12304,12 +12354,63 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12304
12354
  composed: true
12305
12355
  }));
12306
12356
  }
12357
+ /**
12358
+ * The current leaf segment (deepest visible segment), used as the drop target
12359
+ * when a drag is released over blank space rather than a specific `.seg`.
12360
+ */
12361
+ #leafSegment() {
12362
+ const segments = this.#getSegments();
12363
+ if (segments.length > 0) {
12364
+ const index = segments.length - 1;
12365
+ return {
12366
+ cumPath: this.#getCumulativePath(index, false),
12367
+ fullPathSegments: this.#resolveFullPathSegments(index, false)
12368
+ };
12369
+ }
12370
+ const rootSegments = this.#getRootSegments();
12371
+ if (rootSegments.length > 0) {
12372
+ const index = rootSegments.length - 1;
12373
+ return {
12374
+ cumPath: this.#getCumulativePath(index, true),
12375
+ fullPathSegments: this.#resolveFullPathSegments(index, true)
12376
+ };
12377
+ }
12378
+ }
12379
+ /**
12380
+ * Is `event` already being handled by a `.seg`'s own drag handlers? Virtual
12381
+ * (hint) segments don't have drag handlers of their own, so a drag released
12382
+ * over one falls through to the host-level fallback (the real leaf).
12383
+ */
12384
+ #eventTargetsSegment(event) {
12385
+ return Boolean(event.target?.closest?.(`.seg:not(.seg-virtual)`));
12386
+ }
12387
+ #onHostDragOver(event) {
12388
+ if (!this.parseExternalDrop || !event.dataTransfer) return;
12389
+ if (this.#eventTargetsSegment(event)) return;
12390
+ const leaf = this.#leafSegment();
12391
+ if (!leaf) return;
12392
+ event.preventDefault();
12393
+ if (this._dropCumPath !== leaf.cumPath) this._dropCumPath = leaf.cumPath;
12394
+ }
12395
+ #onHostDragLeave(event) {
12396
+ if (!this.parseExternalDrop || this.#eventTargetsSegment(event)) return;
12397
+ const leaf = this.#leafSegment();
12398
+ if (leaf && this._dropCumPath === leaf.cumPath) this._dropCumPath = null;
12399
+ }
12400
+ #onHostDrop(event) {
12401
+ if (!this.parseExternalDrop || this.#eventTargetsSegment(event)) return;
12402
+ const leaf = this.#leafSegment();
12403
+ if (!leaf) return;
12404
+ this.#onSegDrop(leaf.fullPathSegments, event);
12405
+ }
12307
12406
  #onLabelClick(index, event) {
12308
12407
  if (this.#clicksSuppressed()) return;
12309
12408
  event.preventDefault();
12310
12409
  event.stopPropagation();
12311
12410
  this.#closeDropdown();
12312
- const truncatedSegments = this.#getSegments().slice(0, index + 1);
12411
+ const segments = this.#getSegments();
12412
+ const truncatedSegments = segments.slice(0, index + 1);
12413
+ this.#recordHintOnShorten(segments, truncatedSegments);
12313
12414
  const hadLeading = this.path.startsWith(this.separator);
12314
12415
  const newPath = truncatedSegments.join(this.separator);
12315
12416
  this.path = hadLeading ? this.separator + newPath : newPath;
@@ -12327,6 +12428,31 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12327
12428
  composed: true
12328
12429
  }));
12329
12430
  }
12431
+ #onVirtualSegmentClick(hintIndex, event) {
12432
+ if (this.#clicksSuppressed()) return;
12433
+ event.preventDefault();
12434
+ event.stopPropagation();
12435
+ this.#closeDropdown();
12436
+ const hint = this._hintSegments;
12437
+ if (!hint) return;
12438
+ const truncatedSegments = hint.slice(0, hintIndex + 1);
12439
+ const hadLeading = this.path.startsWith(this.separator);
12440
+ const newPath = truncatedSegments.join(this.separator);
12441
+ this.path = hadLeading ? this.separator + newPath : newPath;
12442
+ this._cachedSiblings = /* @__PURE__ */ new Map();
12443
+ this._leafSegmentIndex = null;
12444
+ const parentPath = truncatedSegments.slice(0, -1).join(this.separator);
12445
+ this.dispatchEvent(new CustomEvent(`navigate`, {
12446
+ detail: {
12447
+ index: hintIndex,
12448
+ label: truncatedSegments[hintIndex],
12449
+ parentPath,
12450
+ fullPath: newPath
12451
+ },
12452
+ bubbles: true,
12453
+ composed: true
12454
+ }));
12455
+ }
12330
12456
  #selectSibling(index, segment) {
12331
12457
  const cumPath = this._openCumPath;
12332
12458
  this.#closeDropdown();
@@ -12402,7 +12528,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12402
12528
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12403
12529
  <label title=${label}
12404
12530
  part="label"
12405
- ?draggable=${draggable}
12531
+ draggable=${draggable ? `true` : `false`}
12406
12532
  @click=${(e) => this.#onLabelClick(index, e)}
12407
12533
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12408
12534
  @dragend=${() => this.#onLabelDragEnd()}>
@@ -12419,6 +12545,26 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12419
12545
  </div>
12420
12546
  ` : nothing}
12421
12547
  </div>
12548
+ `;
12549
+ }
12550
+ /**
12551
+ * A translucent, click-to-restore segment beyond the current path, shown
12552
+ * while `hintPrevious` is enabled and the current path is a prefix of the
12553
+ * deepest path visited. Doesn't participate in drag'n'drop and isn't
12554
+ * keyboard-focusable.
12555
+ */
12556
+ #renderVirtualSegment(label, hintIndex) {
12557
+ const fullPathSegments = [...this.#getRootSegments(), ...(this._hintSegments ?? []).slice(0, hintIndex + 1)];
12558
+ const icon = this.#resolveSegmentIcon(fullPathSegments, fullPathSegments.length - 1);
12559
+ return html`
12560
+ <div class="seg seg-virtual" part="segment virtual">
12561
+ <label title=${label}
12562
+ part="label"
12563
+ @click=${(e) => this.#onVirtualSegmentClick(hintIndex, e)}>
12564
+ ${icon ? html`<ixfx-icon class="crumb-icon segment-icon" name=${icon}></ixfx-icon>` : nothing}
12565
+ ${label}
12566
+ </label>
12567
+ </div>
12422
12568
  `;
12423
12569
  }
12424
12570
  #renderRootSegment(label, index) {
@@ -12440,7 +12586,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12440
12586
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12441
12587
  <label title=${label}
12442
12588
  part="label"
12443
- ?draggable=${draggable}
12589
+ draggable=${draggable ? `true` : `false`}
12444
12590
  @click=${(e) => this.#onLabelClick(index, e)}
12445
12591
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12446
12592
  @dragend=${() => this.#onLabelDragEnd()}>${icon ? html`<ixfx-icon class="crumb-icon segment-icon" name=${icon}></ixfx-icon>` : nothing}${label}</label>
@@ -12546,6 +12692,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12546
12692
  #onHostKeyDown;
12547
12693
  render() {
12548
12694
  const segments = this.#getSegments();
12695
+ const hintSegments = this.hintPrevious ? this._hintSegments : null;
12696
+ const virtualSegments = hintSegments !== null && hintSegments.length > segments.length && this.#isSegmentPrefix(segments, hintSegments) ? hintSegments.slice(segments.length) : [];
12549
12697
  if (this._editing) {
12550
12698
  const editValue = this.path;
12551
12699
  return html`
@@ -12568,10 +12716,15 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12568
12716
  ${this.separator}
12569
12717
  </div>` : nothing;
12570
12718
  return html`
12571
- <div class="crumbs scroll-fade-x" tabindex="0" @keydown=${this.#onHostKeyDown}>
12719
+ <div class="crumbs scroll-fade-x" tabindex="0"
12720
+ @keydown=${this.#onHostKeyDown}
12721
+ @dragover=${(e) => this.#onHostDragOver(e)}
12722
+ @dragleave=${(e) => this.#onHostDragLeave(e)}
12723
+ @drop=${(e) => this.#onHostDrop(e)}>
12572
12724
  ${rootSegments.length > 0 ? rootSegments.map((label, i) => this.#renderRootSegment(label, i)) : nothing}
12573
12725
  ${leadingCaret}
12574
12726
  ${segments.map((label, i) => this.#renderSegment(label, i, i === segments.length - 1))}
12727
+ ${virtualSegments.map((label, i) => this.#renderVirtualSegment(label, segments.length + i))}
12575
12728
  <div class="trailing" @click=${this.#onTrailingClick}></div>
12576
12729
  </div>
12577
12730
  ${this._openIdx !== null ? html`
@@ -12598,6 +12751,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12598
12751
  --padding-m: var(--space-s);
12599
12752
  --hover-bg: var(--surface-5);
12600
12753
  --hover-fg: var(--surface-5-text);
12754
+ --crumb-hint-opacity: 0.35;
12755
+ --crumb-hint-hover-opacity: 0.6;
12601
12756
 
12602
12757
  display: flex;
12603
12758
  width: 100%;
@@ -12709,6 +12864,14 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12709
12864
  background: var(--crumb-drop-into-bg, color-mix(in srgb, var(--accent) 14%, transparent));
12710
12865
  }
12711
12866
 
12867
+ .seg-virtual {
12868
+ opacity: var(--crumb-hint-opacity);
12869
+ }
12870
+
12871
+ .seg-virtual:hover {
12872
+ opacity: var(--crumb-hint-hover-opacity);
12873
+ }
12874
+
12712
12875
  .crumb-icon {
12713
12876
  width: 1em;
12714
12877
  height: 1em;
@@ -12793,6 +12956,10 @@ __decorate([property({ type: Object })], CrumbPathElement.prototype, "siblingLoo
12793
12956
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentIconLookup", void 0);
12794
12957
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentDragLookup", void 0);
12795
12958
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "parseExternalDrop", void 0);
12959
+ __decorate([property({
12960
+ type: Boolean,
12961
+ attribute: `hint-previous`
12962
+ })], CrumbPathElement.prototype, "hintPrevious", void 0);
12796
12963
  __decorate([state()], CrumbPathElement.prototype, "_openIdx", void 0);
12797
12964
  __decorate([state()], CrumbPathElement.prototype, "_editing", void 0);
12798
12965
  __decorate([state()], CrumbPathElement.prototype, "_cachedSiblings", void 0);
@@ -12804,6 +12971,7 @@ __decorate([state()], CrumbPathElement.prototype, "_editStartIndex", void 0);
12804
12971
  __decorate([state()], CrumbPathElement.prototype, "_openCumPath", void 0);
12805
12972
  __decorate([state()], CrumbPathElement.prototype, "_dragCumPath", void 0);
12806
12973
  __decorate([state()], CrumbPathElement.prototype, "_dropCumPath", void 0);
12974
+ __decorate([state()], CrumbPathElement.prototype, "_hintSegments", void 0);
12807
12975
  CrumbPathElement = __decorate([safeCustomElement(`ixfx-crumb-path`)], CrumbPathElement);
12808
12976
  //#endregion
12809
12977
  //#region src/data-display/data-display.ts
@@ -13127,6 +13295,34 @@ let DataDisplayComponent = class DataDisplayComponent extends LitElement {
13127
13295
  __decorate([property({ type: Boolean })], DataDisplayComponent.prototype, "automap", void 0);
13128
13296
  DataDisplayComponent = __decorate([safeCustomElement(`ixfx-data-display`)], DataDisplayComponent);
13129
13297
  //#endregion
13298
+ //#region src/common/item-appearance.ts
13299
+ /**
13300
+ * Shared helpers for the list components' per-item appearance hooks — the
13301
+ * per-item icon colour / text colour / badge that miller-list, detail-list,
13302
+ * grid-list, data-grid and vertical-list all accept.
13303
+ *
13304
+ * Every component takes these three features in its own idiom (plain fields
13305
+ * on a `TreeItem`, a property-name lookup, an adapter function, a nominated
13306
+ * column) but they all agree on what "not supplied" means, and on never
13307
+ * emitting an empty slot for an absent value.
13308
+ */
13309
+ /**
13310
+ * Whether a per-item appearance value counts as "not supplied". `false` and
13311
+ * `''` are included so a consumer can clear a feature without rebuilding the
13312
+ * collection — e.g. `badge: flags && tagChip(flags.colour)`.
13313
+ */
13314
+ function appearanceAbsent(value) {
13315
+ return value === void 0 || value === null || value === false || value === ``;
13316
+ }
13317
+ /**
13318
+ * A usable CSS colour from a per-item value, or `undefined`. Non-strings and
13319
+ * empty strings are ignored rather than stringified, so a stray number or
13320
+ * `0` never lands in a `style` attribute.
13321
+ */
13322
+ function appearanceColor(value) {
13323
+ return typeof value === `string` && value.length > 0 ? value : void 0;
13324
+ }
13325
+ //#endregion
13130
13326
  //#region src/util/drag-ghost.ts
13131
13327
  /**
13132
13328
  * Theme-aware floating element helper.
@@ -14251,6 +14447,12 @@ __decorate([query(`.text`)], PanTextElement.prototype, "_textEl", void 0);
14251
14447
  PanTextElement = __decorate([safeCustomElement(`ixfx-pan-text`)], PanTextElement);
14252
14448
  //#endregion
14253
14449
  //#region src/data-grid/data-grid.ts
14450
+ const EMPTY_CELL_LOOK = {
14451
+ textColor: void 0,
14452
+ icon: void 0,
14453
+ iconColor: void 0,
14454
+ badge: void 0
14455
+ };
14254
14456
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
14255
14457
  function sameKeys$3(a, b) {
14256
14458
  if (a === void 0 || a.length !== b.length) return false;
@@ -14275,6 +14477,9 @@ let DataGridElement = class DataGridElement extends LitElement {
14275
14477
  this.allowGrouping = false;
14276
14478
  this.crossGroupSelection = false;
14277
14479
  this.columnMenu = `auto`;
14480
+ this.textColorColumn = ``;
14481
+ this.iconColumn = ``;
14482
+ this.badgeColumn = ``;
14278
14483
  this.itemsDraggable = false;
14279
14484
  this._columnFormatters = /* @__PURE__ */ new Map();
14280
14485
  this._resizingColKey = void 0;
@@ -14694,6 +14899,25 @@ let DataGridElement = class DataGridElement extends LitElement {
14694
14899
  }
14695
14900
  .cell.editable-text { cursor: text; }
14696
14901
  .cell input, .cell select { width: 100%; font: inherit; }
14902
+ /* Per-row icon / badge, enabled by nominating iconColumn / badgeColumn.
14903
+ The badge self-justifies to the cell's trailing edge; the value text
14904
+ keeps its own flex sizing so the cell still ellipsises. */
14905
+ .cell-icon {
14906
+ flex: none;
14907
+ display: inline-flex;
14908
+ align-items: center;
14909
+ justify-content: center;
14910
+ margin-inline-end: var(--data-grid-cell-icon-gap, 6px);
14911
+ width: var(--data-grid-cell-icon-size, 1em);
14912
+ height: var(--data-grid-cell-icon-size, 1em);
14913
+ }
14914
+ .cell-badge {
14915
+ flex: none;
14916
+ display: inline-flex;
14917
+ align-items: center;
14918
+ margin-inline-start: auto;
14919
+ padding-inline-start: var(--data-grid-badge-gap, 6px);
14920
+ }
14697
14921
  .checkbox-col { width: 28px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
14698
14922
  ixfx-menu-trigger { flex-shrink: 0; border-left: var(--border-subtle); background: var(--data-grid-header-bg); display: flex; align-items: center; --ixfx-button-padding: var(--space-s) var(--space-m); min-width: 44px; min-height: var(--data-grid-header-height); justify-content: center; }
14699
14923
  .header-cell[drag-source] { opacity: 0.4; }
@@ -15465,6 +15689,27 @@ let DataGridElement = class DataGridElement extends LitElement {
15465
15689
  if (this.globalFormatter) return this.globalFormatter(value, col.key, row);
15466
15690
  return value;
15467
15691
  }
15692
+ /**
15693
+ * Resolve the per-row appearance for the cell at `colKey`, honouring the
15694
+ * nominated columns. Additive — `col.renderer` / `col.formatter` still own
15695
+ * the cell's value; this only decorates it. Returns all-`undefined` for
15696
+ * every cell when nothing is nominated (the common case).
15697
+ */
15698
+ _cellLook(row, colKey) {
15699
+ if (this.textColorColumn === `` && this.iconColumn === `` && this.badgeColumn === ``) return EMPTY_CELL_LOOK;
15700
+ const rec = typeof row === `object` && row !== null ? row : {};
15701
+ const textColor = colKey === this.textColorColumn ? appearanceColor(rec.textColor) : void 0;
15702
+ const icon = colKey === this.iconColumn && !appearanceAbsent(rec.icon) ? rec.icon : void 0;
15703
+ const iconColor = icon === void 0 ? void 0 : appearanceColor(rec.iconColor);
15704
+ const badge = colKey === this.badgeColumn && !appearanceAbsent(rec.badge) ? rec.badge : void 0;
15705
+ if (textColor === void 0 && icon === void 0 && badge === void 0) return EMPTY_CELL_LOOK;
15706
+ return {
15707
+ textColor,
15708
+ icon,
15709
+ iconColor,
15710
+ badge
15711
+ };
15712
+ }
15468
15713
  _handleCellSecondClick(rowKey, col) {
15469
15714
  if (this.editability !== `user`) return;
15470
15715
  const now = Date.now();
@@ -15922,7 +16167,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15922
16167
  const textEditable = this._isEditable(row, col) && this._isTextEditor(col);
15923
16168
  const indent = col.align !== `right` && col.align !== `center` && col.reorderable !== false ? `padding-left:calc(var(--space-s) + var(--data-grid-reorder-handle-width, 10px) + var(--space-xs));` : ``;
15924
16169
  const canPan = this.cellOverflow === `pan-text` && !col.renderer && (selected || tickled);
15925
- return html`<div class="cell ${textEditable ? `editable-text` : ``} ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" data-col=${col.key} style="width:${col.width ?? 150}px; justify-content:${col.align === `right` ? `flex-end` : col.align === `center` ? `center` : `flex-start`}; ${indent}"
16170
+ const look = this._cellLook(row, col.key);
16171
+ const cellStyle = look.textColor === void 0 ? `` : `color:${look.textColor};`;
16172
+ return html`<div class="cell ${textEditable ? `editable-text` : ``} ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" data-col=${col.key} style="width:${col.width ?? 150}px; justify-content:${col.align === `right` ? `flex-end` : col.align === `center` ? `center` : `flex-start`}; ${indent} ${cellStyle}"
15926
16173
  @click=${(e) => {
15927
16174
  this._lastClickedCell = {
15928
16175
  rowKey,
@@ -15944,7 +16191,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15944
16191
  composed: true
15945
16192
  }));
15946
16193
  }}
15947
- >${canPan ? html`<ixfx-pan-text mode="loop">${cellContent}</ixfx-pan-text>` : cellContent}</div>`;
16194
+ >${look.icon === void 0 ? nothing : html`<span class="cell-icon" part="icon" style=${look.iconColor === void 0 ? nothing : `color:${look.iconColor}`}>${look.icon}</span>`}${canPan ? html`<ixfx-pan-text mode="loop">${cellContent}</ixfx-pan-text>` : cellContent}${look.badge === void 0 ? nothing : html`<span class="cell-badge" part="badge">${look.badge}</span>`}</div>`;
15948
16195
  })}
15949
16196
  </div>
15950
16197
  `;
@@ -16004,6 +16251,18 @@ __decorate([property({
16004
16251
  attribute: `column-menu`
16005
16252
  })], DataGridElement.prototype, "columnMenu", void 0);
16006
16253
  __decorate([property({ attribute: false })], DataGridElement.prototype, "globalFormatter", void 0);
16254
+ __decorate([property({
16255
+ type: String,
16256
+ attribute: `text-color-column`
16257
+ })], DataGridElement.prototype, "textColorColumn", void 0);
16258
+ __decorate([property({
16259
+ type: String,
16260
+ attribute: `icon-column`
16261
+ })], DataGridElement.prototype, "iconColumn", void 0);
16262
+ __decorate([property({
16263
+ type: String,
16264
+ attribute: `badge-column`
16265
+ })], DataGridElement.prototype, "badgeColumn", void 0);
16007
16266
  __decorate([property({ attribute: false })], DataGridElement.prototype, "filterPredicate", void 0);
16008
16267
  __decorate([property({ attribute: false })], DataGridElement.prototype, "rowSearchText", void 0);
16009
16268
  __decorate([property({
@@ -16304,6 +16563,14 @@ DataTableElement = __decorate([safeCustomElement(`ixfx-data-table`)], DataTableE
16304
16563
  const ITEM_SELECTOR$3 = `:scope > li`;
16305
16564
  const SEARCH_HIGHLIGHT_KEY = `detail-list-search`;
16306
16565
  const LABEL_SPAN_CSS = `overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0`;
16566
+ /**
16567
+ * Layout for the per-item badge wrapper `#ensureBadge` adds. Applied as inline
16568
+ * `cssText` rather than shadow CSS because `::slotted()` only matches the
16569
+ * host's *direct* children — the badge lives inside a slotted `<li>`, so a
16570
+ * `::slotted()` rule could never reach it. The values still read the CSS
16571
+ * custom property, so it stays themeable from the outside.
16572
+ */
16573
+ const BADGE_EL_CSS = `flex-shrink:0;display:inline-flex;align-items:center;margin-inline-start:auto;padding-inline-start:var(--detail-list-badge-gap,8px)`;
16307
16574
  function pointInRect(x, y, rect) {
16308
16575
  return rect.width > 0 && rect.height > 0 && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
16309
16576
  }
@@ -16337,6 +16604,9 @@ let DetailListElement = class DetailListElement extends LitElement {
16337
16604
  this.items = [];
16338
16605
  this.displayProperty = ``;
16339
16606
  this.iconProperty = ``;
16607
+ this.iconColorProperty = ``;
16608
+ this.textColorProperty = ``;
16609
+ this.badgeProperty = ``;
16340
16610
  this.keyProperty = ``;
16341
16611
  this.itemWrap = `stop`;
16342
16612
  this.itemsDraggable = false;
@@ -16538,7 +16808,8 @@ let DetailListElement = class DetailListElement extends LitElement {
16538
16808
  };
16539
16809
  this._onWheel = (e) => {
16540
16810
  e.preventDefault();
16541
- this._listEl.scrollLeft += e.deltaY;
16811
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
16812
+ this._listEl.scrollLeft += delta;
16542
16813
  };
16543
16814
  }
16544
16815
  #itemData;
@@ -16743,7 +17014,7 @@ let DetailListElement = class DetailListElement extends LitElement {
16743
17014
  });
16744
17015
  return;
16745
17016
  }
16746
- if (changed.has(`items`) && this.provider === void 0 && this.items.length > 0) this.#rebuildFromObjects(this.items);
17017
+ if ((changed.has(`items`) || changed.has(`displayProperty`) || changed.has(`formatter`) || changed.has(`iconProperty`) || changed.has(`iconColorProperty`) || changed.has(`textColorProperty`) || changed.has(`badgeProperty`) || changed.has(`keyProperty`)) && this.provider === void 0 && this.items.length > 0) this.#rebuildFromObjects(this.items);
16747
17018
  this.#syncFromProvider();
16748
17019
  if (changed.has(`sort`) || changed.has(`sortBy`) || changed.has(`sortComparator`)) this.#applySort();
16749
17020
  if (changed.has(`filterPredicate`)) {
@@ -16948,24 +17219,36 @@ let DetailListElement = class DetailListElement extends LitElement {
16948
17219
  const li = document.createElement(`li`);
16949
17220
  this.#itemData.set(li, item);
16950
17221
  const content = this.formatter ? this.formatter(item, index) : this.displayProperty ? this.#propertyValue(item, this.displayProperty) : item;
17222
+ const textColor = this.#colorValue(item, this.textColorProperty);
17223
+ const iconColor = this.#colorValue(item, this.iconColorProperty);
17224
+ if (iconColor !== void 0) li.setAttribute(`data-icon-color`, iconColor);
16951
17225
  if (typeof content === `string`) {
16952
- this._setLabel(li, content);
17226
+ const span = this._setLabel(li, content);
17227
+ if (textColor !== void 0) span.style.color = textColor;
16953
17228
  if (this.iconProperty) {
16954
17229
  const icon = this.#propertyValue(item, this.iconProperty);
16955
17230
  if (typeof icon === `string` && icon.length > 0) li.setAttribute(`data-icon`, icon);
16956
17231
  }
16957
- } else if (content instanceof Node) li.appendChild(content);
17232
+ } else if (content instanceof Node) {
17233
+ li.appendChild(content);
17234
+ if (textColor !== void 0) li.style.color = textColor;
17235
+ }
16958
17236
  if (this.keyProperty) {
16959
17237
  const key = this.#propertyValue(item, this.keyProperty);
16960
17238
  if (key !== void 0 && key !== null) li.setAttribute(`data-key`, String(key));
16961
17239
  }
16962
17240
  this.#ensureIcon(li);
17241
+ this.#ensureBadge(li, this.#propertyValue(item, this.badgeProperty));
16963
17242
  return li;
16964
17243
  }
16965
17244
  #propertyValue(item, prop) {
16966
17245
  if (typeof item !== `object` || item === null || !(prop in item)) return void 0;
16967
17246
  return item[prop];
16968
17247
  }
17248
+ /** A non-empty string from `item[prop]`, else `undefined`. */
17249
+ #colorValue(item, prop) {
17250
+ return prop ? appearanceColor(this.#propertyValue(item, prop)) : void 0;
17251
+ }
16969
17252
  #syncFromProvider() {
16970
17253
  if (this.provider === void 0) return;
16971
17254
  const items = this.#data.items;
@@ -17223,12 +17506,14 @@ let DetailListElement = class DetailListElement extends LitElement {
17223
17506
  if (this.interactionMode !== `checked`) return true;
17224
17507
  return !item.hasAttribute(`not-checked`);
17225
17508
  }
17509
+ /** Appends the caption span; returns it so callers can style it per item. */
17226
17510
  _setLabel(li, label) {
17227
17511
  const span = document.createElement(`span`);
17228
17512
  span.style.cssText = LABEL_SPAN_CSS;
17229
17513
  span.textContent = label;
17230
17514
  li.appendChild(span);
17231
17515
  li.setAttribute(`data-search-label`, label);
17516
+ return span;
17232
17517
  }
17233
17518
  /**
17234
17519
  * Inject a leading `<ixfx-icon>` into an item carrying `data-icon`.
@@ -17242,8 +17527,28 @@ let DetailListElement = class DetailListElement extends LitElement {
17242
17527
  icon.setAttribute(`name`, name);
17243
17528
  icon.style.marginInlineEnd = `var(--detail-list-icon-gap, 6px)`;
17244
17529
  icon.style.flexShrink = `0`;
17530
+ const color = li.getAttribute(`data-icon-color`);
17531
+ if (color) icon.style.color = color;
17245
17532
  li.prepend(icon);
17246
17533
  }
17534
+ /**
17535
+ * Append a right-justified Finder-style badge. `badge` may be a DOM `Node`
17536
+ * or anything Lit can render (typically a `TemplateResult`). Idempotent —
17537
+ * a second call replaces the existing badge.
17538
+ */
17539
+ #ensureBadge(li, badge) {
17540
+ if (appearanceAbsent(badge)) return;
17541
+ let holder = li.querySelector(`:scope > .item-badge`);
17542
+ if (!holder) {
17543
+ holder = document.createElement(`span`);
17544
+ holder.className = `item-badge`;
17545
+ holder.style.cssText = BADGE_EL_CSS;
17546
+ li.appendChild(holder);
17547
+ }
17548
+ holder.replaceChildren();
17549
+ if (badge instanceof Node) holder.appendChild(badge);
17550
+ else render(badge, holder);
17551
+ }
17247
17552
  /** Scan light-DOM children for unprocessed `data-icon` items. */
17248
17553
  #processChildren() {
17249
17554
  for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) this.#ensureIcon(el);
@@ -17517,6 +17822,10 @@ let DetailListElement = class DetailListElement extends LitElement {
17517
17822
  opacity: 0.6;
17518
17823
  }
17519
17824
 
17825
+ /* Per-item icon / badge (adapter mode) are styled inline by
17826
+ #ensureBadge — ::slotted() only reaches the host's direct children,
17827
+ and these spans live inside a slotted <li>. */
17828
+
17520
17829
  /* checked mode: the injected checkbox communicates selection — suppress bg */
17521
17830
  :host([interaction-mode="checked"]) ::slotted([data-selected]) {
17522
17831
  background: transparent;
@@ -17591,6 +17900,18 @@ __decorate([property({
17591
17900
  type: String,
17592
17901
  attribute: `icon-property`
17593
17902
  })], DetailListElement.prototype, "iconProperty", void 0);
17903
+ __decorate([property({
17904
+ type: String,
17905
+ attribute: `icon-color-property`
17906
+ })], DetailListElement.prototype, "iconColorProperty", void 0);
17907
+ __decorate([property({
17908
+ type: String,
17909
+ attribute: `text-color-property`
17910
+ })], DetailListElement.prototype, "textColorProperty", void 0);
17911
+ __decorate([property({
17912
+ type: String,
17913
+ attribute: `badge-property`
17914
+ })], DetailListElement.prototype, "badgeProperty", void 0);
17594
17915
  __decorate([property({
17595
17916
  type: String,
17596
17917
  attribute: `key-property`
@@ -23322,6 +23643,10 @@ var ElementGridListSource = class {
23322
23643
  if (eff === void 0) return void 0;
23323
23644
  return html`<slot name=${this.slotNameFor(this.#items[eff])}></slot>`;
23324
23645
  }
23646
+ iconAt(_index) {}
23647
+ iconColorAt(_index) {}
23648
+ textColorAt(_index) {}
23649
+ badgeAt(_index) {}
23325
23650
  setFilters(opts) {
23326
23651
  if (`predicate` in opts) this.#predicate = opts.predicate;
23327
23652
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23438,6 +23763,22 @@ var DataGridListSource = class {
23438
23763
  const eff = this.#effectiveIndices[index];
23439
23764
  return eff === void 0 ? void 0 : this.#adapter.render(this.#items[eff], eff);
23440
23765
  }
23766
+ iconAt(index) {
23767
+ const eff = this.#effectiveIndices[index];
23768
+ return eff === void 0 || this.#adapter.icon === void 0 ? void 0 : this.#adapter.icon(this.#items[eff], eff);
23769
+ }
23770
+ iconColorAt(index) {
23771
+ const eff = this.#effectiveIndices[index];
23772
+ return eff === void 0 ? void 0 : this.#adapter.iconColor?.(this.#items[eff], eff);
23773
+ }
23774
+ textColorAt(index) {
23775
+ const eff = this.#effectiveIndices[index];
23776
+ return eff === void 0 ? void 0 : this.#adapter.textColor?.(this.#items[eff], eff);
23777
+ }
23778
+ badgeAt(index) {
23779
+ const eff = this.#effectiveIndices[index];
23780
+ return eff === void 0 || this.#adapter.badge === void 0 ? void 0 : this.#adapter.badge(this.#items[eff], eff);
23781
+ }
23441
23782
  setFilters(opts) {
23442
23783
  if (`predicate` in opts) this.#predicate = opts.predicate;
23443
23784
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23978,6 +24319,50 @@ let GridListElement = class GridListElement extends LitElement {
23978
24319
  color: inherit;
23979
24320
  }
23980
24321
 
24322
+ /* ---- Per-item icon (data mode) ----
24323
+ Only [has-icon] switches to a column, so a cell without an icon keeps
24324
+ exactly the layout it had before. The main content stays stretched;
24325
+ the icon centres itself above it. */
24326
+ .content.has-icon {
24327
+ display: flex;
24328
+ flex-direction: column;
24329
+ min-height: 0;
24330
+ gap: var(--grid-list-icon-gap, 2px);
24331
+ }
24332
+ .cell-icon {
24333
+ flex: none;
24334
+ align-self: center;
24335
+ display: inline-flex;
24336
+ align-items: center;
24337
+ justify-content: center;
24338
+ width: var(--grid-list-icon-size, 1.25em);
24339
+ height: var(--grid-list-icon-size, 1.25em);
24340
+ }
24341
+
24342
+ /* ---- Per-item badge (data mode) ----
24343
+ Finder thumbnails put the tag inline, immediately before the name, so
24344
+ the caption becomes a flex row. The text is wrapped in its own
24345
+ shrinkable box so the ellipsis survives. */
24346
+ .caption.has-badge {
24347
+ display: flex;
24348
+ align-items: center;
24349
+ gap: var(--grid-list-badge-gap, 3px);
24350
+ text-align: initial;
24351
+ }
24352
+ :host([caption-align="left"]) .caption.has-badge { justify-content: flex-start; }
24353
+ :host([caption-align="right"]) .caption.has-badge { justify-content: flex-end; }
24354
+ .caption.has-badge .caption-text {
24355
+ flex: 0 1 auto;
24356
+ min-width: 0;
24357
+ overflow: hidden;
24358
+ text-overflow: ellipsis;
24359
+ }
24360
+ .caption .badge {
24361
+ flex: none;
24362
+ display: inline-flex;
24363
+ align-items: center;
24364
+ }
24365
+
23981
24366
  /* ---- Box modes (hidden / under / overlaid): the cell is one box with a
23982
24367
  single selection outline ---- */
23983
24368
  :host(:not([caption-style="separate"])) .cell {
@@ -24986,6 +25371,8 @@ let GridListElement = class GridListElement extends LitElement {
24986
25371
  const x = win.horizontal ? index * win.colStride : columnOf(index, win.cols) * win.colStride;
24987
25372
  const y = win.horizontal ? Math.max(0, (this._viewportHeight - cellHeight) / 2) : rowOf(index, win.cols) * win.rowStride;
24988
25373
  const captionCss = separate ? `height:${Math.max(0, this.captionHeight - 4)}px` : style === `under` ? `height:${this.captionHeight}px` : ``;
25374
+ const rawIcon = this.#source.iconAt(index);
25375
+ const icon = appearanceAbsent(rawIcon) ? void 0 : rawIcon;
24989
25376
  return html`
24990
25377
  <div
24991
25378
  class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${marquee ? ` marquee` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
@@ -25003,8 +25390,11 @@ let GridListElement = class GridListElement extends LitElement {
25003
25390
  @dblclick=${() => this.#onCellActivate(key)}
25004
25391
  @pointerenter=${() => this.#handleCellPointerEnter(key)}
25005
25392
  >
25006
- <div class="content" part="content">${this.#source.renderCellContent(index)}</div>
25007
- ${showCaption ? this.#renderCaption(caption, captionCss, tickled) : ``}
25393
+ <div class="content${icon === void 0 ? `` : ` has-icon`}" part="content">
25394
+ ${icon === void 0 ? nothing : html`<span class="cell-icon" part="icon" style=${this.#colorStyle(this.#source.iconColorAt(index))}>${icon}</span>`}
25395
+ ${this.#source.renderCellContent(index)}
25396
+ </div>
25397
+ ${showCaption ? this.#renderCaption(caption, captionCss, tickled, this.#source.badgeAt(index), this.#source.textColorAt(index)) : ``}
25008
25398
  </div>
25009
25399
  `;
25010
25400
  }
@@ -25012,10 +25402,25 @@ let GridListElement = class GridListElement extends LitElement {
25012
25402
  * A tickled cell swaps its plain label for an `ixfx-pan-text` in loop mode
25013
25403
  * so a truncated label scrolls instead of staying an ellipsis. Unticked
25014
25404
  * cells keep the cheap plain-text caption.
25405
+ *
25406
+ * With no `badge` and no `textColor` the DOM is exactly as it was — the
25407
+ * flex row (and its extra `<span>` around the text) only appears when a
25408
+ * badge needs to sit inline ahead of the name.
25015
25409
  */
25016
- #renderCaption(caption, captionCss, tickled) {
25017
- if (tickled) return html`<div class="caption" part="caption" style=${captionCss} title=${caption}><ixfx-pan-text mode="loop">${caption}</ixfx-pan-text></div>`;
25018
- return html`<div class="caption" part="caption" style=${captionCss} title=${caption}>${caption}</div>`;
25410
+ #renderCaption(caption, captionCss, tickled, badge, textColor) {
25411
+ const color = appearanceColor(textColor);
25412
+ const style = color === void 0 ? captionCss : `${captionCss}${captionCss.length > 0 ? `;` : ``}color:${color};`;
25413
+ if (appearanceAbsent(badge)) {
25414
+ if (tickled) return html`<div class="caption" part="caption" style=${style} title=${caption}><ixfx-pan-text mode="loop">${caption}</ixfx-pan-text></div>`;
25415
+ return html`<div class="caption" part="caption" style=${style} title=${caption}>${caption}</div>`;
25416
+ }
25417
+ const label = tickled ? html`<ixfx-pan-text mode="loop">${caption}</ixfx-pan-text>` : html`<span class="caption-text">${caption}</span>`;
25418
+ return html`<div class="caption has-badge" part="caption" style=${style} title=${caption}><span class="badge" part="badge">${badge}</span>${label}</div>`;
25419
+ }
25420
+ /** `color:<c>;` for a non-empty colour, else `nothing` (attribute removed). */
25421
+ #colorStyle(color) {
25422
+ const c = appearanceColor(color);
25423
+ return c === void 0 ? nothing : `color:${c};`;
25019
25424
  }
25020
25425
  /** Stop the active caption pan, if any, and forget it. */
25021
25426
  #stopActiveCaptionPan() {
@@ -28218,6 +28623,16 @@ const ITEM_SELECTOR = `li:not([data-virtual-spacer]), [data-list-item]:not([data
28218
28623
  * vertical rows are shorter relative to the viewport, so 3.
28219
28624
  */
28220
28625
  const VIRTUAL_OVERSCAN = 3;
28626
+ /**
28627
+ * Layout for the per-item icon / badge wrappers `#decorateAdapterItem` adds.
28628
+ *
28629
+ * Applied as inline `cssText` rather than shadow CSS because `::slotted()` only
28630
+ * matches the host's *direct* children — these spans live inside a slotted
28631
+ * `<li>`, so a `::slotted()` rule could never reach them. The values still read
28632
+ * the CSS custom properties, so they stay themeable from the outside.
28633
+ */
28634
+ const ITEM_ICON_EL_CSS = `flex:none;display:inline-flex;align-items:center;justify-content:center;overflow:hidden;margin-inline-end:var(--list-item-icon-gap,6px);width:var(--list-item-icon-size,1em);height:var(--list-item-icon-size,1em)`;
28635
+ const ITEM_BADGE_EL_CSS = `flex:none;display:inline-flex;align-items:center;margin-inline-start:auto;padding-inline-start:var(--list-item-badge-gap,8px)`;
28221
28636
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
28222
28637
  function sameKeys(a, b) {
28223
28638
  if (a === void 0 || a.length !== b.length) return false;
@@ -28627,7 +29042,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28627
29042
  }
28628
29043
  this.#syncSearchCommands();
28629
29044
  }
28630
- if ((changed.has(`items`) || changed.has(`formatter`) || changed.has(`keyProperty`)) && this.items !== void 0) {
29045
+ if ((changed.has(`items`) || changed.has(`formatter`) || changed.has(`keyProperty`) || changed.has(`icon`) || changed.has(`iconColor`) || changed.has(`textColor`) || changed.has(`badge`)) && this.items !== void 0) {
28631
29046
  if (this.#suspendAdapterRebuild) this.#suspendAdapterRebuild = false;
28632
29047
  else if (this.#virtualActive()) this.#rebuildAdapterVirtual();
28633
29048
  else this.#rebuildFromAdapter();
@@ -28897,7 +29312,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28897
29312
  const fresh = this.formatter ? this.formatter(data, index) : data;
28898
29313
  if (el instanceof HTMLLIElement) {
28899
29314
  for (const child of [...el.childNodes]) child.remove();
28900
- this.#fillAdapterItem(el, fresh, data);
29315
+ this.#fillAdapterItem(el, fresh, data, index);
28901
29316
  if (this.interactionMode === `checked`) this._syncCheckboxes();
28902
29317
  this._syncItemStates();
28903
29318
  } else if (fresh instanceof Element && this.#isRowElement(fresh)) {
@@ -28952,17 +29367,47 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28952
29367
  return content;
28953
29368
  }
28954
29369
  const li = document.createElement(`li`);
28955
- this.#fillAdapterItem(li, content, item);
29370
+ this.#fillAdapterItem(li, content, item, index);
28956
29371
  return li;
28957
29372
  }
28958
- #fillAdapterItem(li, content, item) {
29373
+ #fillAdapterItem(li, content, item, index) {
28959
29374
  if (typeof content === `string`) li.textContent = content;
28960
29375
  else if (content instanceof Node) li.appendChild(content);
28961
29376
  else if (this.formatter && content !== void 0 && content !== null) render(content, li);
29377
+ this.#decorateAdapterItem(li, item, index);
28962
29378
  this.#stampAdapterKey(li, item);
28963
29379
  this.#itemData.set(li, item);
28964
29380
  }
28965
29381
  /**
29382
+ * Apply the per-item icon / text colour / badge to a built row. Split out
29383
+ * from {@link #fillAdapterItem} so the parts stay readable.
29384
+ */
29385
+ #decorateAdapterItem(li, item, index) {
29386
+ const textColor = appearanceColor(this.textColor?.(item, index));
29387
+ if (textColor !== void 0) li.style.color = textColor;
29388
+ else li.style.removeProperty(`color`);
29389
+ const icon = this.icon?.(item, index);
29390
+ if (!appearanceAbsent(icon)) {
29391
+ const holder = document.createElement(`span`);
29392
+ holder.className = `item-icon`;
29393
+ holder.style.cssText = ITEM_ICON_EL_CSS;
29394
+ const iconColor = appearanceColor(this.iconColor?.(item, index));
29395
+ if (iconColor !== void 0) holder.style.color = iconColor;
29396
+ if (icon instanceof Node) holder.appendChild(icon);
29397
+ else render(icon, holder);
29398
+ li.prepend(holder);
29399
+ }
29400
+ const badge = this.badge?.(item, index);
29401
+ if (!appearanceAbsent(badge)) {
29402
+ const holder = document.createElement(`span`);
29403
+ holder.className = `item-badge`;
29404
+ holder.style.cssText = ITEM_BADGE_EL_CSS;
29405
+ if (badge instanceof Node) holder.appendChild(badge);
29406
+ else render(badge, holder);
29407
+ li.appendChild(holder);
29408
+ }
29409
+ }
29410
+ /**
28966
29411
  * Whether a formatter-produced element is a whole row (used directly)
28967
29412
  * rather than row content (rendered into a `<li>`): an `<li>`, already
28968
29413
  * marked `[data-list-item]`, or a custom element.
@@ -29484,6 +29929,10 @@ let VerticalListElement = class VerticalListElement extends LitElement {
29484
29929
  outline: 1px solid transparent;
29485
29930
  }
29486
29931
 
29932
+ /* Per-item icon / badge (adapter mode) are styled inline by
29933
+ #decorateAdapterItem — ::slotted() only reaches the host's direct
29934
+ children, and these spans live inside a slotted <li>. */
29935
+
29487
29936
  /* Custom shadow-DOM components handle their own internal layout/padding.
29488
29937
  Wrapped in :where() so the selector stays at zero specificity and a
29489
29938
  consumer's own :host rule (e.g. display: flex) wins without
@@ -29630,6 +30079,10 @@ __decorate([property({
29630
30079
  attribute: `key-property`
29631
30080
  })], VerticalListElement.prototype, "keyProperty", void 0);
29632
30081
  __decorate([property({ attribute: false })], VerticalListElement.prototype, "formatter", void 0);
30082
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "icon", void 0);
30083
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "iconColor", void 0);
30084
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "textColor", void 0);
30085
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "badge", void 0);
29633
30086
  __decorate([property({
29634
30087
  type: Boolean,
29635
30088
  attribute: `virtualize`,
@@ -30905,6 +31358,90 @@ __decorate([state()], IxfxHexEditorElement.prototype, "_containerH", void 0);
30905
31358
  __decorate([state()], IxfxHexEditorElement.prototype, "_shadeMap", void 0);
30906
31359
  __decorate([state()], IxfxHexEditorElement.prototype, "_focusSide", void 0);
30907
31360
  IxfxHexEditorElement = __decorate([safeCustomElement(`ixfx-hex-editor`)], IxfxHexEditorElement);
31361
+ /**
31362
+ * Whether `n` deserves a preview: it has children and at least one of them
31363
+ * isn't legible right now — laid out without a label, or not laid out at
31364
+ * all (outside the `maxRadius` window).
31365
+ */
31366
+ function needsChildPreview(n, visible, labelIds) {
31367
+ const children = n.node.children ?? [];
31368
+ if (children.length === 0) return false;
31369
+ const shown = visible.filter((v) => v.parentId === n.id);
31370
+ return shown.length < children.length || shown.some((v) => !labelIds.has(v.id));
31371
+ }
31372
+ /** Where node position `p` (disk units) lands on screen, in CSS px from the host's top-left. */
31373
+ function diskToHostPx(p, zoom, width, height) {
31374
+ const pxPerUnit = Math.min(width, height) / 2.1;
31375
+ return {
31376
+ x: width / 2 + (zoom.offset.x + zoom.zoom * p.x) * pxPerUnit,
31377
+ y: height / 2 + (zoom.offset.y + zoom.zoom * p.y) * pxPerUnit
31378
+ };
31379
+ }
31380
+ function renderChildPreview(preview, handlers) {
31381
+ if (!preview) return nothing;
31382
+ const children = preview.node.children ?? [];
31383
+ const listed = children.slice(0, 12);
31384
+ const more = children.length - listed.length;
31385
+ return html`
31386
+ <div
31387
+ class="child-preview ${preview.above ? `above` : `below`}"
31388
+ part="child-preview"
31389
+ role="dialog"
31390
+ aria-label=${`Children of ${preview.node.item.label}`}
31391
+ style="left: ${preview.anchor.x}px; top: ${preview.anchor.y}px"
31392
+ @pointerenter=${handlers.onPointerEnter}
31393
+ @pointerleave=${handlers.onPointerLeave}
31394
+ @pointerdown=${(e) => e.stopPropagation()}
31395
+ >
31396
+ <div class="child-preview-title">${preview.node.item.label}</div>
31397
+ <ul>
31398
+ ${listed.map((child) => html`<li>
31399
+ <button type="button" tabindex="-1" @click=${() => handlers.onPick(child)}>${child.item.label}</button>
31400
+ </li>`)}
31401
+ </ul>
31402
+ ${more > 0 ? html`<div class="child-preview-more">+${more} more</div>` : nothing}
31403
+ </div>
31404
+ `;
31405
+ }
31406
+ /**
31407
+ * Show/hide timing for the preview: shows at once for a qualifying node;
31408
+ * otherwise hides after a short grace period, unless the pointer is over the
31409
+ * popover itself — so it can travel from the node onto the list.
31410
+ */
31411
+ function createChildPreviewTiming(host) {
31412
+ let hovered = false;
31413
+ let timer;
31414
+ const update = (id) => {
31415
+ clearTimeout(timer);
31416
+ const candidate = host.candidate(id);
31417
+ if (candidate !== void 0) {
31418
+ host.setPreviewId(candidate);
31419
+ return;
31420
+ }
31421
+ timer = setTimeout(() => {
31422
+ if (!hovered) host.setPreviewId(void 0);
31423
+ }, 250);
31424
+ };
31425
+ return {
31426
+ update,
31427
+ pointerEnter() {
31428
+ hovered = true;
31429
+ clearTimeout(timer);
31430
+ },
31431
+ pointerLeave(tickledId) {
31432
+ hovered = false;
31433
+ update(tickledId);
31434
+ },
31435
+ hide() {
31436
+ hovered = false;
31437
+ clearTimeout(timer);
31438
+ host.setPreviewId(void 0);
31439
+ },
31440
+ dispose() {
31441
+ clearTimeout(timer);
31442
+ }
31443
+ };
31444
+ }
30908
31445
  //#endregion
30909
31446
  //#region src/hyperbolic-tree/poincare-math.ts
30910
31447
  const EPSILON = 1e-9;
@@ -31009,8 +31546,625 @@ function geodesicInterpolate(a, b, t) {
31009
31546
  if (localNorm < EPSILON) return a;
31010
31547
  return mobiusFromOrigin(complexScale(complexScale(bLocal, 1 / localNorm), euclideanRadiusForHyperbolicDistance(hyperbolicDistanceForEuclideanRadius(localNorm) * t)), a);
31011
31548
  }
31549
+ const identityIsometry = {
31550
+ a: {
31551
+ x: 1,
31552
+ y: 0
31553
+ },
31554
+ b: {
31555
+ x: 0,
31556
+ y: 0
31557
+ }
31558
+ };
31559
+ /** Apply `m` to `z`. */
31560
+ function applyIsometry(m, z) {
31561
+ return clampToDisk(complexDiv(complexAdd(complexMul(m.a, z), m.b), complexAdd(complexMul(complexConjugate(m.b), z), complexConjugate(m.a))));
31562
+ }
31563
+ /** `outer ∘ inner` — apply `inner` first, then `outer`. */
31564
+ function composeIsometry(outer, inner) {
31565
+ const a = complexAdd(complexMul(outer.a, inner.a), complexMul(outer.b, complexConjugate(inner.b)));
31566
+ const b = complexAdd(complexMul(outer.a, inner.b), complexMul(outer.b, complexConjugate(inner.a)));
31567
+ const det = Math.sqrt(Math.max(EPSILON, a.x * a.x + a.y * a.y - b.x * b.x - b.y * b.y));
31568
+ return {
31569
+ a: complexScale(a, 1 / det),
31570
+ b: complexScale(b, 1 / det)
31571
+ };
31572
+ }
31573
+ function invertIsometry(m) {
31574
+ return {
31575
+ a: complexConjugate(m.a),
31576
+ b: complexScale(m.b, -1)
31577
+ };
31578
+ }
31579
+ /** Pure translation carrying the origin to `p` (same map as {@link mobiusFromOrigin}). */
31580
+ function translationFromOrigin(p) {
31581
+ const scale = 1 / Math.sqrt(Math.max(EPSILON, 1 - p.x * p.x - p.y * p.y));
31582
+ return {
31583
+ a: {
31584
+ x: scale,
31585
+ y: 0
31586
+ },
31587
+ b: complexScale(p, scale)
31588
+ };
31589
+ }
31590
+ /** `z ↦ e^{iθ}·z`. */
31591
+ function rotationIsometry(theta) {
31592
+ return {
31593
+ a: {
31594
+ x: Math.cos(theta / 2),
31595
+ y: Math.sin(theta / 2)
31596
+ },
31597
+ b: {
31598
+ x: 0,
31599
+ y: 0
31600
+ }
31601
+ };
31602
+ }
31603
+ /**
31604
+ * The view map that sends canonical point `center` to the origin, then
31605
+ * rotates by `rotation` radians: `z ↦ e^{i·rotation}·mobiusToOrigin(z, center)`.
31606
+ */
31607
+ function viewIsometry(center, rotation = 0) {
31608
+ return composeIsometry(rotationIsometry(rotation), invertIsometry(translationFromOrigin(center)));
31609
+ }
31610
+ /** Decompose `m` into the `{ center, rotation }` accepted by {@link viewIsometry}. */
31611
+ function decomposeViewIsometry(m) {
31612
+ return {
31613
+ center: applyIsometry(invertIsometry(m), {
31614
+ x: 0,
31615
+ y: 0
31616
+ }),
31617
+ rotation: 2 * Math.atan2(m.a.y, m.a.x)
31618
+ };
31619
+ }
31620
+ function geodesicSegment(p, q) {
31621
+ const det = p.x * q.y - p.y * q.x;
31622
+ const chord = Math.hypot(q.x - p.x, q.y - p.y);
31623
+ if (Math.abs(det) < 1e-6 * Math.max(chord, EPSILON)) return { kind: `line` };
31624
+ const rp = (p.x * p.x + p.y * p.y + 1) / 2;
31625
+ const rq = (q.x * q.x + q.y * q.y + 1) / 2;
31626
+ const center = {
31627
+ x: (rp * q.y - rq * p.y) / det,
31628
+ y: (p.x * rq - q.x * rp) / det
31629
+ };
31630
+ return {
31631
+ kind: `arc`,
31632
+ center,
31633
+ radius: Math.sqrt(Math.max(0, center.x * center.x + center.y * center.y - 1)),
31634
+ sweep: (p.x - center.x) * (q.y - center.y) - (p.y - center.y) * (q.x - center.x) > 0 ? 1 : 0
31635
+ };
31636
+ }
31637
+ /** SVG path `d` for the geodesic from `p` to `q`, with coordinates multiplied by `scale`. */
31638
+ function geodesicPath(p, q, scale = 1) {
31639
+ const fmt = (n) => (n * scale).toFixed(2);
31640
+ const segment = geodesicSegment(p, q);
31641
+ const start = `M${fmt(p.x)},${fmt(p.y)}`;
31642
+ if (segment.kind === `line` || segment.radius * scale > 1e6) return `${start} L${fmt(q.x)},${fmt(q.y)}`;
31643
+ return `${start} A${fmt(segment.radius)},${fmt(segment.radius)} 0 0 ${segment.sweep} ${fmt(q.x)},${fmt(q.y)}`;
31644
+ }
31645
+ //#endregion
31646
+ //#region src/hyperbolic-tree/view-motion.ts
31647
+ /** Wrap an angle into `(-π, π]`. */
31648
+ function wrapAngle(theta) {
31649
+ return Math.atan2(Math.sin(theta), Math.cos(theta));
31650
+ }
31651
+ function easeInOutCubic(t) {
31652
+ return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
31653
+ }
31654
+ function prefersReducedMotion$1() {
31655
+ return typeof matchMedia === `function` && matchMedia(`(prefers-reduced-motion: reduce)`).matches;
31656
+ }
31657
+ /**
31658
+ * The view after dragging from `start` to `current` (both disk
31659
+ * coordinates), starting from `baseView`. Applies the hyperbolic translation
31660
+ * along the geodesic carrying the grabbed point to the pointer — a true
31661
+ * isometry, so the grabbed point tracks the pointer exactly and the tree
31662
+ * never distorts. (Adding a Euclidean offset to the canonical center
31663
+ * instead is not an isometry: it lurches badly near the disk boundary.)
31664
+ */
31665
+ function panView(baseView, start, current) {
31666
+ const toStart = translationFromOrigin(start);
31667
+ return composeIsometry(composeIsometry(toStart, composeIsometry(translationFromOrigin(mobiusToOrigin(current, start)), invertIsometry(toStart))), baseView);
31668
+ }
31669
+ /**
31670
+ * A focus-change animation from view `from` — the old view, re-expressed in
31671
+ * the *new* focus's local frame, so the new focus currently sits somewhere
31672
+ * off-center — to a settled view with the new focus at the center. The
31673
+ * center travels along the hyperbolic geodesic; the rotation is held, so the
31674
+ * tree glides into place without spinning, and the settled view keeps
31675
+ * whatever orientation it arrived with (`settledRotation`).
31676
+ */
31677
+ function focusTransition(from) {
31678
+ const { center, rotation } = decomposeViewIsometry(from);
31679
+ const settledRotation = wrapAngle(rotation);
31680
+ const origin = {
31681
+ x: 0,
31682
+ y: 0
31683
+ };
31684
+ return {
31685
+ settledRotation,
31686
+ viewAt: (t) => {
31687
+ const eased = easeInOutCubic(Math.min(1, Math.max(0, t)));
31688
+ return viewIsometry(geodesicInterpolate(center, origin, eased), settledRotation);
31689
+ }
31690
+ };
31691
+ }
31692
+ //#endregion
31693
+ //#region src/hyperbolic-tree/gestures.ts
31694
+ const MIN_ZOOM = .5;
31695
+ /**
31696
+ * Keep the view center over the disk (so you can't zoom off into empty
31697
+ * space), and re-center entirely once the whole disk fits again.
31698
+ */
31699
+ function clampZoomState(state) {
31700
+ const zoom = Math.min(8, Math.max(MIN_ZOOM, state.zoom));
31701
+ if (zoom <= 1) return {
31702
+ zoom,
31703
+ offset: {
31704
+ x: 0,
31705
+ y: 0
31706
+ }
31707
+ };
31708
+ const limit = zoom - 1;
31709
+ const length = Math.hypot(state.offset.x, state.offset.y);
31710
+ return {
31711
+ zoom,
31712
+ offset: length > limit ? {
31713
+ x: state.offset.x * limit / length,
31714
+ y: state.offset.y * limit / length
31715
+ } : state.offset
31716
+ };
31717
+ }
31718
+ /**
31719
+ * Zoom by `factor` keeping the disk point under `screenPoint` fixed on
31720
+ * screen — the "zoom where I'm pointing" behaviour of maps and image viewers.
31721
+ */
31722
+ function zoomAt(state, screenPoint, factor) {
31723
+ const zoom = Math.min(8, Math.max(MIN_ZOOM, state.zoom * factor));
31724
+ const disk = screenToDisk(screenPoint, state);
31725
+ return clampZoomState({
31726
+ zoom,
31727
+ offset: {
31728
+ x: screenPoint.x - zoom * disk.x,
31729
+ y: screenPoint.y - zoom * disk.y
31730
+ }
31731
+ });
31732
+ }
31733
+ /**
31734
+ * A client-space position in screen units: disk radii from the SVG's center,
31735
+ * before zoom. The disk fills the SVG's smaller dimension, with the
31736
+ * viewBox's 5% margin.
31737
+ */
31738
+ function clientToScreen(rect, clientX, clientY) {
31739
+ const unitsPerPixel = 2.1 / (Math.min(rect.width, rect.height) || 1);
31740
+ return {
31741
+ x: (clientX - (rect.left + rect.width / 2)) * unitsPerPixel,
31742
+ y: (clientY - (rect.top + rect.height / 2)) * unitsPerPixel
31743
+ };
31744
+ }
31745
+ function screenToDisk(p, state) {
31746
+ const zoom = state.zoom || 1;
31747
+ return {
31748
+ x: (p.x - state.offset.x) / zoom,
31749
+ y: (p.y - state.offset.y) / zoom
31750
+ };
31751
+ }
31752
+ /** Is `event` on empty disk space (the SVG itself or the boundary circle), rather than a node? */
31753
+ function isBackground(event) {
31754
+ const target = event.target;
31755
+ return target === event.currentTarget || target?.classList.contains(`disk-boundary`) === true;
31756
+ }
31757
+ function createGestureHandlers(host) {
31758
+ const pointers = /* @__PURE__ */ new Map();
31759
+ let pan;
31760
+ let pinch;
31761
+ const screenOf = (event) => {
31762
+ const rect = host.svgRect();
31763
+ return rect ? clientToScreen(rect, event.clientX, event.clientY) : void 0;
31764
+ };
31765
+ const diskOf = (screen) => clampToDisk(screenToDisk(screen, host.zoomState()), .98);
31766
+ const midpoint = (a, b) => ({
31767
+ x: (a.x + b.x) / 2,
31768
+ y: (a.y + b.y) / 2
31769
+ });
31770
+ const startPan = (pointerId, screen) => {
31771
+ pan = {
31772
+ pointerId,
31773
+ start: diskOf(screen),
31774
+ baseView: host.takeView()
31775
+ };
31776
+ };
31777
+ const startPinch = () => {
31778
+ const [[idA, a], [idB, b]] = [...pointers.entries()];
31779
+ pan = void 0;
31780
+ const { zoom } = host.zoomState();
31781
+ pinch = {
31782
+ ids: [idA, idB],
31783
+ startDistance: Math.max(1e-6, Math.hypot(a.x - b.x, a.y - b.y)),
31784
+ startZoom: zoom,
31785
+ anchor: screenToDisk(midpoint(a, b), host.zoomState())
31786
+ };
31787
+ };
31788
+ return {
31789
+ onPointerDown(event) {
31790
+ if (!isBackground(event)) return;
31791
+ host.focusTree();
31792
+ const screen = screenOf(event);
31793
+ if (!screen) return;
31794
+ event.currentTarget.setPointerCapture?.(event.pointerId);
31795
+ pointers.set(event.pointerId, screen);
31796
+ if (pointers.size === 2 && host.zoomGesturesEnabled()) startPinch();
31797
+ else if (pointers.size === 1) startPan(event.pointerId, screen);
31798
+ },
31799
+ onPointerMove(event) {
31800
+ if (!pointers.has(event.pointerId)) return;
31801
+ const screen = screenOf(event);
31802
+ if (!screen) return;
31803
+ pointers.set(event.pointerId, screen);
31804
+ if (pinch) {
31805
+ const a = pointers.get(pinch.ids[0]);
31806
+ const b = pointers.get(pinch.ids[1]);
31807
+ if (!a || !b) return;
31808
+ const zoom = pinch.startZoom * Math.hypot(a.x - b.x, a.y - b.y) / pinch.startDistance;
31809
+ const mid = midpoint(a, b);
31810
+ const clampedZoom = Math.min(8, Math.max(MIN_ZOOM, zoom));
31811
+ host.setZoomState(clampZoomState({
31812
+ zoom: clampedZoom,
31813
+ offset: {
31814
+ x: mid.x - clampedZoom * pinch.anchor.x,
31815
+ y: mid.y - clampedZoom * pinch.anchor.y
31816
+ }
31817
+ }));
31818
+ } else if (pan && pan.pointerId === event.pointerId) host.setView(panView(pan.baseView, pan.start, diskOf(screen)));
31819
+ },
31820
+ onPointerUp(event) {
31821
+ if (!pointers.delete(event.pointerId)) return;
31822
+ if (pinch) {
31823
+ pinch = void 0;
31824
+ const [remaining] = [...pointers.entries()];
31825
+ if (remaining) startPan(remaining[0], remaining[1]);
31826
+ } else if (pan?.pointerId === event.pointerId) pan = void 0;
31827
+ },
31828
+ /**
31829
+ * Ctrl/⌘-wheel — which is also what a trackpad pinch produces — always
31830
+ * zooms while the pointer is over the tree. A plain wheel only zooms
31831
+ * once the tree has keyboard focus (the library's focus-gated wheel
31832
+ * convention), so scrolling past it doesn't get hijacked.
31833
+ */
31834
+ onWheel(event) {
31835
+ const modified = event.ctrlKey || event.metaKey;
31836
+ if (!host.zoomGesturesEnabled() || !modified && !host.hasFocus()) return;
31837
+ const screen = screenOf(event);
31838
+ if (!screen) return;
31839
+ event.preventDefault();
31840
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
31841
+ const factor = Math.exp(-delta * (modified ? .01 : .002));
31842
+ host.setZoomState(zoomAt(host.zoomState(), screen, factor));
31843
+ },
31844
+ /** Forget any in-progress pan (e.g. when a focus change takes over the view). */
31845
+ cancel() {
31846
+ pan = void 0;
31847
+ pinch = void 0;
31848
+ pointers.clear();
31849
+ }
31850
+ };
31851
+ }
31852
+ //#endregion
31853
+ //#region src/hyperbolic-tree/hyperbolictree-styles.ts
31854
+ /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
31855
+ const hyperbolicTreeStyles = css`
31856
+ :host {
31857
+ --hyperbolictree-node-color: var(--surface-4);
31858
+ --hyperbolictree-node-text: var(--surface-3-text);
31859
+ --hyperbolictree-edge-color: var(--surface-6);
31860
+ --hyperbolictree-boundary-color: var(--border-subtle);
31861
+ /* Real screen px — converted to SVG units from the element's rendered size. */
31862
+ --hyperbolictree-font-size: 13px;
31863
+ --hyperbolictree-box-color: var(--hyperbolictree-node-color);
31864
+ --hyperbolictree-box-text: var(--surface-4-text);
31865
+ --hyperbolictree-box-border: var(--surface-2);
31866
+ --hyperbolictree-branch-hue-start: 250;
31867
+ --hyperbolictree-branch-chroma: 0.13;
31868
+ --hyperbolictree-branch-lightness: 62%;
31869
+ --hyperbolictree-branch-mix: 55%;
31870
+
31871
+ display: block;
31872
+ position: relative;
31873
+ overflow: hidden;
31874
+ outline: none;
31875
+ overscroll-behavior: contain;
31876
+ }
31877
+
31878
+ /*
31879
+ * delegatesFocus means the host itself reports as focused whenever
31880
+ * the shadow DOM's focused descendant (the svg) is — without this,
31881
+ * the host also picks up the browser's own default focus ring/fill
31882
+ * sized to the whole box.
31883
+ */
31884
+ :host(:focus),
31885
+ :host(:focus-visible) {
31886
+ outline: none;
31887
+ }
31888
+
31889
+ .hyperbolictree {
31890
+ display: block;
31891
+ width: 100%;
31892
+ height: 100%;
31893
+ outline: none;
31894
+ touch-action: none;
31895
+ overscroll-behavior: contain;
31896
+ }
31897
+
31898
+ .disk-boundary {
31899
+ fill: none;
31900
+ stroke: var(--hyperbolictree-boundary-color);
31901
+ stroke-width: 1;
31902
+ }
31903
+
31904
+ .edge {
31905
+ fill: none;
31906
+ stroke: var(--hyperbolictree-edge-color);
31907
+ /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
31908
+ stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
31909
+ /* Constant on-screen width, however far the disk is zoomed. */
31910
+ vector-effect: non-scaling-stroke;
31911
+ }
31912
+
31913
+ .node {
31914
+ cursor: pointer;
31915
+ }
31916
+
31917
+ /*
31918
+ * color-by-branch: each top-level branch gets an evenly-spaced hue,
31919
+ * mixed into the normal node/box/edge colour (rather than replacing it)
31920
+ * so it stays in keeping with — and legible in — the current theme.
31921
+ * Only sets the base colour vars, so selected/tickled states still win.
31922
+ */
31923
+ :host([color-by-branch]) .node,
31924
+ :host([color-by-branch]) .edge {
31925
+ --_branch-color: oklch(
31926
+ var(--hyperbolictree-branch-lightness)
31927
+ var(--hyperbolictree-branch-chroma)
31928
+ calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
31929
+ );
31930
+ }
31931
+
31932
+ :host([color-by-branch]) .node {
31933
+ --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
31934
+ --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
31935
+ }
31936
+
31937
+ :host([color-by-branch]) .edge {
31938
+ stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
31939
+ }
31940
+
31941
+ .node-hit {
31942
+ fill: transparent;
31943
+ pointer-events: all;
31944
+ }
31945
+
31946
+ .node-dot {
31947
+ /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
31948
+ fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
31949
+ stroke: var(--surface-2);
31950
+ stroke-width: 1;
31951
+ pointer-events: none;
31952
+ }
31953
+
31954
+ .node-box {
31955
+ fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
31956
+ stroke: var(--hyperbolictree-box-border);
31957
+ stroke-width: 1.5;
31958
+ /* Keep the border crisp however far the box has been scaled down. */
31959
+ vector-effect: non-scaling-stroke;
31960
+ }
31961
+
31962
+ /* Text-less stand-in for a box too small, or too crowded, to label. */
31963
+ .node-box.marker {
31964
+ stroke-width: 1;
31965
+ }
31966
+
31967
+ /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
31968
+ .hyperbolictree-label {
31969
+ fill: var(--hyperbolictree-node-text);
31970
+ cursor: pointer;
31971
+ user-select: none;
31972
+ }
31973
+
31974
+ .box-label {
31975
+ fill: var(--hyperbolictree-box-text);
31976
+ pointer-events: none;
31977
+ }
31978
+
31979
+ /*
31980
+ * State fills come paired with their theme text tokens: a box label sits
31981
+ * on the state fill itself, so e.g. --item-bg-selected needs
31982
+ * --item-text-selected on top, not the resting box text colour.
31983
+ */
31984
+ .node.tickled .node-dot,
31985
+ .node.tickled .node-box {
31986
+ fill: var(--item-bg-tickled);
31987
+ }
31988
+
31989
+ .node.tickled .box-label {
31990
+ fill: var(--item-text-tickled);
31991
+ }
31992
+
31993
+ .node.selected .node-dot,
31994
+ .node.selected .node-box {
31995
+ fill: var(--item-bg-selected);
31996
+ }
31997
+
31998
+ .node.selected .box-label {
31999
+ fill: var(--item-text-selected);
32000
+ }
32001
+
32002
+ .node.selected.tickled .node-dot,
32003
+ .node.selected.tickled .node-box {
32004
+ fill: var(--item-bg-selected-tickled);
32005
+ }
32006
+
32007
+ .node.focused .node-dot,
32008
+ .node.focused .node-box {
32009
+ stroke: var(--accent);
32010
+ stroke-width: 3;
32011
+ }
32012
+
32013
+ /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
32014
+ .node.tickled .hyperbolictree-label:not(.box-label) {
32015
+ paint-order: stroke;
32016
+ stroke: var(--surface-2);
32017
+ stroke-width: 4px;
32018
+ stroke-linejoin: round;
32019
+ }
32020
+
32021
+ .node.filtered {
32022
+ opacity: 0.3;
32023
+ }
32024
+
32025
+ .node.loading .node-dot,
32026
+ .node.loading .node-box {
32027
+ opacity: 0.6;
32028
+ }
32029
+
32030
+
32031
+ /* Child preview: a clickable list of a crowded node's children. */
32032
+ .child-preview {
32033
+ position: absolute;
32034
+ z-index: 1;
32035
+ min-width: 8rem;
32036
+ max-width: min(18rem, 60%);
32037
+ max-height: 45%;
32038
+ overflow: auto;
32039
+ padding: var(--space-xs, 4px);
32040
+ background: var(--surface-3);
32041
+ color: var(--surface-3-text);
32042
+ border: var(--border-subtle);
32043
+ border-radius: var(--radius-m, 6px);
32044
+ box-shadow: var(--shadow-m);
32045
+ font-size: var(--hyperbolictree-font-size);
32046
+ transform: translate(-50%, 14px);
32047
+ }
32048
+
32049
+ .child-preview.above {
32050
+ transform: translate(-50%, calc(-100% - 14px));
32051
+ }
32052
+
32053
+ .child-preview-title {
32054
+ padding: 2px 6px 4px;
32055
+ font-weight: 600;
32056
+ overflow: hidden;
32057
+ text-overflow: ellipsis;
32058
+ white-space: nowrap;
32059
+ }
32060
+
32061
+ .child-preview ul {
32062
+ margin: 0;
32063
+ padding: 0;
32064
+ list-style: none;
32065
+ }
32066
+
32067
+ .child-preview button {
32068
+ display: block;
32069
+ width: 100%;
32070
+ padding: 2px 6px;
32071
+ border: none;
32072
+ border-radius: var(--radius-s, 4px);
32073
+ background: none;
32074
+ color: inherit;
32075
+ font: inherit;
32076
+ text-align: start;
32077
+ overflow: hidden;
32078
+ text-overflow: ellipsis;
32079
+ white-space: nowrap;
32080
+ cursor: pointer;
32081
+ }
32082
+
32083
+ .child-preview button:hover {
32084
+ background: var(--item-bg-tickled);
32085
+ color: var(--item-text-tickled);
32086
+ }
32087
+
32088
+ .child-preview-more {
32089
+ padding: 2px 6px;
32090
+ color: var(--surface-muted-text);
32091
+ }
32092
+ `;
31012
32093
  //#endregion
31013
32094
  //#region src/hyperbolic-tree/layout.ts
32095
+ const ID_SEPARATOR = `\u001F`;
32096
+ /**
32097
+ * Path-derived ids for `children` of the node with id `parentId`. `TreeItem.key`
32098
+ * is meant to be unique, but real data (e.g. a file system, where `readme.md`
32099
+ * recurs in many folders) often only has keys unique among siblings. Keying
32100
+ * the layout on `item.key` alone would silently merge those nodes — edges get
32101
+ * attached to the wrong parent and clicks focus the wrong node — so the
32102
+ * layout identifies nodes by their key path instead. Repeated keys among
32103
+ * siblings are disambiguated by occurrence count. The root's id is its key.
32104
+ */
32105
+ function hyperbolicChildIds(parentId, children) {
32106
+ const seen = /* @__PURE__ */ new Map();
32107
+ return children.map((child) => {
32108
+ const key = child.item.key;
32109
+ const count = seen.get(key) ?? 0;
32110
+ seen.set(key, count + 1);
32111
+ return `${parentId}${ID_SEPARATOR}${count === 0 ? key : `${key}#${count}`}`;
32112
+ });
32113
+ }
32114
+ /** Depth (root is 0) of the node with layout id `id`. */
32115
+ function hyperbolicIdDepth(id) {
32116
+ return id.split(ID_SEPARATOR).length - 1;
32117
+ }
32118
+ /**
32119
+ * Layout id of the root's child that `id` descends from (or is) — i.e. which
32120
+ * top-level branch it belongs to. `undefined` for the root itself.
32121
+ */
32122
+ function hyperbolicBranchId(id) {
32123
+ const parts = id.split(ID_SEPARATOR);
32124
+ return parts.length < 2 ? void 0 : `${parts[0]}${ID_SEPARATOR}${parts[1]}`;
32125
+ }
32126
+ /** Whether layout id `id` is a strict descendant of `ancestorId`. */
32127
+ function isHyperbolicDescendantId(id, ancestorId) {
32128
+ return id.startsWith(`${ancestorId}${ID_SEPARATOR}`);
32129
+ }
32130
+ /** Chain of nodes from `root` down to the node with layout id `id`, or `undefined` if absent. */
32131
+ function findHyperbolicPathById(root, id) {
32132
+ if (!root) return void 0;
32133
+ const walk = (node, nodeId) => {
32134
+ if (nodeId === id) return [node];
32135
+ if (!isHyperbolicDescendantId(id, nodeId)) return void 0;
32136
+ const children = node.children ?? [];
32137
+ const ids = hyperbolicChildIds(nodeId, children);
32138
+ for (let i = 0; i < children.length; i++) {
32139
+ const hit = walk(children[i], ids[i]);
32140
+ if (hit) return [node, ...hit];
32141
+ }
32142
+ };
32143
+ return walk(root, root.item.key);
32144
+ }
32145
+ /** Layout id of `target` (matched by object identity) within `root`, or `undefined` if absent. */
32146
+ function findHyperbolicIdOf(root, target) {
32147
+ if (!root) return void 0;
32148
+ const walk = (node, nodeId) => {
32149
+ if (node === target) return nodeId;
32150
+ const children = node.children ?? [];
32151
+ const ids = hyperbolicChildIds(nodeId, children);
32152
+ for (let i = 0; i < children.length; i++) {
32153
+ const hit = walk(children[i], ids[i]);
32154
+ if (hit !== void 0) return hit;
32155
+ }
32156
+ };
32157
+ return walk(root, root.item.key);
32158
+ }
32159
+ /** Key path (from the root's children) to the first node, depth-first, satisfying `predicate`. */
32160
+ function findHyperbolicKeyPath(nodes, predicate, prefix = []) {
32161
+ for (const node of nodes) {
32162
+ const path = [...prefix, node.item.key];
32163
+ if (predicate(node)) return path;
32164
+ const hit = node.children?.length ? findHyperbolicKeyPath(node.children, predicate, path) : void 0;
32165
+ if (hit) return hit;
32166
+ }
32167
+ }
31014
32168
  /** Depth-first search for the node with `key`. Returns `undefined` when absent. */
31015
32169
  function findHyperbolicNode(root, key) {
31016
32170
  if (!root) return void 0;
@@ -31085,21 +32239,58 @@ function stableUnitJitter(key) {
31085
32239
  * radially).
31086
32240
  */
31087
32241
  const CHAIN_JITTER_RADIANS = .22;
32242
+ /** Default minimum hyperbolic distance between adjacent siblings. See {@link childDistance}. */
32243
+ const DEFAULT_SIBLING_SPACING = .5;
32244
+ /**
32245
+ * Hyperbolic distance from parent to a child whose angular slice (in the
32246
+ * parent's frame) is `share`, such that the child sits at least `spacing`
32247
+ * from a neighbour one slice-width away: from the hyperbolic law of cosines
32248
+ * for an isosceles triangle, `sinh(d/2) = sinh(L)·sin(Δθ/2)`, solved for L.
32249
+ * Never less than `minimum` (the `curvature`), and capped so a sliver-thin
32250
+ * slice can't fling a child off to infinity.
32251
+ *
32252
+ * This is what makes panning *useful* in a crowded fan: panning is an
32253
+ * isometry — it can't change the hyperbolic distance between siblings, only
32254
+ * move them to where the disk magnifies more. If siblings are laid out
32255
+ * nearly on top of each other in hyperbolic terms, no amount of dragging
32256
+ * separates them; guaranteeing a minimum spacing means dragging a crowd
32257
+ * towards the center always fans it out.
32258
+ */
32259
+ function childDistance(share, spacing, minimum) {
32260
+ if (spacing <= 0) return minimum;
32261
+ const halfAngle = Math.min(share, Math.PI) / 2;
32262
+ const needed = Math.asinh(Math.sinh(spacing / 2) / Math.max(Math.sin(halfAngle), 1e-6));
32263
+ return Math.min(Math.max(minimum, needed), minimum + 6);
32264
+ }
31088
32265
  /**
31089
32266
  * Canonical (true-root-anchored) hyperbolic position of every currently-loaded
31090
- * node, keyed by node key. Positions never depend on the current focus —
32267
+ * node, keyed by layout id (see {@link hyperbolicChildIds}). Positions never depend on the current focus —
31091
32268
  * refocusing is purely a Möbius re-projection of these fixed coordinates, so
31092
32269
  * the tree never needs to be re-laid-out when the focus changes.
31093
32270
  *
31094
32271
  * Each node holds an angular wedge — inherited from its parent, expressed in
31095
32272
  * its *own* local frame (root starts with the full `[-π, π]`) — across which
31096
- * its children are placed at hyperbolic distance `curvature` (default `1`).
31097
- * That wedge is partitioned among the children, weighted by each child's own
32273
+ * its children are placed at hyperbolic distance of at least `curvature`
32274
+ * (default `1`) — further for a thin slice, per `siblingSpacing` (see
32275
+ * {@link childDistance}).
32276
+ * That wedge is partitioned among the children, weighted by √ of each child's own
31098
32277
  * subtree size rather than split evenly, and each child's own share is
31099
32278
  * carried into its local frame via {@link boundaryAngleInLocalFrame} before
31100
- * recursing. This is a strict partition (slices never overlap and always sum
31101
- * to the parent's own wedge), which is what guarantees sibling subtrees never
31102
- * spatially cross regardless of how deep or bushy they are.
32279
+ * recursing. This is a strict partition, and each child's wedge region
32280
+ * (bounded by geodesic rays to the same two ideal boundary points as its
32281
+ * slice) nests inside its parent's, which is what guarantees sibling
32282
+ * subtrees never spatially cross regardless of how deep or bushy they are.
32283
+ *
32284
+ * Every node's local frame is tracked as a full {@link DiskIsometry}
32285
+ * (local → canonical), built by composing its parent's frame with the
32286
+ * translation to the child. Composing Möbius translations introduces a
32287
+ * rotation (gyration); dropping it — i.e. assuming a node's frame is simply
32288
+ * `mobiusToOrigin(·, position)` — makes every wedge from depth 3 onward
32289
+ * point in a subtly wrong direction, so subtrees cross each other.
32290
+ *
32291
+ * A wedge wider than π is narrowed to π (around the same bisector) before
32292
+ * the child recurses into it: a near-full-circle wedge would otherwise fan
32293
+ * that child's own children back around towards — and across — its parent.
31103
32294
  *
31104
32295
  * `curvature` scales how far apart (in hyperbolic distance) each generation
31105
32296
  * sits. Raising it pushes each generation further out, so the Poincaré
@@ -31108,7 +32299,7 @@ const CHAIN_JITTER_RADIANS = .22;
31108
32299
  * in" on the focus. Lowering it spreads generations out more evenly,
31109
32300
  * flattening the view toward a Euclidean-feeling layout.
31110
32301
  */
31111
- function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
32302
+ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1, siblingSpacing = DEFAULT_SIBLING_SPACING) {
31112
32303
  const map = /* @__PURE__ */ new Map();
31113
32304
  if (!root) return map;
31114
32305
  const origin = {
@@ -31117,70 +32308,122 @@ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
31117
32308
  };
31118
32309
  map.set(root.item.key, {
31119
32310
  node: root,
32311
+ id: root.item.key,
31120
32312
  depth: 0,
31121
32313
  parentKey: void 0,
32314
+ parentId: void 0,
31122
32315
  position: origin,
32316
+ toParent: identityIsometry,
31123
32317
  angularShare: 2 * Math.PI
31124
32318
  });
31125
- const place = (node, position, wedgeStart, wedgeEnd, depth) => {
32319
+ const place = (node, id, frame, wedgeStart, wedgeEnd, depth) => {
31126
32320
  if (depth >= maxDepth) return;
31127
32321
  const children = node.children ?? [];
31128
32322
  const n = children.length;
31129
32323
  if (n === 0) return;
31130
- const r0 = euclideanRadiusForHyperbolicDistance(curvature);
31131
32324
  const angularWidth = wedgeEnd - wedgeStart;
31132
- const weights = children.map((child) => subtreeSize(child, maxDepth - depth - 1));
32325
+ const weights = children.map((child) => Math.sqrt(subtreeSize(child, maxDepth - depth - 1)));
31133
32326
  const totalWeight = weights.reduce((a, b) => a + b, 0);
32327
+ const ids = hyperbolicChildIds(id, children);
31134
32328
  let cursor = wedgeStart;
31135
32329
  children.forEach((child, index) => {
31136
32330
  const share = n === 1 ? angularWidth : angularWidth * weights[index] / totalWeight;
31137
- const childStart = cursor;
31138
- const childEnd = cursor + share;
31139
- cursor = childEnd;
31140
- const theta = (childStart + childEnd) / 2 + (n === 1 ? stableUnitJitter(child.item.key) * Math.min(CHAIN_JITTER_RADIANS, share * .4) : 0);
32331
+ const bisector = cursor + share / 2;
32332
+ cursor += share;
32333
+ const theta = bisector + (n === 1 ? stableUnitJitter(child.item.key) * Math.min(CHAIN_JITTER_RADIANS, share * .4) : 0);
32334
+ const r = euclideanRadiusForHyperbolicDistance(n === 1 ? curvature : childDistance(share, siblingSpacing, curvature));
31141
32335
  const local = {
31142
- x: r0 * Math.cos(theta),
31143
- y: r0 * Math.sin(theta)
32336
+ x: r * Math.cos(theta),
32337
+ y: r * Math.sin(theta)
31144
32338
  };
31145
- const canonicalPosition = mobiusFromOrigin(local, position);
31146
- map.set(child.item.key, {
32339
+ const toParent = translationFromOrigin(local);
32340
+ const childFrame = composeIsometry(frame, toParent);
32341
+ map.set(ids[index], {
31147
32342
  node: child,
32343
+ id: ids[index],
31148
32344
  depth: depth + 1,
31149
32345
  parentKey: node.item.key,
31150
- position: canonicalPosition,
32346
+ parentId: id,
32347
+ position: applyIsometry(childFrame, origin),
32348
+ toParent,
31151
32349
  angularShare: share
31152
32350
  });
31153
- const localStart = boundaryAngleInLocalFrame(childStart, local);
31154
- let localWidth = boundaryAngleInLocalFrame(childEnd, local) - localStart;
32351
+ const halfWedge = Math.min(share, Math.PI) / 2;
32352
+ const localStart = boundaryAngleInLocalFrame(bisector - halfWedge, local);
32353
+ let localWidth = boundaryAngleInLocalFrame(bisector + halfWedge, local) - localStart;
31155
32354
  if (localWidth <= 0) localWidth += 2 * Math.PI;
31156
- place(child, canonicalPosition, localStart, localStart + localWidth, depth + 1);
32355
+ place(child, ids[index], childFrame, localStart, localStart + localWidth, depth + 1);
31157
32356
  });
31158
32357
  };
31159
- place(root, origin, -Math.PI, Math.PI, 0);
32358
+ place(root, root.item.key, identityIsometry, -Math.PI, Math.PI, 0);
31160
32359
  return map;
31161
32360
  }
31162
- /** Nodes within `maxRadius` tree-graph steps of `focusedKey`, mapped to their step distance. */
31163
- function ringDistances(canonical, focusedKey, maxRadius) {
31164
- const ring = /* @__PURE__ */ new Map();
31165
- if (!canonical.has(focusedKey)) return ring;
31166
- ring.set(focusedKey, 0);
31167
- let frontier = [focusedKey];
31168
- for (let distance = 1; distance <= maxRadius && frontier.length > 0; distance++) {
32361
+ /**
32362
+ * Every node within `maxRadius` tree-graph steps of `focusedId`, with its
32363
+ * frame relative to the focus. Built by walking outward from the focus and
32364
+ * composing one short {@link CanonicalEntry.toParent} hop per edge — so
32365
+ * precision depends only on distance *from the focus*, never on how far the
32366
+ * focus is from the root.
32367
+ */
32368
+ function focusFrames(entries, focusedId, maxRadius) {
32369
+ const frames = /* @__PURE__ */ new Map();
32370
+ if (!entries.has(focusedId)) return frames;
32371
+ frames.set(focusedId, {
32372
+ ring: 0,
32373
+ frame: identityIsometry
32374
+ });
32375
+ let frontier = [focusedId];
32376
+ for (let ring = 1; ring <= maxRadius && frontier.length > 0; ring++) {
31169
32377
  const next = [];
31170
- for (const key of frontier) {
31171
- const entry = canonical.get(key);
31172
- if (!entry) continue;
31173
- const neighbours = [];
31174
- if (entry.parentKey !== void 0) neighbours.push(entry.parentKey);
31175
- for (const child of entry.node.children ?? []) neighbours.push(child.item.key);
31176
- for (const neighbour of neighbours) if (!ring.has(neighbour) && canonical.has(neighbour)) {
31177
- ring.set(neighbour, distance);
31178
- next.push(neighbour);
31179
- }
32378
+ for (const id of frontier) {
32379
+ const entry = entries.get(id);
32380
+ const { frame } = frames.get(id);
32381
+ const visit = (neighbourId, neighbourFrame) => {
32382
+ if (frames.has(neighbourId) || !entries.has(neighbourId)) return;
32383
+ frames.set(neighbourId, {
32384
+ ring,
32385
+ frame: neighbourFrame()
32386
+ });
32387
+ next.push(neighbourId);
32388
+ };
32389
+ for (const childId of hyperbolicChildIds(id, entry.node.children ?? [])) visit(childId, () => composeIsometry(frame, entries.get(childId).toParent));
32390
+ if (entry.parentId !== void 0) visit(entry.parentId, () => composeIsometry(frame, invertIsometry(entry.toParent)));
31180
32391
  }
31181
32392
  frontier = next;
31182
32393
  }
31183
- return ring;
32394
+ return frames;
32395
+ }
32396
+ /** `toParent` hops composed from `id` up to (not including) ancestor `ancestorId`: `id`-local → `ancestorId`-local. */
32397
+ function frameUpTo(entries, id, ancestorId) {
32398
+ let frame = identityIsometry;
32399
+ for (let cursor = id; cursor !== void 0 && cursor !== ancestorId; cursor = entries.get(cursor)?.parentId) frame = composeIsometry(entries.get(cursor).toParent, frame);
32400
+ return frame;
32401
+ }
32402
+ /**
32403
+ * The frame of node `toId` expressed in node `fromId`'s local frame (maps
32404
+ * `toId`-local coordinates to `fromId`-local ones), composed along the tree
32405
+ * path between them via their lowest common ancestor. Used to hand a view
32406
+ * over from one focus to another without passing through root-anchored
32407
+ * coordinates. `undefined` if either id is absent.
32408
+ */
32409
+ function hyperbolicRelativeFrame(entries, fromId, toId) {
32410
+ if (!entries.has(fromId) || !entries.has(toId)) return void 0;
32411
+ const ancestors = /* @__PURE__ */ new Set();
32412
+ for (let cursor = fromId; cursor !== void 0; cursor = entries.get(cursor)?.parentId) ancestors.add(cursor);
32413
+ let lca = toId;
32414
+ while (lca !== void 0 && !ancestors.has(lca)) lca = entries.get(lca)?.parentId;
32415
+ if (lca === void 0) return void 0;
32416
+ return composeIsometry(invertIsometry(frameUpTo(entries, fromId, lca)), frameUpTo(entries, toId, lca));
32417
+ }
32418
+ /**
32419
+ * Resolve `focusedKey` — either a layout id, or (as a convenience, and for
32420
+ * data with globally-unique keys) a plain `item.key`, matching the first
32421
+ * node in depth-first order carrying it.
32422
+ */
32423
+ function resolveFocusId(canonical, focusedKey) {
32424
+ if (focusedKey === void 0) return void 0;
32425
+ if (canonical.has(focusedKey)) return focusedKey;
32426
+ for (const entry of canonical.values()) if (entry.node.item.key === focusedKey) return entry.id;
31184
32427
  }
31185
32428
  /**
31186
32429
  * Compute the visible nodes/edges of a hyperbolic tree view: every
@@ -31188,58 +32431,69 @@ function ringDistances(canonical, focusedKey, maxRadius) {
31188
32431
  * node, projected onto the Poincaré disk with the focus at its center.
31189
32432
  *
31190
32433
  * @param root True tree root. `undefined` yields an empty layout.
31191
- * @param options `focusedKey` (default the true root; unknown keys fall back
31192
- * to the true root), `maxRadius` (default unlimited), `maxDepth` (safety
31193
- * cap on canonical-position recursion depth, default 24), `centerOverride`
31194
- * (project around this canonical-space point instead of the focus node's
31195
- * own position — used to animate smoothly between foci).
32434
+ * @param options `focusedKey` (a layout id or plain `item.key`; default the
32435
+ * true root; unknown keys fall back to the true root), `maxRadius` (default unlimited), `maxDepth` (safety
32436
+ * cap on layout recursion depth, default 24), `curvature`,
32437
+ * `siblingSpacing`, `centerOverride` (put this point of the focus's local
32438
+ * frame at the center instead of the focus itself), `view` (a full
32439
+ * focus-local → screen isometry, taking precedence over `centerOverride` —
32440
+ * used for panning and animation, which also rotate).
31196
32441
  */
31197
32442
  function computeHyperbolicLayout(root, options = {}) {
31198
32443
  if (!root) return {
31199
32444
  nodes: [],
31200
32445
  edges: []
31201
32446
  };
31202
- const { maxRadius = Number.POSITIVE_INFINITY, maxDepth = 24, curvature = 1 } = options;
31203
- const canonical = computeCanonicalPositions(root, maxDepth, curvature);
31204
- const focusedKey = options.focusedKey !== void 0 && canonical.has(options.focusedKey) ? options.focusedKey : root.item.key;
31205
- const focusEntry = canonical.get(focusedKey);
31206
- if (!focusEntry) return {
32447
+ const { maxRadius = Number.POSITIVE_INFINITY, maxDepth = 24, curvature = 1, siblingSpacing = DEFAULT_SIBLING_SPACING } = options;
32448
+ const canonical = computeCanonicalPositions(root, maxDepth, curvature, siblingSpacing);
32449
+ const focusedId = resolveFocusId(canonical, options.focusedKey) ?? root.item.key;
32450
+ if (!canonical.has(focusedId)) return {
31207
32451
  nodes: [],
31208
32452
  edges: []
31209
32453
  };
31210
- const projectionCenter = options.centerOverride ?? focusEntry.position;
31211
- const ring = ringDistances(canonical, focusedKey, maxRadius);
31212
- const rawWeights = [...ring.keys()].map((key) => canonical.get(key).node.item.weight).filter((w) => typeof w === `number`);
32454
+ const view = options.view ?? viewIsometry(options.centerOverride ?? {
32455
+ x: 0,
32456
+ y: 0
32457
+ });
32458
+ const frames = focusFrames(canonical, focusedId, maxRadius);
32459
+ const rawWeights = [...frames.keys()].map((key) => canonical.get(key).node.item.weight).filter((w) => typeof w === `number`);
31213
32460
  const minWeight = rawWeights.length > 0 ? Math.min(...rawWeights) : void 0;
31214
32461
  const maxWeight = rawWeights.length > 0 ? Math.max(...rawWeights) : void 0;
31215
32462
  const normalizeWeight = (weight) => {
31216
32463
  if (weight === void 0 || minWeight === void 0 || maxWeight === void 0 || maxWeight === minWeight) return .5;
31217
32464
  return (weight - minWeight) / (maxWeight - minWeight);
31218
32465
  };
31219
- const nodes = [...ring.entries()].map(([key, distance]) => {
31220
- const entry = canonical.get(key);
32466
+ const nodes = [...frames.entries()].map(([id, { ring, frame }]) => {
32467
+ const entry = canonical.get(id);
31221
32468
  const weight = entry.node.item.weight;
31222
32469
  return {
31223
32470
  node: entry.node,
31224
- key,
32471
+ key: entry.node.item.key,
32472
+ id,
31225
32473
  depth: entry.depth,
31226
- ring: distance,
32474
+ ring,
31227
32475
  parentKey: entry.parentKey,
31228
- position: mobiusToOrigin(entry.position, projectionCenter),
32476
+ parentId: entry.parentId,
32477
+ position: applyIsometry(composeIsometry(view, frame), {
32478
+ x: 0,
32479
+ y: 0
32480
+ }),
31229
32481
  angularShare: entry.angularShare,
31230
32482
  weight,
31231
32483
  weightNormalized: normalizeWeight(weight)
31232
32484
  };
31233
32485
  }).sort((a, b) => a.ring - b.ring);
31234
- const byKey = new Map(nodes.map((n) => [n.key, n]));
32486
+ const byId = new Map(nodes.map((n) => [n.id, n]));
31235
32487
  const edges = [];
31236
32488
  for (const n of nodes) {
31237
- if (n.parentKey === void 0) continue;
31238
- const parent = byKey.get(n.parentKey);
32489
+ if (n.parentId === void 0) continue;
32490
+ const parent = byId.get(n.parentId);
31239
32491
  if (!parent) continue;
31240
32492
  edges.push({
31241
32493
  fromKey: parent.key,
31242
32494
  toKey: n.key,
32495
+ fromId: parent.id,
32496
+ toId: n.id,
31243
32497
  from: parent.position,
31244
32498
  to: n.position,
31245
32499
  weightNormalized: (parent.weightNormalized + n.weightNormalized) / 2
@@ -31251,17 +32505,476 @@ function computeHyperbolicLayout(root, options = {}) {
31251
32505
  };
31252
32506
  }
31253
32507
  //#endregion
31254
- //#region src/hyperbolic-tree/hyperbolictree-list.ts
32508
+ //#region src/hyperbolic-tree/keyboard.ts
32509
+ const unhandled = {
32510
+ action: { kind: `none` },
32511
+ handled: false
32512
+ };
32513
+ /** Angular distance (radians, 0–π) of a layout node from screen-"up" (−π/2). */
32514
+ function angleDistanceFromUp(n) {
32515
+ const angle = Math.atan2(n.position.y, n.position.x);
32516
+ let diff = Math.abs(angle - -Math.PI / 2);
32517
+ if (diff > Math.PI) diff = 2 * Math.PI - diff;
32518
+ return diff;
32519
+ }
32520
+ /**
32521
+ * Resolve `key` against the focused node (layout id `focusId`):
32522
+ * - `ArrowUp` — the visible child closest to screen-"up"
32523
+ * - `ArrowDown` — the parent
32524
+ * - `ArrowLeft` / `ArrowRight` — previous / next sibling, wrapping
32525
+ * - `Enter` — activate the focus
32526
+ * - `Escape` — back to the root (unhandled when already there)
32527
+ */
32528
+ function resolveKeyboardAction(key, root, focusId, visibleNodes) {
32529
+ const path = findHyperbolicPathById(root, focusId);
32530
+ const focusNode = path?.at(-1);
32531
+ if (!path || !focusNode) return unhandled;
32532
+ const parent = path.length >= 2 ? path[path.length - 2] : void 0;
32533
+ const none = {
32534
+ action: { kind: `none` },
32535
+ handled: true
32536
+ };
32537
+ const focus = (node) => node ? {
32538
+ action: {
32539
+ kind: `focus`,
32540
+ node
32541
+ },
32542
+ handled: true
32543
+ } : none;
32544
+ switch (key) {
32545
+ case `ArrowUp`: {
32546
+ const children = visibleNodes.filter((n) => n.parentId === focusId);
32547
+ if (children.length === 0) return none;
32548
+ return focus(children.reduce((a, b) => angleDistanceFromUp(a) < angleDistanceFromUp(b) ? a : b).node);
32549
+ }
32550
+ case `ArrowDown`: return focus(parent);
32551
+ case `ArrowLeft`:
32552
+ case `ArrowRight`: {
32553
+ const siblings = parent?.children ?? [];
32554
+ const index = siblings.indexOf(focusNode);
32555
+ if (index === -1 || siblings.length <= 1) return none;
32556
+ return focus(siblings[(index + (key === `ArrowRight` ? 1 : -1) + siblings.length) % siblings.length]);
32557
+ }
32558
+ case `Enter`: return {
32559
+ action: {
32560
+ kind: `activate`,
32561
+ node: focusNode,
32562
+ depth: path.length - 1
32563
+ },
32564
+ handled: true
32565
+ };
32566
+ case `Escape`: return focusNode === root ? unhandled : focus(root);
32567
+ default: return unhandled;
32568
+ }
32569
+ }
32570
+ //#endregion
32571
+ //#region src/hyperbolic-tree/lazy-children.ts
32572
+ function createLazyChildLoader(source, onChange) {
32573
+ const pending = /* @__PURE__ */ new Set();
32574
+ const aborters = /* @__PURE__ */ new Map();
32575
+ const fetchChildren = async (node, depth, signal) => {
32576
+ const { model, loadChildren } = source();
32577
+ if (model) return model.getChildren(node, depth, signal);
32578
+ return [...await collectDataResult(loadChildren({
32579
+ node,
32580
+ depth
32581
+ }, signal), signal)];
32582
+ };
32583
+ const load = async (node, depth) => {
32584
+ pending.add(node);
32585
+ const controller = new AbortController();
32586
+ aborters.set(node, controller);
32587
+ onChange();
32588
+ try {
32589
+ const children = await fetchChildren(node, depth, controller.signal);
32590
+ if (!controller.signal.aborted) node.children = children;
32591
+ } catch {
32592
+ if (!controller.signal.aborted) node.children = [];
32593
+ } finally {
32594
+ pending.delete(node);
32595
+ aborters.delete(node);
32596
+ if (!controller.signal.aborted) onChange();
32597
+ }
32598
+ };
32599
+ return {
32600
+ /** Start loading children for every node in `visible` that has none yet. */
32601
+ ensure(visible) {
32602
+ const { model, loadChildren } = source();
32603
+ if (!model && !loadChildren) return;
32604
+ for (const { node, depth } of visible) if (node.children === void 0 && !pending.has(node)) load(node, depth);
32605
+ },
32606
+ isPending: (node) => pending.has(node),
32607
+ abortAll() {
32608
+ for (const aborter of aborters.values()) aborter.abort();
32609
+ aborters.clear();
32610
+ pending.clear();
32611
+ }
32612
+ };
32613
+ }
32614
+ //#endregion
32615
+ //#region src/hyperbolic-tree/render-utils.ts
32616
+ /**
32617
+ * The Poincaré disk's conformal factor relative to the origin, `1 − |p|²`:
32618
+ * how much a shape of fixed hyperbolic size shrinks (in Euclidean terms) when
32619
+ * moved from the center to `p`. Sizing nodes by this makes them shrink
32620
+ * exactly as the space does, rather than by an ad-hoc falloff.
32621
+ */
32622
+ function conformalScale(p) {
32623
+ return Math.max(0, 1 - (p.x * p.x + p.y * p.y));
32624
+ }
32625
+ /**
32626
+ * Euclidean distance from each node to its nearest visible neighbour, keyed
32627
+ * by layout id (`Infinity` for a lone node). Used to cap on-screen node size
32628
+ * so crowded nodes never overlap — measured in the *projected* view, so it
32629
+ * stays right whichever node is focused. O(n²), fine for the few hundred
32630
+ * nodes a `maxRadius` window typically holds.
32631
+ */
32632
+ function nearestNeighbourDistances(nodes) {
32633
+ const out = /* @__PURE__ */ new Map();
32634
+ for (const n of nodes) out.set(n.id, Number.POSITIVE_INFINITY);
32635
+ for (let i = 0; i < nodes.length; i++) {
32636
+ const a = nodes[i].position;
32637
+ for (let j = i + 1; j < nodes.length; j++) {
32638
+ const b = nodes[j].position;
32639
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
32640
+ if (d < out.get(nodes[i].id)) out.set(nodes[i].id, d);
32641
+ if (d < out.get(nodes[j].id)) out.set(nodes[j].id, d);
32642
+ }
32643
+ }
32644
+ return out;
32645
+ }
32646
+ /** Weight multiplier for `n`, interpolating between the min/max weight scales. */
32647
+ function weightScale(n, options) {
32648
+ return options.minWeightScale + (options.maxWeightScale - options.minWeightScale) * n.weightNormalized;
32649
+ }
32650
+ /**
32651
+ * A dot's rendered radius: base size × weight × conformal shrink, capped to
32652
+ * under half the distance to its nearest neighbour so crowded siblings keep
32653
+ * a visible gap instead of merging into a blob.
32654
+ */
32655
+ function dotRadius(n, nearest, options) {
32656
+ const radius = options.nodeRadius * conformalScale(n.position) * weightScale(n, options);
32657
+ return Math.max(Math.min(radius, nearest * .45), .003);
32658
+ }
32659
+ /**
32660
+ * Footprint of a `2·halfWidth × 2·halfHeight` rectangle centered on
32661
+ * `center` and rotated by `angle` radians.
32662
+ */
32663
+ function orientedRect(id, center, halfWidth, halfHeight, angle = 0) {
32664
+ const cos = Math.cos(angle);
32665
+ const sin = Math.sin(angle);
32666
+ const corner = (u, v) => ({
32667
+ x: center.x + u * cos - v * sin,
32668
+ y: center.y + u * sin + v * cos
32669
+ });
32670
+ return {
32671
+ id,
32672
+ corners: [
32673
+ corner(-halfWidth, -halfHeight),
32674
+ corner(halfWidth, -halfHeight),
32675
+ corner(halfWidth, halfHeight),
32676
+ corner(-halfWidth, halfHeight)
32677
+ ]
32678
+ };
32679
+ }
32680
+ function polygonArea(points) {
32681
+ let area = 0;
32682
+ for (let i = 0; i < points.length; i++) {
32683
+ const a = points[i];
32684
+ const b = points[(i + 1) % points.length];
32685
+ area += a.x * b.y - b.x * a.y;
32686
+ }
32687
+ return Math.abs(area) / 2;
32688
+ }
32689
+ /** Sutherland–Hodgman: `subject` clipped to convex `clip` (either winding). */
32690
+ function clipPolygon(subject, clip) {
32691
+ const orientation = Math.sign((clip[1].x - clip[0].x) * (clip[2].y - clip[0].y) - (clip[1].y - clip[0].y) * (clip[2].x - clip[0].x)) || 1;
32692
+ let output = [...subject];
32693
+ for (let i = 0; i < clip.length && output.length > 0; i++) {
32694
+ const a = clip[i];
32695
+ const b = clip[(i + 1) % clip.length];
32696
+ const side = (p) => orientation * ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x));
32697
+ const input = output;
32698
+ output = [];
32699
+ for (let j = 0; j < input.length; j++) {
32700
+ const current = input[j];
32701
+ const previous = input[(j + input.length - 1) % input.length];
32702
+ const sc = side(current);
32703
+ const sp = side(previous);
32704
+ if (sc >= 0 !== sp >= 0) {
32705
+ const t = sp / (sp - sc);
32706
+ output.push({
32707
+ x: previous.x + (current.x - previous.x) * t,
32708
+ y: previous.y + (current.y - previous.y) * t
32709
+ });
32710
+ }
32711
+ if (sc >= 0) output.push(current);
32712
+ }
32713
+ }
32714
+ return output;
32715
+ }
32716
+ function bounds(points) {
32717
+ const xs = points.map((p) => p.x);
32718
+ const ys = points.map((p) => p.y);
32719
+ return {
32720
+ x0: Math.min(...xs),
32721
+ y0: Math.min(...ys),
32722
+ x1: Math.max(...xs),
32723
+ y1: Math.max(...ys)
32724
+ };
32725
+ }
32726
+ /** Fraction (0–1) of `candidate`'s area covered by `other`. */
32727
+ function overlapFraction(candidate, other) {
32728
+ const a = bounds(candidate.corners);
32729
+ const b = bounds(other.corners);
32730
+ if (a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0) return 0;
32731
+ const area = polygonArea(candidate.corners);
32732
+ if (area <= 0) return 1;
32733
+ return polygonArea(clipPolygon(candidate.corners, other.corners)) / area;
32734
+ }
31255
32735
  /**
31256
- * The Poincaré disk's math operates in a `-1..1` unit radius, but rendering
31257
- * an SVG `viewBox` at that literal scale (then letting CSS stretch it to a
31258
- * few hundred device pixels) is an extreme, unusual upscale factor that has
31259
- * been observed to trigger a Chromium GPU-rasterization glitch — a `fill:
31260
- * none` shape painting as solid-filled, matching its computed style exactly
31261
- * but not what's on screen. Rendering in a "normal"-magnitude SVG coordinate
31262
- * system (viewBox radius {@link RENDER_SCALE} instead of `1`) avoids it.
32736
+ * Greedy label placement: walk `shapes` in priority order and keep each one
32737
+ * whose overlap with every already-kept shape is at most `maxOverlap` of its
32738
+ * own area. Returns the ids of the kept labels. Checking actual (possibly
32739
+ * rotated) label footprints, not just node positions, is what stops a fan of
32740
+ * same-radius siblings from stacking their text into an unreadable pile.
31263
32741
  */
31264
- const RENDER_SCALE = 500;
32742
+ function cullOverlappingLabels(shapes, maxOverlap = .03) {
32743
+ const kept = [];
32744
+ for (const shape of shapes) if (kept.every((k) => overlapFraction(shape, k) <= maxOverlap)) kept.push(shape);
32745
+ return new Set(kept.map((r) => r.id));
32746
+ }
32747
+ /** `label` cut to at most `maxChars` characters, ending in `…` when cut. `0` or less disables truncation. */
32748
+ function truncateLabel(label, maxChars) {
32749
+ if (maxChars <= 0 || label.length <= maxChars) return label;
32750
+ return `${label.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
32751
+ }
32752
+ let measureContext;
32753
+ const widthCache = /* @__PURE__ */ new Map();
32754
+ /**
32755
+ * Rendered width of `text` at `fontSize` px in `fontFamily`, via an offscreen
32756
+ * canvas (cached). Falls back to a per-character estimate where canvas text
32757
+ * measurement is unavailable (e.g. non-browser test environments).
32758
+ */
32759
+ function measureTextWidth(text, fontSize, fontFamily) {
32760
+ const cacheKey = `${fontFamily}\u0000${text}`;
32761
+ let atUnitSize = widthCache.get(cacheKey);
32762
+ if (atUnitSize === void 0) {
32763
+ if (measureContext === void 0) try {
32764
+ measureContext = typeof document === `undefined` ? null : document.createElement(`canvas`).getContext(`2d`);
32765
+ } catch {
32766
+ measureContext = null;
32767
+ }
32768
+ const reference = 100;
32769
+ let measured;
32770
+ if (measureContext) {
32771
+ measureContext.font = `${reference}px ${fontFamily || `sans-serif`}`;
32772
+ const width = measureContext.measureText(text).width;
32773
+ if (Number.isFinite(width) && width > 0) measured = width / reference;
32774
+ }
32775
+ atUnitSize = measured ?? text.length * .6;
32776
+ widthCache.set(cacheKey, atUnitSize);
32777
+ }
32778
+ return atUnitSize * fontSize;
32779
+ }
32780
+ /**
32781
+ * Smallest scale a box shrinks to, however far from the focus. Pure conformal
32782
+ * scaling (`1 − |z|²`) is geometrically exact but shrinks the second ring to
32783
+ * well under half size, taking labels below legibility long before the
32784
+ * boundary — flooring it keeps nearby rings readable while still reading as
32785
+ * "further away".
32786
+ */
32787
+ const BOX_SCALE_FLOOR = .45;
32788
+ /**
32789
+ * Geometry for a `box`-shaped node. The box is laid out at its focus size and
32790
+ * then uniformly scaled — by the conformal factor, floored at
32791
+ * {@link BOX_SCALE_FLOOR} — so the label is measured just once. Weight
32792
+ * nudges the scale gently (square-rooted), so heavy nodes read as larger
32793
+ * without dwarfing their neighbours' labels.
32794
+ */
32795
+ function boxGeometry(n, options) {
32796
+ const conformal = BOX_SCALE_FLOOR + .55 * conformalScale(n.position);
32797
+ const scale = Math.min(1, conformal * (options.zoom ?? 1)) * Math.sqrt(weightScale(n, options));
32798
+ const text = truncateLabel(n.node.item.label, options.maxChars);
32799
+ const padX = options.fontSize * .5;
32800
+ const height = options.fontSize * 1.55;
32801
+ return {
32802
+ scale,
32803
+ width: measureTextWidth(text, options.fontSize, options.fontFamily) + padX * 2,
32804
+ height,
32805
+ markerSize: options.fontSize * .55,
32806
+ text
32807
+ };
32808
+ }
32809
+ /** Label footprint of a box of `geometry` centered at `center` and rotated by `angle` (render units). */
32810
+ function boxLabelShape(id, center, geometry, angle = 0) {
32811
+ return orientedRect(id, center, geometry.width * geometry.scale / 2, geometry.height * geometry.scale / 2, angle);
32812
+ }
32813
+ //#endregion
32814
+ //#region src/hyperbolic-tree/render-context.ts
32815
+ const HORIZONTAL = {
32816
+ direction: 0,
32817
+ textAngle: 0,
32818
+ flipped: false
32819
+ };
32820
+ /**
32821
+ * Label font metrics. `--hyperbolictree-font-size` is in real screen px;
32822
+ * the SVG's viewBox is fixed, so it's converted to render units using the
32823
+ * on-screen disk size (`extentPx`) and zoom — keeping text legible whatever
32824
+ * size the element is laid out at, and constant-size while zooming
32825
+ * (zooming in spreads crowded labels apart rather than just magnifying the
32826
+ * pile).
32827
+ */
32828
+ function fontMetrics(host, extentPx, zoom) {
32829
+ const style = typeof getComputedStyle === `function` ? getComputedStyle(host) : void 0;
32830
+ const parsed = Number.parseFloat(style?.getPropertyValue(`--hyperbolictree-font-size`) ?? ``);
32831
+ const px = Number.isFinite(parsed) && parsed > 0 ? parsed : 13;
32832
+ return {
32833
+ px,
32834
+ units: px * (1050 / (extentPx > 0 ? extentPx : 420) / (zoom || 1)),
32835
+ family: style?.fontFamily ?? ``
32836
+ };
32837
+ }
32838
+ /** Map of top-level-branch layout id → index among the root's children. */
32839
+ function branchIndices(root) {
32840
+ if (!root) return /* @__PURE__ */ new Map();
32841
+ return new Map(hyperbolicChildIds(root.item.key, root.children ?? []).map((id, i) => [id, i]));
32842
+ }
32843
+ /** Inline `--branch-index`/`--branch-count` declarations for the node/edge with layout id `id` (empty for the root). */
32844
+ function branchStyle(id, branches) {
32845
+ const branch = hyperbolicBranchId(id);
32846
+ const index = branch === void 0 ? void 0 : branches.get(branch);
32847
+ return index === void 0 ? `` : ` --branch-index: ${index}; --branch-count: ${branches.size};`;
32848
+ }
32849
+ function groupSiblings(nodes) {
32850
+ const groups = /* @__PURE__ */ new Map();
32851
+ for (const n of nodes) {
32852
+ const group = groups.get(n.parentId) ?? [];
32853
+ group.push(n);
32854
+ groups.set(n.parentId, group);
32855
+ }
32856
+ return groups;
32857
+ }
32858
+ /**
32859
+ * Label priority: the focus, then selected nodes, then larger (nearer the
32860
+ * center) first.
32861
+ */
32862
+ function byLabelPriority(nodes, sizeOf, isSelected) {
32863
+ const rank = (n) => n.ring === 0 ? 0 : isSelected(n.node) ? 1 : 2;
32864
+ return [...nodes].sort((a, b) => rank(a) - rank(b) || sizeOf(b) - sizeOf(a) || a.ring - b.ring);
32865
+ }
32866
+ /**
32867
+ * Each node's label orientation. The reference neighbour is the one a step
32868
+ * nearer the focus: usually the parent, but for the focus's own ancestors
32869
+ * (which sit *behind* it) the child on the path back to the focus. The focus
32870
+ * itself stays horizontal.
32871
+ */
32872
+ function labelOrientations(nodes, radial) {
32873
+ const out = /* @__PURE__ */ new Map();
32874
+ if (!radial) {
32875
+ for (const n of nodes) out.set(n.id, HORIZONTAL);
32876
+ return out;
32877
+ }
32878
+ const byId = new Map(nodes.map((n) => [n.id, n]));
32879
+ const nearer = /* @__PURE__ */ new Map();
32880
+ for (const n of nodes) {
32881
+ const parent = n.parentId === void 0 ? void 0 : byId.get(n.parentId);
32882
+ if (!parent) continue;
32883
+ if (parent.ring < n.ring) nearer.set(n.id, parent);
32884
+ else if (n.ring < parent.ring) nearer.set(parent.id, n);
32885
+ }
32886
+ for (const n of nodes) {
32887
+ const from = nearer.get(n.id);
32888
+ if (!from || n.ring === 0) {
32889
+ out.set(n.id, HORIZONTAL);
32890
+ continue;
32891
+ }
32892
+ const direction = Math.atan2(n.position.y - from.position.y, n.position.x - from.position.x);
32893
+ const flipped = Math.cos(direction) < 0;
32894
+ out.set(n.id, {
32895
+ direction,
32896
+ textAngle: flipped ? direction + Math.PI : direction,
32897
+ flipped
32898
+ });
32899
+ }
32900
+ return out;
32901
+ }
32902
+ const renderCenter = (n) => ({
32903
+ x: n.position.x * 500,
32904
+ y: n.position.y * 500
32905
+ });
32906
+ function boxLabels(nodes, options, orientations) {
32907
+ const { font } = options;
32908
+ const boxes = new Map(nodes.map((n) => [n.id, boxGeometry(n, {
32909
+ fontSize: font.units,
32910
+ fontFamily: font.family,
32911
+ maxChars: options.boxMaxChars,
32912
+ minWeightScale: options.minWeightScale,
32913
+ maxWeightScale: options.maxWeightScale,
32914
+ zoom: options.zoom
32915
+ })]));
32916
+ return {
32917
+ boxes,
32918
+ labelIds: cullOverlappingLabels(byLabelPriority(nodes.filter((n) => !options.isTickled(n.id) && font.px * boxes.get(n.id).scale >= options.minLabelSize), (n) => boxes.get(n.id).scale, options.isSelected).map((n) => boxLabelShape(n.id, renderCenter(n), boxes.get(n.id), orientations.get(n.id).textAngle)))
32919
+ };
32920
+ }
32921
+ function dotLabels(nodes, options, orientations) {
32922
+ const { font } = options;
32923
+ const nearest = nearestNeighbourDistances(nodes);
32924
+ const radii = new Map(nodes.map((n) => [n.id, dotRadius(n, nearest.get(n.id), options)]));
32925
+ return {
32926
+ radii,
32927
+ labelIds: cullOverlappingLabels(byLabelPriority(nodes.filter((n) => !options.isTickled(n.id) && radii.get(n.id) * options.zoom >= options.minNodeRadius), (n) => radii.get(n.id), options.isSelected).map((n) => {
32928
+ const { direction } = orientations.get(n.id);
32929
+ const halfWidth = measureTextWidth(n.node.item.label, font.units, font.family) / 2;
32930
+ const along = (radii.get(n.id) + .01) * 500 + halfWidth;
32931
+ const c = renderCenter(n);
32932
+ const center = {
32933
+ x: c.x + Math.cos(direction) * along,
32934
+ y: c.y + Math.sin(direction) * along
32935
+ };
32936
+ return orientedRect(n.id, center, halfWidth + font.units * .6, font.units * .6, direction);
32937
+ }))
32938
+ };
32939
+ }
32940
+ /**
32941
+ * Everything the node templates need for one render. Labels are placed
32942
+ * greedily by priority and dropped where they'd overlap; hovered/cursor
32943
+ * nodes are exempt from culling entirely — always labelled — so hovering a
32944
+ * marker reveals it without reshuffling everyone else's labels.
32945
+ */
32946
+ function buildNodeRenderContext(nodes, options) {
32947
+ const orientations = labelOrientations(nodes, options.radialLabels);
32948
+ const shapeData = options.nodeShape === `box` ? {
32949
+ ...boxLabels(nodes, options, orientations),
32950
+ radii: /* @__PURE__ */ new Map()
32951
+ } : {
32952
+ ...dotLabels(nodes, options, orientations),
32953
+ boxes: /* @__PURE__ */ new Map()
32954
+ };
32955
+ const labelIds = new Set(shapeData.labelIds);
32956
+ for (const n of nodes) if (options.isTickled(n.id)) labelIds.add(n.id);
32957
+ return {
32958
+ siblingGroups: groupSiblings(nodes),
32959
+ font: options.font,
32960
+ branches: options.branches,
32961
+ boxes: shapeData.boxes,
32962
+ radii: shapeData.radii,
32963
+ labelIds,
32964
+ orientations
32965
+ };
32966
+ }
32967
+ /**
32968
+ * Paint order: far-from-focus first so nearer (larger) nodes sit on top;
32969
+ * then labelled over unlabelled; the hovered node above everything.
32970
+ */
32971
+ function paintOrder(nodes, ctx, isTickled) {
32972
+ const layer = (n) => isTickled(n.id) ? 2 : ctx.labelIds.has(n.id) ? 1 : 0;
32973
+ const distance = (n) => n.position.x ** 2 + n.position.y ** 2;
32974
+ return [...nodes].sort((a, b) => layer(a) - layer(b) || distance(b) - distance(a));
32975
+ }
32976
+ //#endregion
32977
+ //#region src/hyperbolic-tree/render-templates.ts
31265
32978
  /**
31266
32979
  * Sanitizes a node key into a token safe to use inside a `part` attribute /
31267
32980
  * `::part()` selector — arbitrary key characters (spaces, quotes, etc.)
@@ -31270,13 +32983,121 @@ const RENDER_SCALE = 500;
31270
32983
  function partToken(key) {
31271
32984
  return key.replace(/[^\w-]/g, `-`);
31272
32985
  }
31273
- /** Angular distance (radians, 0–π) of a layout node from screen-"up" (−π/2). */
31274
- function angleDistanceFromUp(n) {
31275
- const angle = Math.atan2(n.position.y, n.position.x);
31276
- let diff = Math.abs(angle - -Math.PI / 2);
31277
- if (diff > Math.PI) diff = 2 * Math.PI - diff;
31278
- return diff;
32986
+ const preventMouseDownFocus = (e) => e.preventDefault();
32987
+ /** The shared `<g part="node">` wrapper — ARIA, parts, weight/branch vars, tooltip, and pointer handling. */
32988
+ function renderNodeGroup(n, args, transform, content) {
32989
+ const { node, key, id } = n;
32990
+ const { ctx, state, handlers } = args;
32991
+ const selected = state.isSelected(node);
32992
+ const classes = [
32993
+ `node`,
32994
+ selected ? `selected` : ``,
32995
+ state.isTickled(id) ? `tickled` : ``,
32996
+ state.isFiltered(n) ? `filtered` : ``,
32997
+ state.isPending(node) ? `loading` : ``,
32998
+ n.ring === 0 ? `focused` : ``
32999
+ ].filter(Boolean).join(` `);
33000
+ const siblings = ctx.siblingGroups.get(n.parentId) ?? [n];
33001
+ const weightStyle = `--node-weight: ${n.weightNormalized};${n.weight === void 0 ? `` : ` --node-weight-raw: ${n.weight};`}`;
33002
+ return svg`
33003
+ <g
33004
+ class=${classes}
33005
+ data-key=${key}
33006
+ role="treeitem"
33007
+ tabindex="-1"
33008
+ aria-level=${n.depth + 1}
33009
+ aria-selected=${selected}
33010
+ aria-setsize=${siblings.length}
33011
+ aria-posinset=${siblings.findIndex((sib) => sib.id === id) + 1}
33012
+ aria-label=${node.item.label}
33013
+ part="node node-${partToken(key)}"
33014
+ style=${weightStyle + branchStyle(id, ctx.branches)}
33015
+ transform=${transform}
33016
+ @pointerdown=${handlers.onPointerDown}
33017
+ @mousedown=${preventMouseDownFocus}
33018
+ @click=${(e) => handlers.onClick(n, e)}
33019
+ @dblclick=${(e) => handlers.onDblClick(n, e)}
33020
+ @pointerenter=${() => handlers.onPointerEnter(id)}
33021
+ >${state.showTitle(n) ? svg`<title>${node.item.label}</title>` : nothing}${content}</g>
33022
+ `;
33023
+ }
33024
+ const degrees = (radians) => radians * 180 / Math.PI;
33025
+ /** A dot's label: just past the dot, running along its orientation — flipped labels anchor at their end so they still read outward. */
33026
+ function renderDotLabel(n, ctx, scaledRadius) {
33027
+ const { textAngle, flipped } = ctx.orientations.get(n.id);
33028
+ const gap = scaledRadius + 5;
33029
+ return svg`<text
33030
+ class="hyperbolictree-label"
33031
+ part="label"
33032
+ x=${flipped ? -gap : gap}
33033
+ y="0"
33034
+ transform=${textAngle === 0 ? nothing : `rotate(${degrees(textAngle)})`}
33035
+ text-anchor=${flipped ? `end` : `start`}
33036
+ font-size=${ctx.font.units}
33037
+ dominant-baseline="middle"
33038
+ >${n.node.item.label}</text>`;
33039
+ }
33040
+ function renderDotNode(n, args) {
33041
+ const { ctx } = args;
33042
+ const scaledRadius = ctx.radii.get(n.id) * 500;
33043
+ return renderNodeGroup(n, args, `translate(${n.position.x * 500} ${n.position.y * 500})`, svg`
33044
+ <circle class="node-hit" r=${Math.max(scaledRadius, 17.5)} part="hit" />
33045
+ <circle class="node-dot" r=${scaledRadius} part="dot" />
33046
+ ${ctx.labelIds.has(n.id) ? renderDotLabel(n, ctx, scaledRadius) : nothing}
33047
+ `);
33048
+ }
33049
+ function renderBoxNode(n, args) {
33050
+ const { ctx } = args;
33051
+ const box = ctx.boxes.get(n.id);
33052
+ const showText = ctx.labelIds.has(n.id);
33053
+ const width = showText ? box.width : box.markerSize;
33054
+ const height = showText ? box.height : box.markerSize;
33055
+ const { textAngle } = ctx.orientations.get(n.id);
33056
+ const rotate = showText && textAngle !== 0 ? ` rotate(${degrees(textAngle)})` : ``;
33057
+ return renderNodeGroup(n, args, `translate(${n.position.x * 500} ${n.position.y * 500})${rotate} scale(${box.scale})`, svg`
33058
+ <rect
33059
+ class="node-box ${showText ? `` : `marker`}"
33060
+ part="box"
33061
+ x=${-width / 2}
33062
+ y=${-height / 2}
33063
+ width=${width}
33064
+ height=${height}
33065
+ rx=${ctx.font.units * .2}
33066
+ />
33067
+ ${showText ? svg`<text class="hyperbolictree-label box-label" part="label" x="0" y="0" font-size=${ctx.font.units} text-anchor="middle" dominant-baseline="central">${box.text}</text>` : nothing}
33068
+ `);
33069
+ }
33070
+ /** Every visible node, in paint order, in the shape `ctx` was built for. */
33071
+ function renderNodes(nodes, shape, args) {
33072
+ const ordered = paintOrder(nodes, args.ctx, args.state.isTickled);
33073
+ const renderOne = shape === `box` ? renderBoxNode : renderDotNode;
33074
+ return repeat(ordered, (n) => n.id, (n) => renderOne(n, args));
33075
+ }
33076
+ function renderDefaultEdge(edge, branches) {
33077
+ return svg`<path
33078
+ class="edge"
33079
+ d=${geodesicPath(edge.from, edge.to, 500)}
33080
+ part="edge edge-to-${partToken(edge.toKey)} edge-from-${partToken(edge.fromKey)}"
33081
+ data-from=${edge.fromKey}
33082
+ data-to=${edge.toKey}
33083
+ style="--edge-weight: ${edge.weightNormalized};${branchStyle(edge.toId, branches)}"
33084
+ />`;
31279
33085
  }
33086
+ /** Every visible edge — via `edgeRenderer` when given, else the default geodesic arc. */
33087
+ function renderEdges(edges, byId, branches, edgeRenderer) {
33088
+ return repeat(edges, (e) => `${e.fromId}->${e.toId}`, (edge) => {
33089
+ const fromNode = byId.get(edge.fromId)?.node;
33090
+ const toNode = byId.get(edge.toId)?.node;
33091
+ if (edgeRenderer && fromNode && toNode) return svg`<g transform="scale(${500})">${edgeRenderer({
33092
+ edge,
33093
+ fromNode,
33094
+ toNode
33095
+ })}</g>`;
33096
+ return renderDefaultEdge(edge, branches);
33097
+ });
33098
+ }
33099
+ //#endregion
33100
+ //#region src/hyperbolic-tree/hyperbolictree-list.ts
31280
33101
  let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitElement {
31281
33102
  constructor(..._args) {
31282
33103
  super(..._args);
@@ -31284,37 +33105,63 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31284
33105
  this.selectionFilter = `all`;
31285
33106
  this.interactionMode = `standard`;
31286
33107
  this.maxRadius = 4;
33108
+ this.nodeShape = `dot`;
33109
+ this.boxMaxChars = 24;
33110
+ this.minLabelSize = 9;
33111
+ this.colorByBranch = false;
31287
33112
  this.nodeRadius = .05;
31288
33113
  this.minNodeRadius = .015;
31289
33114
  this.minWeightScale = .6;
31290
33115
  this.maxWeightScale = 1.6;
31291
33116
  this.zoom = 1;
33117
+ this.disableZoomGestures = false;
33118
+ this.disableRadialLabels = false;
33119
+ this.disableChildPreview = false;
31292
33120
  this.curvature = 1;
33121
+ this.siblingSpacing = DEFAULT_SIBLING_SPACING;
31293
33122
  this.focusAnimationDuration = 400;
31294
33123
  this._selectedNodes = /* @__PURE__ */ new Set();
31295
33124
  this.disableKeyboardNavigation = false;
31296
33125
  this.registry = void 0;
31297
- this._focusKey = void 0;
31298
- this._animCenter = void 0;
31299
- this._panOffset = {
33126
+ this._focusId = void 0;
33127
+ this._viewOverride = void 0;
33128
+ this._focusRotation = 0;
33129
+ this._zoomOffset = {
31300
33130
  x: 0,
31301
33131
  y: 0
31302
33132
  };
33133
+ this._previewId = void 0;
33134
+ this.#preview = createChildPreviewTiming({
33135
+ candidate: (id) => {
33136
+ const nodes = this.#layout().nodes;
33137
+ const n = id === void 0 || this.disableChildPreview ? void 0 : nodes.find((v) => v.id === id);
33138
+ return n && needsChildPreview(n, nodes, this.#lastLabelIds) ? n.id : void 0;
33139
+ },
33140
+ setPreviewId: (id) => {
33141
+ this._previewId = id;
33142
+ }
33143
+ });
33144
+ this.#lazy = createLazyChildLoader(() => ({
33145
+ model: this.model,
33146
+ loadChildren: this.loadChildren
33147
+ }), () => this.requestUpdate());
33148
+ this.#lastLabelIds = /* @__PURE__ */ new Set();
31303
33149
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
31304
33150
  labelSelector: `.hyperbolictree-label`,
31305
33151
  highlightKey: `incr-search-highlight`
31306
33152
  }) });
31307
33153
  this.tickled = new TickledController(this, {
31308
33154
  getItems: () => this.#layout().nodes.map((n) => ({
31309
- id: n.key,
33155
+ id: n.id,
31310
33156
  enabled: true
31311
33157
  })),
31312
33158
  onStateChange: () => {
31313
33159
  this.setAttribute(`data-input-mode`, this.tickled.inputMode);
31314
33160
  },
31315
33161
  onTickle: (id) => {
33162
+ this.#preview.update(id);
31316
33163
  if (id === void 0) return;
31317
- const n = this.#layout().nodes.find((n) => n.key === id);
33164
+ const n = this.#layout().nodes.find((n) => n.id === id);
31318
33165
  if (!n) return;
31319
33166
  this.dispatchEvent(new CustomEvent(`tickle`, {
31320
33167
  detail: {
@@ -31326,8 +33173,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31326
33173
  }));
31327
33174
  }
31328
33175
  });
31329
- this.#pendingKeys = /* @__PURE__ */ new Set();
31330
- this.#aborters = /* @__PURE__ */ new Map();
33176
+ this._hostSize = {
33177
+ width: 0,
33178
+ height: 0
33179
+ };
33180
+ this.#gestures = createGestureHandlers({
33181
+ svgRect: () => this.renderRoot.querySelector(`svg.hyperbolictree`)?.getBoundingClientRect(),
33182
+ takeView: () => {
33183
+ const view = this.#currentView();
33184
+ this.#cancelAnimation();
33185
+ this._viewOverride = view;
33186
+ return view;
33187
+ },
33188
+ setView: (view) => {
33189
+ this._viewOverride = view;
33190
+ },
33191
+ zoomState: () => this.#zoomState(),
33192
+ setZoomState: (state) => {
33193
+ this.zoom = state.zoom;
33194
+ this._zoomOffset = state.offset;
33195
+ },
33196
+ zoomGesturesEnabled: () => !this.disableZoomGestures,
33197
+ hasFocus: () => this.renderRoot.activeElement !== null,
33198
+ focusTree: () => this.#focusTree()
33199
+ });
31331
33200
  }
31332
33201
  static {
31333
33202
  this.shadowRootOptions = {
@@ -31340,37 +33209,55 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31340
33209
  }
31341
33210
  /** The node currently centered on the disk. Pure view-state — `root` is never mutated by focus changes. */
31342
33211
  get focusedNode() {
31343
- if (this._focusKey === void 0) return this.#effectiveRoot;
31344
- return findHyperbolicNode(this.#effectiveRoot, this._focusKey) ?? this.#effectiveRoot;
33212
+ if (this._focusId === void 0) return this.#effectiveRoot;
33213
+ return findHyperbolicPathById(this.#effectiveRoot, this._focusId)?.at(-1) ?? this.#effectiveRoot;
31345
33214
  }
33215
+ #preview;
33216
+ #lazy;
33217
+ /** Labels shown in the last render — what the child preview judges legibility by. */
33218
+ #lastLabelIds;
31346
33219
  #modelUnsubscribe;
31347
- #pendingKeys;
31348
- #aborters;
31349
33220
  #ownedRegistry;
31350
33221
  #unregisterSearchCommands;
31351
33222
  #animFrame;
31352
- #panStart;
33223
+ #resizeObserver;
33224
+ #gestures;
31353
33225
  get #effectiveRoot() {
31354
33226
  return this.model?.root ?? this.root;
31355
33227
  }
31356
33228
  #layout() {
31357
33229
  return computeHyperbolicLayout(this.#effectiveRoot, {
31358
- focusedKey: this._focusKey,
33230
+ focusedKey: this._focusId,
31359
33231
  maxRadius: this.maxRadius,
31360
33232
  curvature: this.curvature,
31361
- centerOverride: this._panOffset.x === 0 && this._panOffset.y === 0 && this._animCenter === void 0 ? void 0 : this.#effectiveCenter()
33233
+ siblingSpacing: this.siblingSpacing,
33234
+ view: this.#currentView()
31362
33235
  });
31363
33236
  }
31364
- #effectiveCenter() {
31365
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31366
- const focusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31367
- const base = this._animCenter ?? (focusKey === void 0 ? void 0 : canonical.get(focusKey)?.position);
31368
- if (base === void 0) return void 0;
31369
- if (this._panOffset.x === 0 && this._panOffset.y === 0) return base;
31370
- return clampToDisk({
31371
- x: base.x + this._panOffset.x,
31372
- y: base.y + this._panOffset.y
31373
- }, .95);
33237
+ get #rootId() {
33238
+ return this.#effectiveRoot?.item.key;
33239
+ }
33240
+ /** The projection currently on screen, from the focus's local frame. */
33241
+ #currentView() {
33242
+ return this._viewOverride ?? rotationIsometry(this._focusRotation);
33243
+ }
33244
+ /**
33245
+ * The current view re-expressed in `targetId`'s local frame — i.e. the same
33246
+ * picture, but with `targetId` as the reference node. Where the view hands
33247
+ * over on a focus change.
33248
+ */
33249
+ #viewFromFrameOf(targetId, currentId) {
33250
+ const entries = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature, this.siblingSpacing);
33251
+ const relative = currentId === void 0 ? void 0 : hyperbolicRelativeFrame(entries, currentId, targetId);
33252
+ return relative ? composeIsometry(this.#currentView(), relative) : rotationIsometry(this._focusRotation);
33253
+ }
33254
+ /** Layout id of `node` — by identity, falling back to the first node sharing its key. */
33255
+ #idOf(node) {
33256
+ const root = this.#effectiveRoot;
33257
+ const direct = findHyperbolicIdOf(root, node);
33258
+ if (direct !== void 0) return direct;
33259
+ const byKey = findHyperbolicNode(root, node.item.key);
33260
+ return byKey ? findHyperbolicIdOf(root, byKey) : void 0;
31374
33261
  }
31375
33262
  connectedCallback() {
31376
33263
  super.connectedCallback();
@@ -31390,14 +33277,25 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31390
33277
  });
31391
33278
  this.registry = registry;
31392
33279
  this.#syncSearchCommands();
33280
+ if (typeof ResizeObserver === `function`) {
33281
+ this.#resizeObserver = new ResizeObserver(([entry]) => {
33282
+ const { width, height } = entry.contentRect;
33283
+ this._hostSize = {
33284
+ width,
33285
+ height
33286
+ };
33287
+ });
33288
+ this.#resizeObserver.observe(this);
33289
+ }
31393
33290
  }
31394
33291
  disconnectedCallback() {
31395
33292
  super.disconnectedCallback();
33293
+ this.#resizeObserver?.disconnect();
33294
+ this.#resizeObserver = void 0;
31396
33295
  this.#modelUnsubscribe?.();
31397
33296
  this.#modelUnsubscribe = void 0;
31398
- for (const aborter of this.#aborters.values()) aborter.abort();
31399
- this.#aborters.clear();
31400
- this.#pendingKeys.clear();
33297
+ this.#preview.dispose();
33298
+ this.#lazy.abortAll();
31401
33299
  if (this.#animFrame !== void 0) {
31402
33300
  cancelAnimationFrame(this.#animFrame);
31403
33301
  this.#animFrame = void 0;
@@ -31438,58 +33336,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31438
33336
  }
31439
33337
  if (changedProps.has(`interactionMode`)) this.clearSelection();
31440
33338
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
31441
- if (this._focusKey !== void 0 && !findHyperbolicNode(this.#effectiveRoot, this._focusKey)) {
31442
- this._focusKey = void 0;
31443
- this._animCenter = void 0;
33339
+ if (this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId)) {
33340
+ this._focusId = void 0;
33341
+ this._viewOverride = void 0;
33342
+ this._focusRotation = 0;
31444
33343
  }
31445
- this.#ensureVisibleChildren();
33344
+ this.#lazy.ensure(this.#layout().nodes);
31446
33345
  }
31447
33346
  #resetViewState() {
31448
33347
  this.#clearSelectionInternal();
31449
33348
  this.tickled.clearTickled();
31450
- this._focusKey = void 0;
31451
- this._animCenter = void 0;
31452
- this._panOffset = {
33349
+ this._focusId = void 0;
33350
+ this._viewOverride = void 0;
33351
+ this._focusRotation = 0;
33352
+ this._zoomOffset = {
31453
33353
  x: 0,
31454
33354
  y: 0
31455
33355
  };
33356
+ this._previewId = void 0;
33357
+ this.#gestures.cancel();
31456
33358
  if (this.#animFrame !== void 0) {
31457
33359
  cancelAnimationFrame(this.#animFrame);
31458
33360
  this.#animFrame = void 0;
31459
33361
  }
31460
33362
  }
31461
- #ensureVisibleChildren() {
31462
- if (!this.model && !this.loadChildren) return;
31463
- for (const n of this.#layout().nodes) {
31464
- const { node } = n;
31465
- if (node.children !== void 0 || this.#pendingKeys.has(node.item.key)) continue;
31466
- this.#loadChildren(node, n.depth);
31467
- }
31468
- }
31469
- async #loadChildren(node, depth) {
31470
- const key = node.item.key;
31471
- this.#pendingKeys.add(key);
31472
- const controller = new AbortController();
31473
- this.#aborters.set(key, controller);
31474
- this.requestUpdate();
31475
- try {
31476
- let children;
31477
- if (this.model) children = await this.model.getChildren(node, depth, controller.signal);
31478
- else children = [...await collectDataResult(this.loadChildren({
31479
- node,
31480
- depth
31481
- }, controller.signal), controller.signal)];
31482
- if (controller.signal.aborted) return;
31483
- node.children = children;
31484
- } catch {
31485
- if (controller.signal.aborted) return;
31486
- node.children = [];
31487
- } finally {
31488
- this.#pendingKeys.delete(key);
31489
- this.#aborters.delete(key);
31490
- if (!controller.signal.aborted) this.requestUpdate();
31491
- }
31492
- }
31493
33363
  #canSelect(node) {
31494
33364
  if (this.selectionFilter === `none`) return false;
31495
33365
  if (this.selectionFilter === `leaf`) return node.item.isLeaf === true;
@@ -31565,83 +33435,83 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31565
33435
  * the previous one, for interop with generic `TreeComponent` listeners.
31566
33436
  */
31567
33437
  focusOn(node) {
31568
- const targetKey = node.item.key;
31569
- const currentFocusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31570
- if (currentFocusKey === targetKey) return;
31571
- const previousNode = currentFocusKey === void 0 ? void 0 : findHyperbolicNode(this.#effectiveRoot, currentFocusKey);
31572
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31573
- const fromPosition = (currentFocusKey === void 0 ? void 0 : canonical.get(currentFocusKey)?.position) ?? {
31574
- x: 0,
31575
- y: 0
31576
- };
31577
- const toPosition = canonical.get(targetKey)?.position ?? {
31578
- x: 0,
31579
- y: 0
31580
- };
31581
- this._focusKey = targetKey;
31582
- this._panOffset = {
31583
- x: 0,
31584
- y: 0
31585
- };
31586
- this.#animateTo(fromPosition, toPosition);
33438
+ const root = this.#effectiveRoot;
33439
+ const targetId = this.#idOf(node);
33440
+ const currentId = this._focusId ?? this.#rootId;
33441
+ if (targetId === void 0 || currentId === targetId) return;
33442
+ const targetNode = findHyperbolicPathById(root, targetId)?.at(-1) ?? node;
33443
+ const previousNode = currentId === void 0 ? void 0 : findHyperbolicPathById(root, currentId)?.at(-1);
33444
+ const fromView = this.#viewFromFrameOf(targetId, currentId);
33445
+ this._focusId = targetId;
33446
+ this.#gestures.cancel();
33447
+ this._previewId = void 0;
33448
+ this.#animateTo(fromView);
31587
33449
  this.dispatchEvent(new CustomEvent(`focus`, {
31588
33450
  detail: {
31589
- node,
33451
+ node: targetNode,
31590
33452
  previous: previousNode
31591
33453
  },
31592
33454
  bubbles: true,
31593
33455
  composed: true
31594
33456
  }));
31595
- if (previousNode) {
31596
- if (findHyperbolicNode(previousNode, targetKey)) {
31597
- const depth = (findHyperbolicPath(this.#effectiveRoot, targetKey)?.length ?? 1) - 1;
31598
- this.dispatchEvent(new CustomEvent(`expand`, {
31599
- detail: {
31600
- node,
31601
- depth
31602
- },
31603
- bubbles: true,
31604
- composed: true
31605
- }));
31606
- } else if (findHyperbolicNode(node, previousNode.item.key)) {
31607
- const depth = (findHyperbolicPath(this.#effectiveRoot, previousNode.item.key)?.length ?? 1) - 1;
31608
- this.dispatchEvent(new CustomEvent(`collapse`, {
31609
- detail: {
31610
- node: previousNode,
31611
- depth
31612
- },
31613
- bubbles: true,
31614
- composed: true
31615
- }));
31616
- }
33457
+ if (previousNode && currentId !== void 0) {
33458
+ if (isHyperbolicDescendantId(targetId, currentId)) this.dispatchEvent(new CustomEvent(`expand`, {
33459
+ detail: {
33460
+ node: targetNode,
33461
+ depth: hyperbolicIdDepth(targetId)
33462
+ },
33463
+ bubbles: true,
33464
+ composed: true
33465
+ }));
33466
+ else if (isHyperbolicDescendantId(currentId, targetId)) this.dispatchEvent(new CustomEvent(`collapse`, {
33467
+ detail: {
33468
+ node: previousNode,
33469
+ depth: hyperbolicIdDepth(currentId)
33470
+ },
33471
+ bubbles: true,
33472
+ composed: true
33473
+ }));
31617
33474
  }
31618
33475
  this.requestUpdate();
31619
33476
  }
31620
- #prefersReducedMotion() {
31621
- return typeof matchMedia === `function` && matchMedia(`(prefers-reduced-motion: reduce)`).matches;
31622
- }
31623
- #animateTo(from, to) {
33477
+ #cancelAnimation() {
31624
33478
  if (this.#animFrame !== void 0) {
31625
33479
  cancelAnimationFrame(this.#animFrame);
31626
33480
  this.#animFrame = void 0;
31627
33481
  }
31628
- if (this.focusAnimationDuration <= 0 || this.#prefersReducedMotion() || typeof requestAnimationFrame !== `function`) {
31629
- this._animCenter = void 0;
33482
+ }
33483
+ /** Animate from view `from` (in the new focus's frame) to the settled, centered view — see {@link focusTransition}. */
33484
+ #animateTo(from) {
33485
+ this.#cancelAnimation();
33486
+ const { viewAt, settledRotation } = focusTransition(from);
33487
+ this._focusRotation = settledRotation;
33488
+ const fromOffset = this._zoomOffset;
33489
+ if (this.focusAnimationDuration <= 0 || prefersReducedMotion$1() || typeof requestAnimationFrame !== `function`) {
33490
+ this._viewOverride = void 0;
33491
+ this._zoomOffset = {
33492
+ x: 0,
33493
+ y: 0
33494
+ };
31630
33495
  return;
31631
33496
  }
31632
33497
  const start = performance.now();
31633
33498
  const duration = this.focusAnimationDuration;
31634
33499
  const step = (now) => {
31635
33500
  const t = Math.min(1, (now - start) / duration);
31636
- this._animCenter = t >= 1 ? void 0 : geodesicInterpolate(from, to, t);
31637
- this.requestUpdate();
33501
+ const remaining = 1 - easeInOutCubic(t);
33502
+ this._zoomOffset = {
33503
+ x: fromOffset.x * remaining,
33504
+ y: fromOffset.y * remaining
33505
+ };
33506
+ this._viewOverride = t >= 1 ? void 0 : viewAt(t);
31638
33507
  this.#animFrame = t < 1 ? requestAnimationFrame(step) : void 0;
31639
33508
  };
31640
33509
  this.#animFrame = requestAnimationFrame(step);
31641
33510
  }
31642
33511
  #focusAndMaybeSelect(node) {
31643
33512
  this.focusOn(node);
31644
- this.tickled.setTickled(node.item.key);
33513
+ const id = this.#idOf(node);
33514
+ if (id !== void 0) this.tickled.setTickled(id);
31645
33515
  if (this.interactionMode === `implicit`) this.select(node);
31646
33516
  }
31647
33517
  /** Focus + select the node at `keys` (path from the root's children). Returns `true` if found. */
@@ -31656,7 +33526,8 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31656
33526
  }
31657
33527
  this.focusOn(current);
31658
33528
  this.select(current);
31659
- this.tickled.setTickled(current.item.key);
33529
+ const id = this.#idOf(current);
33530
+ if (id !== void 0) this.tickled.setTickled(id);
31660
33531
  this.requestUpdate();
31661
33532
  return true;
31662
33533
  }
@@ -31664,7 +33535,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31664
33535
  navigateToNode(predicate) {
31665
33536
  const root = this.#effectiveRoot;
31666
33537
  if (!root?.children) return false;
31667
- const keyPath = this.#findKeyPath(root.children, predicate);
33538
+ const keyPath = findHyperbolicKeyPath(root.children, predicate);
31668
33539
  if (!keyPath) return false;
31669
33540
  return this.navigateTo(keyPath);
31670
33541
  }
@@ -31675,22 +33546,13 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31675
33546
  */
31676
33547
  revealKey(key) {
31677
33548
  const node = findHyperbolicNode(this.#effectiveRoot, key);
31678
- if (!node) return false;
31679
- if (!this.#layout().nodes.some((n) => n.key === key)) this.focusOn(node);
31680
- this.tickled.setTickled(key);
33549
+ const id = node ? this.#idOf(node) : void 0;
33550
+ if (!node || id === void 0) return false;
33551
+ if (!this.#layout().nodes.some((n) => n.id === id)) this.focusOn(node);
33552
+ this.tickled.setTickled(id);
31681
33553
  this.requestUpdate();
31682
33554
  return true;
31683
33555
  }
31684
- #findKeyPath(nodes, predicate, prefix = []) {
31685
- for (const node of nodes) {
31686
- const path = [...prefix, node.item.key];
31687
- if (predicate(node)) return path;
31688
- if (node.children?.length) {
31689
- const hit = this.#findKeyPath(node.children, predicate, path);
31690
- if (hit) return hit;
31691
- }
31692
- }
31693
- }
31694
33556
  /**
31695
33557
  * All nodes currently laid out on the disk (within `maxRadius` of the
31696
33558
  * focus), in render (ring-major) order. Unlike `ixfx-icicletree-list`, the
@@ -31727,7 +33589,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31727
33589
  this.requestUpdate();
31728
33590
  }
31729
33591
  } else if (this.interactionMode !== `manual`) this.select(n.node);
31730
- this.tickled.setTickled(n.key);
33592
+ this.tickled.setTickled(n.id);
31731
33593
  this.focusOn(n.node);
31732
33594
  }
31733
33595
  #handleItemDblClick(n) {
@@ -31749,239 +33611,147 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31749
33611
  }));
31750
33612
  }
31751
33613
  #handleKeyDown(event) {
31752
- if (this.disableKeyboardNavigation) return;
31753
33614
  const root = this.#effectiveRoot;
31754
- const { nodes } = this.#layout();
31755
- if (!root || nodes.length === 0) return;
31756
- const focusNode = findHyperbolicNode(root, this._focusKey ?? root.item.key);
31757
- if (!focusNode) return;
31758
- switch (event.key) {
31759
- case `ArrowUp`: {
31760
- event.preventDefault();
31761
- const children = nodes.filter((n) => n.parentKey === focusNode.item.key);
31762
- if (children.length === 0) return;
31763
- const best = children.reduce((a, b) => angleDistanceFromUp(a) < angleDistanceFromUp(b) ? a : b);
31764
- this.#focusAndMaybeSelect(best.node);
31765
- return;
31766
- }
31767
- case `ArrowDown`: {
31768
- event.preventDefault();
31769
- const path = findHyperbolicPath(root, focusNode.item.key);
31770
- const parent = path && path.length >= 2 ? path[path.length - 2] : void 0;
31771
- if (parent) this.#focusAndMaybeSelect(parent);
31772
- return;
31773
- }
31774
- case `ArrowLeft`:
31775
- case `ArrowRight`: {
31776
- event.preventDefault();
31777
- const path = findHyperbolicPath(root, focusNode.item.key);
31778
- const siblings = (path && path.length >= 2 ? path[path.length - 2] : void 0)?.children ?? (focusNode === root ? [focusNode] : []);
31779
- const index = siblings.findIndex((n) => n.item.key === focusNode.item.key);
31780
- if (index === -1 || siblings.length <= 1) return;
31781
- const next = siblings[(index + (event.key === `ArrowRight` ? 1 : -1) + siblings.length) % siblings.length];
31782
- this.#focusAndMaybeSelect(next);
31783
- return;
31784
- }
31785
- case `Enter`: {
31786
- event.preventDefault();
31787
- if (this.interactionMode !== `manual`) this.select(focusNode);
31788
- const depth = (findHyperbolicPath(root, focusNode.item.key)?.length ?? 1) - 1;
31789
- this.dispatchEvent(new CustomEvent(`activate`, {
31790
- detail: {
31791
- node: focusNode,
31792
- depth
31793
- },
31794
- bubbles: true,
31795
- composed: true
31796
- }));
31797
- return;
31798
- }
31799
- case `Escape`: if (this._focusKey !== void 0 && this._focusKey !== root.item.key) {
31800
- event.preventDefault();
31801
- this.focusOn(root);
31802
- }
33615
+ if (this.disableKeyboardNavigation || !root) return;
33616
+ if (event.key === `Escape` && this._previewId !== void 0) {
33617
+ event.preventDefault();
33618
+ this._previewId = void 0;
33619
+ return;
33620
+ }
33621
+ const { action, handled } = resolveKeyboardAction(event.key, root, this._focusId ?? root.item.key, this.#layout().nodes);
33622
+ if (handled) event.preventDefault();
33623
+ if (action.kind === `focus`) this.#focusAndMaybeSelect(action.node);
33624
+ else if (action.kind === `activate`) {
33625
+ if (this.interactionMode !== `manual`) this.select(action.node);
33626
+ this.dispatchEvent(new CustomEvent(`activate`, {
33627
+ detail: {
33628
+ node: action.node,
33629
+ depth: action.depth
33630
+ },
33631
+ bubbles: true,
33632
+ composed: true
33633
+ }));
31803
33634
  }
31804
33635
  }
31805
- #isBackgroundTarget(event) {
31806
- const target = event.target;
31807
- return target === event.currentTarget || target?.classList.contains(`disk-boundary`) === true;
31808
- }
31809
- #handleBackgroundPointerDown(event) {
31810
- if (!this.#isBackgroundTarget(event)) return;
31811
- event.currentTarget.setPointerCapture(event.pointerId);
31812
- this.#panStart = {
31813
- pointerId: event.pointerId,
31814
- startClient: {
31815
- x: event.clientX,
31816
- y: event.clientY
31817
- },
31818
- baseOffset: this._panOffset
31819
- };
31820
- }
31821
- #handleBackgroundPointerMove(event) {
31822
- if (!this.#panStart || event.pointerId !== this.#panStart.pointerId) return;
31823
- const rect = event.currentTarget.getBoundingClientRect();
31824
- const scale = 2.1 / (Math.min(rect.width, rect.height) || 1);
31825
- const dx = (event.clientX - this.#panStart.startClient.x) * scale;
31826
- const dy = (event.clientY - this.#panStart.startClient.y) * scale;
31827
- this._panOffset = clampToDisk({
31828
- x: this.#panStart.baseOffset.x - dx,
31829
- y: this.#panStart.baseOffset.y - dy
31830
- }, .9);
31831
- this.requestUpdate();
33636
+ #zoomState() {
33637
+ return clampZoomState({
33638
+ zoom: this.zoom,
33639
+ offset: this._zoomOffset
33640
+ });
31832
33641
  }
31833
- #handleBackgroundPointerUp(event) {
31834
- if (this.#panStart?.pointerId === event.pointerId) this.#panStart = void 0;
31835
- }
31836
- #defaultEdgeRenderer(edge) {
31837
- const steps = 8;
31838
- let d = ``;
31839
- for (let i = 0; i <= steps; i++) {
31840
- const p = geodesicInterpolate(edge.from, edge.to, i / steps);
31841
- d += `${i === 0 ? `M` : `L`}${(p.x * RENDER_SCALE).toFixed(2)},${(p.y * RENDER_SCALE).toFixed(2)} `;
31842
- }
31843
- return svg`<path
31844
- class="edge"
31845
- d=${d}
31846
- part="edge edge-to-${partToken(edge.toKey)} edge-from-${partToken(edge.fromKey)}"
31847
- data-from=${edge.fromKey}
31848
- data-to=${edge.toKey}
31849
- style="--edge-weight: ${edge.weightNormalized}"
31850
- />`;
33642
+ /**
33643
+ * Any pointer interaction (a node press, or a pan) moves keyboard focus to
33644
+ * the tree, so the user can carry straight on with the arrow keys.
33645
+ * Focuses the stable `<svg role="tree">` — not the node, which paint-order
33646
+ * re-sorting may move in the DOM (dropping its focus) — without scrolling.
33647
+ */
33648
+ #focusTree() {
33649
+ this.renderRoot.querySelector(`svg.hyperbolictree`)?.focus({ preventScroll: true });
31851
33650
  }
31852
- #renderEdge(edge, byKey) {
31853
- if (this.edgeRenderer) {
31854
- const fromNode = byKey.get(edge.fromKey)?.node;
31855
- const toNode = byKey.get(edge.toKey)?.node;
31856
- if (fromNode && toNode) return svg`<g transform="scale(${RENDER_SCALE})">${this.edgeRenderer({
31857
- edge,
31858
- fromNode,
31859
- toNode
31860
- })}</g>`;
31861
- }
31862
- return this.#defaultEdgeRenderer(edge);
31863
- }
31864
- /**
31865
- * A node's rendered dot radius: shrinks with distance from focus (existing
31866
- * behaviour), but also caps itself to the angular room {@link
31867
- * HyperbolicLayoutNode.angularShare} actually gives it at its radius —
31868
- * without this, a cluster of siblings squeezed into a narrow wedge (a
31869
- * bushy ancestor elsewhere in the tree, or many siblings at this level)
31870
- * all render at the same distance-only size and physically overlap into
31871
- * an unreadable blob, however small {@link minNodeRadius} suppresses their
31872
- * labels. `angularShare * distance` approximates the Euclidean arc length
31873
- * available to the node; capping the diameter to a fraction of that keeps
31874
- * a visible gap between crowded neighbours.
31875
- */
31876
- #nodeRenderRadius(n) {
31877
- const distance = Math.hypot(n.position.x, n.position.y);
31878
- const scale = Math.max(.2, 1 - distance);
31879
- const weightScale = this.minWeightScale + (this.maxWeightScale - this.minWeightScale) * n.weightNormalized;
31880
- const radius = this.nodeRadius * scale * weightScale;
31881
- if (distance < 1e-6) return radius;
31882
- const arcLength = n.angularShare * distance;
31883
- return Math.min(radius, Math.max(arcLength * .35, .003));
31884
- }
31885
- /**
31886
- * Which nodes get a rendered label, greedily thinned to avoid overlap: a
31887
- * per-node radius threshold alone ({@link minNodeRadius}) still lets many
31888
- * same-sized nodes crowded into a small angular wedge all show labels that
31889
- * visually collide. Nodes are considered in priority order (closer ring
31890
- * first, then larger on-screen radius) and a label is kept only if its
31891
- * position isn't too close to an already-kept one.
31892
- */
31893
- #computeLabelKeys(nodes) {
31894
- const minSpacing = .07;
31895
- const prioritised = [...nodes.filter((n) => this.#nodeRenderRadius(n) >= this.minNodeRadius)].sort((a, b) => {
31896
- if (a.ring !== b.ring) return a.ring - b.ring;
31897
- return Math.hypot(a.position.x, a.position.y) - Math.hypot(b.position.x, b.position.y);
33651
+ #handleNodePointerDown(event) {
33652
+ event.stopPropagation();
33653
+ this.#focusTree();
33654
+ }
33655
+ #pickPreviewChild(child) {
33656
+ this.#preview.hide();
33657
+ if (this.interactionMode !== `manual`) this.select(child);
33658
+ const id = this.#idOf(child);
33659
+ if (id !== void 0) this.tickled.setTickled(id);
33660
+ this.focusOn(child);
33661
+ this.#focusTree();
33662
+ }
33663
+ #previewFor(nodes) {
33664
+ const n = this._previewId === void 0 || this.disableChildPreview ? void 0 : nodes.find((v) => v.id === this._previewId);
33665
+ if (!n) return void 0;
33666
+ const { width, height } = this._hostSize;
33667
+ const anchor = diskToHostPx(n.position, this.#zoomState(), width || 420, height || 420);
33668
+ return {
33669
+ node: n.node,
33670
+ anchor,
33671
+ above: anchor.y > (height || 420) / 2
33672
+ };
33673
+ }
33674
+ #nodeContext(nodes, branches) {
33675
+ return buildNodeRenderContext(nodes, {
33676
+ nodeShape: this.nodeShape,
33677
+ radialLabels: !this.disableRadialLabels,
33678
+ zoom: this.#zoomState().zoom,
33679
+ font: fontMetrics(this, Math.min(this._hostSize.width, this._hostSize.height), this.zoom),
33680
+ branches,
33681
+ boxMaxChars: this.boxMaxChars,
33682
+ minLabelSize: this.minLabelSize,
33683
+ nodeRadius: this.nodeRadius,
33684
+ minNodeRadius: this.minNodeRadius,
33685
+ minWeightScale: this.minWeightScale,
33686
+ maxWeightScale: this.maxWeightScale,
33687
+ isSelected: (node) => this._selectedNodes.has(node),
33688
+ isTickled: (id) => this.tickled.isTickled(id)
31898
33689
  });
31899
- const kept = [];
31900
- const keys = /* @__PURE__ */ new Set();
31901
- for (const n of prioritised) if (!kept.some((k) => Math.hypot(k.position.x - n.position.x, k.position.y - n.position.y) < minSpacing)) {
31902
- kept.push(n);
31903
- keys.add(n.key);
31904
- }
31905
- return keys;
31906
33690
  }
31907
- #renderNode(n, siblingGroups, labelKeys) {
31908
- const { node, key, position } = n;
31909
- const selected = this._selectedNodes.has(node);
31910
- const tickled = this.tickled.isTickled(key);
31911
- const filtered = !this.#matchesFilter(node, n.depth);
31912
- const pending = this.#pendingKeys.has(key);
31913
- const isFocused = n.ring === 0;
31914
- const radius = this.#nodeRenderRadius(n);
31915
- const showLabel = labelKeys.has(key);
31916
- const siblings = siblingGroups.get(n.parentKey) ?? [n];
31917
- const index = siblings.findIndex((s) => s.key === key);
31918
- const scaledRadius = radius * RENDER_SCALE;
31919
- const weightStyle = `--node-weight: ${n.weightNormalized};${n.weight === void 0 ? `` : ` --node-weight-raw: ${n.weight};`}`;
31920
- return svg`
31921
- <g
31922
- class="node ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${filtered ? `filtered` : ``} ${pending ? `loading` : ``} ${isFocused ? `focused` : ``}"
31923
- data-key=${key}
31924
- role="treeitem"
31925
- tabindex="-1"
31926
- aria-level=${n.depth + 1}
31927
- aria-selected=${selected}
31928
- aria-setsize=${siblings.length}
31929
- aria-posinset=${index + 1}
31930
- aria-label=${node.item.label}
31931
- part="node node-${partToken(key)}"
31932
- style=${weightStyle}
31933
- transform="translate(${position.x * RENDER_SCALE} ${position.y * RENDER_SCALE})"
31934
- @pointerdown=${(e) => e.stopPropagation()}
31935
- @click=${(e) => {
31936
- e.stopPropagation();
31937
- this.#handleItemClick(n, e);
31938
- }}
31939
- @dblclick=${(e) => {
31940
- e.stopPropagation();
31941
- this.#handleItemDblClick(n);
31942
- }}
31943
- @pointerenter=${() => this.tickled.handlePointerEnter(key)}
31944
- >
31945
- <circle class="node-hit" r=${Math.max(scaledRadius, .035 * RENDER_SCALE)} part="hit" />
31946
- <circle class="node-dot" r=${scaledRadius} part="dot" />
31947
- ${showLabel ? svg`<text class="hyperbolictree-label" part="label" x=${scaledRadius + .01 * RENDER_SCALE} y="0" dominant-baseline="middle">${node.item.label}</text>` : nothing}
31948
- </g>
31949
- `;
33691
+ #renderNodes(nodes, ctx) {
33692
+ return renderNodes(nodes, this.nodeShape, {
33693
+ ctx,
33694
+ state: {
33695
+ isSelected: (node) => this._selectedNodes.has(node),
33696
+ isTickled: (id) => this.tickled.isTickled(id),
33697
+ showTitle: (n) => this.disableChildPreview || !n.node.children?.length,
33698
+ isFiltered: (n) => !this.#matchesFilter(n.node, n.depth),
33699
+ isPending: (node) => this.#lazy.isPending(node)
33700
+ },
33701
+ handlers: {
33702
+ onPointerDown: (e) => this.#handleNodePointerDown(e),
33703
+ onClick: (n, e) => {
33704
+ e.stopPropagation();
33705
+ this.#handleItemClick(n, e);
33706
+ },
33707
+ onDblClick: (n, e) => {
33708
+ e.stopPropagation();
33709
+ this.#handleItemDblClick(n);
33710
+ },
33711
+ onPointerEnter: (id) => this.tickled.handlePointerEnter(id)
33712
+ }
33713
+ });
31950
33714
  }
31951
33715
  render() {
31952
33716
  const { nodes, edges } = this.#layout();
31953
- const byKey = new Map(nodes.map((n) => [n.key, n]));
31954
- const siblingGroups = /* @__PURE__ */ new Map();
31955
- for (const n of nodes) {
31956
- const group = siblingGroups.get(n.parentKey) ?? [];
31957
- group.push(n);
31958
- siblingGroups.set(n.parentKey, group);
31959
- }
31960
- const labelKeys = this.#computeLabelKeys(nodes);
33717
+ const byId = new Map(nodes.map((n) => [n.id, n]));
33718
+ const branches = branchIndices(this.#effectiveRoot);
33719
+ const ctx = this.#nodeContext(nodes, branches);
33720
+ this.#lastLabelIds = ctx.labelIds;
33721
+ const { zoom, offset } = this.#zoomState();
33722
+ const pan = offset.x === 0 && offset.y === 0 ? `` : `translate(${offset.x * 500} ${offset.y * 500}) `;
31961
33723
  return html`
31962
33724
  <svg
31963
33725
  class="hyperbolictree"
31964
- viewBox="${-1.05 * RENDER_SCALE} ${-1.05 * RENDER_SCALE} ${2.1 * RENDER_SCALE} ${2.1 * RENDER_SCALE}"
33726
+ viewBox="${-525} ${-525} ${1050} ${1050}"
31965
33727
  preserveAspectRatio="xMidYMid meet"
31966
33728
  role="tree"
31967
33729
  tabindex="0"
31968
33730
  data-input-mode=${this.tickled.inputMode}
33731
+ data-node-shape=${this.nodeShape}
31969
33732
  @keydown=${(e) => this.#handleKeyDown(e)}
31970
- @pointerdown=${(e) => this.#handleBackgroundPointerDown(e)}
31971
- @pointermove=${(e) => this.#handleBackgroundPointerMove(e)}
31972
- @pointerup=${(e) => this.#handleBackgroundPointerUp(e)}
33733
+ @pointerdown=${(e) => this.#gestures.onPointerDown(e)}
33734
+ @pointermove=${(e) => this.#gestures.onPointerMove(e)}
33735
+ @pointerup=${(e) => this.#gestures.onPointerUp(e)}
33736
+ @pointercancel=${(e) => this.#gestures.onPointerUp(e)}
33737
+ @wheel=${(e) => this.#gestures.onWheel(e)}
31973
33738
  @pointerleave=${() => this.tickled.clearTickled()}
31974
33739
  >
31975
- <g class="zoom" transform="scale(${this.zoom})">
31976
- <circle class="disk-boundary" part="boundary" cx="0" cy="0" r=${RENDER_SCALE} />
33740
+ <g class="zoom" transform="${pan}scale(${zoom})">
33741
+ <circle class="disk-boundary" part="boundary" cx="0" cy="0" r=${500} />
31977
33742
  <g class="edges" part="edges">
31978
- ${repeat(edges, (e) => `${e.fromKey}->${e.toKey}`, (e) => this.#renderEdge(e, byKey))}
33743
+ ${renderEdges(edges, byId, branches, this.edgeRenderer)}
31979
33744
  </g>
31980
33745
  <g class="nodes" part="nodes">
31981
- ${repeat(nodes, (n) => n.key, (n) => this.#renderNode(n, siblingGroups, labelKeys))}
33746
+ ${this.#renderNodes(nodes, ctx)}
31982
33747
  </g>
31983
33748
  </g>
31984
33749
  </svg>
33750
+ ${renderChildPreview(this.#previewFor(nodes), {
33751
+ onPointerEnter: () => this.#preview.pointerEnter(),
33752
+ onPointerLeave: () => this.#preview.pointerLeave(this.tickled.tickledId),
33753
+ onPick: (child) => this.#pickPreviewChild(child)
33754
+ })}
31985
33755
  ${this.search.renderField()}
31986
33756
  `;
31987
33757
  }
@@ -31989,102 +33759,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31989
33759
  this.styles = [
31990
33760
  themeFallbacks,
31991
33761
  listSearchStyles,
31992
- css`
31993
- :host {
31994
- --hyperbolictree-node-color: var(--surface-4);
31995
- --hyperbolictree-node-text: var(--surface-3-text);
31996
- --hyperbolictree-edge-color: var(--surface-6);
31997
- --hyperbolictree-boundary-color: var(--border-subtle);
31998
- --hyperbolictree-font-size: 25px;
31999
-
32000
- display: block;
32001
- overflow: hidden;
32002
- outline: none;
32003
- overscroll-behavior: contain;
32004
- }
32005
-
32006
- /*
32007
- * delegatesFocus means the host itself reports as focused whenever
32008
- * the shadow DOM's focused descendant (the svg) is — without this,
32009
- * the host also picks up the browser's own default focus ring/fill
32010
- * sized to the whole box.
32011
- */
32012
- :host(:focus),
32013
- :host(:focus-visible) {
32014
- outline: none;
32015
- }
32016
-
32017
- .hyperbolictree {
32018
- display: block;
32019
- width: 100%;
32020
- height: 100%;
32021
- outline: none;
32022
- touch-action: none;
32023
- overscroll-behavior: contain;
32024
- }
32025
-
32026
- .disk-boundary {
32027
- fill: none;
32028
- stroke: var(--hyperbolictree-boundary-color);
32029
- stroke-width: 1;
32030
- }
32031
-
32032
- .edge {
32033
- fill: none;
32034
- stroke: var(--hyperbolictree-edge-color);
32035
- /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
32036
- stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
32037
- }
32038
-
32039
- .node {
32040
- cursor: pointer;
32041
- }
32042
-
32043
- .node-hit {
32044
- fill: transparent;
32045
- pointer-events: all;
32046
- }
32047
-
32048
- .node-dot {
32049
- /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
32050
- fill: color-mix(in oklab, var(--hyperbolictree-node-color), var(--accent) calc(var(--node-weight, 0.5) * 50%));
32051
- stroke: var(--surface-2);
32052
- stroke-width: 1;
32053
- pointer-events: none;
32054
- }
32055
-
32056
- .node.tickled .node-dot {
32057
- fill: var(--item-bg-tickled);
32058
- }
32059
-
32060
- .node.selected .node-dot {
32061
- fill: var(--item-bg-selected);
32062
- }
32063
-
32064
- .node.selected.tickled .node-dot {
32065
- fill: var(--item-bg-selected-tickled);
32066
- }
32067
-
32068
- .node.focused .node-dot {
32069
- stroke: var(--accent);
32070
- stroke-width: 3;
32071
- }
32072
-
32073
- .node.filtered {
32074
- opacity: 0.3;
32075
- }
32076
-
32077
- .node.loading .node-dot {
32078
- opacity: 0.6;
32079
- }
32080
-
32081
- .hyperbolictree-label {
32082
- font-size: var(--hyperbolictree-font-size);
32083
- fill: var(--hyperbolictree-node-text);
32084
- cursor: pointer;
32085
- user-select: none;
32086
- }
32087
- `
33762
+ hyperbolicTreeStyles
32088
33763
  ];
32089
33764
  }
32090
33765
  };
@@ -32107,6 +33782,24 @@ __decorate([property({
32107
33782
  type: Number,
32108
33783
  attribute: `max-radius`
32109
33784
  })], HyperbolicTreeListElement.prototype, "maxRadius", void 0);
33785
+ __decorate([property({
33786
+ type: String,
33787
+ attribute: `node-shape`,
33788
+ reflect: true
33789
+ })], HyperbolicTreeListElement.prototype, "nodeShape", void 0);
33790
+ __decorate([property({
33791
+ type: Number,
33792
+ attribute: `box-max-chars`
33793
+ })], HyperbolicTreeListElement.prototype, "boxMaxChars", void 0);
33794
+ __decorate([property({
33795
+ type: Number,
33796
+ attribute: `min-label-size`
33797
+ })], HyperbolicTreeListElement.prototype, "minLabelSize", void 0);
33798
+ __decorate([property({
33799
+ type: Boolean,
33800
+ attribute: `color-by-branch`,
33801
+ reflect: true
33802
+ })], HyperbolicTreeListElement.prototype, "colorByBranch", void 0);
32110
33803
  __decorate([property({
32111
33804
  type: Number,
32112
33805
  attribute: `node-radius`
@@ -32124,7 +33817,23 @@ __decorate([property({
32124
33817
  attribute: `max-weight-scale`
32125
33818
  })], HyperbolicTreeListElement.prototype, "maxWeightScale", void 0);
32126
33819
  __decorate([property({ type: Number })], HyperbolicTreeListElement.prototype, "zoom", void 0);
33820
+ __decorate([property({
33821
+ type: Boolean,
33822
+ attribute: `disable-zoom-gestures`
33823
+ })], HyperbolicTreeListElement.prototype, "disableZoomGestures", void 0);
33824
+ __decorate([property({
33825
+ type: Boolean,
33826
+ attribute: `disable-radial-labels`
33827
+ })], HyperbolicTreeListElement.prototype, "disableRadialLabels", void 0);
33828
+ __decorate([property({
33829
+ type: Boolean,
33830
+ attribute: `disable-child-preview`
33831
+ })], HyperbolicTreeListElement.prototype, "disableChildPreview", void 0);
32127
33832
  __decorate([property({ type: Number })], HyperbolicTreeListElement.prototype, "curvature", void 0);
33833
+ __decorate([property({
33834
+ type: Number,
33835
+ attribute: `sibling-spacing`
33836
+ })], HyperbolicTreeListElement.prototype, "siblingSpacing", void 0);
32128
33837
  __decorate([property({
32129
33838
  type: Number,
32130
33839
  attribute: `focus-animation-duration`
@@ -32143,9 +33852,12 @@ __decorate([property({
32143
33852
  attribute: `search-modes`,
32144
33853
  reflect: true
32145
33854
  })], HyperbolicTreeListElement.prototype, "searchModes", void 0);
32146
- __decorate([state()], HyperbolicTreeListElement.prototype, "_focusKey", void 0);
32147
- __decorate([state()], HyperbolicTreeListElement.prototype, "_animCenter", void 0);
32148
- __decorate([state()], HyperbolicTreeListElement.prototype, "_panOffset", void 0);
33855
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_focusId", void 0);
33856
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_viewOverride", void 0);
33857
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_focusRotation", void 0);
33858
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_zoomOffset", void 0);
33859
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_previewId", void 0);
33860
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_hostSize", void 0);
32149
33861
  HyperbolicTreeListElement = __decorate([safeCustomElement(`ixfx-hyperbolictree-list`)], HyperbolicTreeListElement);
32150
33862
  //#endregion
32151
33863
  //#region src/icicle-tree/layout.ts
@@ -37913,6 +39625,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37913
39625
  const open = this._isOpen(node, depth);
37914
39626
  const isBranch = node.item.isLeaf !== true;
37915
39627
  const icon = node.item.icon;
39628
+ const iconColor = appearanceColor(node.item.iconColor);
39629
+ const textColor = appearanceColor(node.item.textColor);
39630
+ const badge = appearanceAbsent(node.item.badge) ? void 0 : node.item.badge;
37916
39631
  const tooltip = node.item.tooltip;
37917
39632
  const isCheckedMode = this.interactionMode === `checked`;
37918
39633
  let checkboxTpl = nothing;
@@ -37940,8 +39655,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37940
39655
  @pointerenter=${() => this._handleItemPointerEnter(node, depth)}
37941
39656
  >
37942
39657
  ${checkboxTpl}
37943
- ${icon ? html`<ixfx-icon class="item-icon" name="${icon}"></ixfx-icon>` : nothing}
37944
- <span class="label">${node.item.label}</span>
39658
+ ${icon ? html`<ixfx-icon class="item-icon" name="${icon}" style=${iconColor ? `color:${iconColor}` : nothing}></ixfx-icon>` : nothing}
39659
+ <span class="label" style=${textColor ? `color:${textColor}` : nothing}>${node.item.label}</span>
39660
+ ${badge === void 0 ? nothing : html`<span class="item-badge" part="badge">${badge}</span>`}
37945
39661
  ${tooltip ? html`<ixfx-tooltip class="item-tooltip" anchor="--tooltip-miller-${depth}-${node.item.key}" .value=${tooltip}></ixfx-tooltip>` : nothing}
37946
39662
  ${isBranch ? html`<span class="caret" aria-hidden="true">›</span>` : nothing}
37947
39663
  </li>
@@ -37995,6 +39711,17 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37995
39711
  text-overflow: ellipsis;
37996
39712
  }
37997
39713
 
39714
+ /* Finder-style badge: sits between the label and the caret, flush to
39715
+ the right edge of the row. The label is flex:1, so the gap opens up
39716
+ ahead of the badge automatically. */
39717
+ .item-badge {
39718
+ flex-shrink: 0;
39719
+ display: inline-flex;
39720
+ align-items: center;
39721
+ margin-inline-start: auto;
39722
+ padding-inline-start: var(--miller-list-badge-gap, 0.5em);
39723
+ }
39724
+
37998
39725
  .item-tooltip {
37999
39726
  position: absolute;
38000
39727
  }
@@ -51586,6 +53313,6 @@ function init() {
51586
53313
  console.log(`Init`);
51587
53314
  }
51588
53315
  //#endregion
51589
- export { AcTextElement, AcTokenElement, BottomDrawerElement, ButtonElement, Checkbox, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EditableLabel, EditableNumber, FormElement, FormGroupElement, FormPartElement, GridListElement, GroupedItemListerElement, HyperbolicTreeListElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IcicleTreeListElement, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListDragSelectController, ListNativeDragController, ListSearchController, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, MulticolListElement, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeDropController, TreeKeyboardNavController, TreeListElement, TreemapListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applySnapshot, applyTreeDrop, attachTimelineInteractions, bottomDrawerCommandId, buildHighlightTargets, buildModel, captionForEntry, clampToDisk, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, computeCanonicalPositions, computeColour, computeHyperbolicLayout, computeIcicleLayout, computeLeafGeoms, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createVerticalListAdapter, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findHyperbolicNode, findHyperbolicPath, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, findTreemapNode, findTreemapPath, geodesicInterpolate, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, hyperbolicDistanceForEuclideanRadius, iconBus, init, insertBeside, isDragging, isMultiMode, isSplit, isStack, keyForElement, listItemFadeKeyframes, listSearchStyles, mobiusAdd, mobiusFromOrigin, mobiusToOrigin, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, norm, normalise, normaliseValue, normalize, panelGapIndex, parseColourScaleAttr, parseLayout, poincareDistance, pointerOverAnyHost, prefersReducedMotion, preserveRestoreWeights, pruneDraggedDescendants, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveLeafPath, resolveTracks, serializeLayout, setActive, setIconFont, setPanelRestoreWeight, startListItemOutAnimation, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toSnapshot, toolbarGapIndex, walk, wrapRoot };
53316
+ export { AcTextElement, AcTokenElement, BottomDrawerElement, ButtonElement, Checkbox, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DEFAULT_SIBLING_SPACING, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EditableLabel, EditableNumber, FormElement, FormGroupElement, FormPartElement, GridListElement, GroupedItemListerElement, HyperbolicTreeListElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IcicleTreeListElement, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListDragSelectController, ListNativeDragController, ListSearchController, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, MulticolListElement, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeDropController, TreeKeyboardNavController, TreeListElement, TreemapListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applyIsometry, applySnapshot, applyTreeDrop, attachTimelineInteractions, bottomDrawerCommandId, buildHighlightTargets, buildModel, captionForEntry, clampToDisk, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, composeIsometry, computeCanonicalPositions, computeColour, computeHyperbolicLayout, computeIcicleLayout, computeLeafGeoms, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createVerticalListAdapter, decomposeViewIsometry, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findHyperbolicIdOf, findHyperbolicKeyPath, findHyperbolicNode, findHyperbolicPath, findHyperbolicPathById, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, findTreemapNode, findTreemapPath, geodesicInterpolate, geodesicPath, geodesicSegment, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, hyperbolicBranchId, hyperbolicChildIds, hyperbolicDistanceForEuclideanRadius, hyperbolicIdDepth, hyperbolicRelativeFrame, iconBus, identityIsometry, init, insertBeside, invertIsometry, isDragging, isHyperbolicDescendantId, isMultiMode, isSplit, isStack, keyForElement, listItemFadeKeyframes, listSearchStyles, mobiusAdd, mobiusFromOrigin, mobiusToOrigin, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, norm, normalise, normaliseValue, normalize, panelGapIndex, parseColourScaleAttr, parseLayout, poincareDistance, pointerOverAnyHost, prefersReducedMotion, preserveRestoreWeights, pruneDraggedDescendants, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveLeafPath, resolveTracks, rotationIsometry, serializeLayout, setActive, setIconFont, setPanelRestoreWeight, startListItemOutAnimation, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toSnapshot, toolbarGapIndex, translationFromOrigin, viewIsometry, walk, wrapRoot };
51590
53317
 
51591
53318
  //# sourceMappingURL=index.js.map