@ixfx/components 0.11.3 → 0.11.5

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 (123) hide show
  1. package/bundle/index.d.ts +444 -120
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +2293 -553
  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-BWrtE8H9.d.ts → data-grid-CPsJBnL4.d.ts} +48 -6
  16. package/dist/data-grid-CPsJBnL4.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-PjiaGpA5.d.ts → index-CnyoNA6m.d.ts} +4 -4
  43. package/dist/{index-PjiaGpA5.d.ts.map → index-CnyoNA6m.d.ts.map} +1 -1
  44. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +2 -2
  45. package/dist/{index-BUB5SICW.d.ts.map → index-CoqtMSd9.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 +2054 -647
  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-hoKGwyKT.js → labelled-input-base-BFa4xlYV.js} +11 -8
  57. package/dist/labelled-input-base-BFa4xlYV.js.map +1 -0
  58. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +2 -2
  59. package/dist/{labelled-input-base-Dp_9KP0G.d.ts.map → labelled-input-base-DiTme1JO.d.ts.map} +1 -1
  60. package/dist/labelled-radial-input.d.ts +2 -2
  61. package/dist/labelled-radial-input.d.ts.map +1 -1
  62. package/dist/labelled-radial-input.js +1 -1
  63. package/dist/labelled-range-input.d.ts +1 -1
  64. package/dist/labelled-range-input.js +1 -1
  65. package/dist/{labelled-slider-input-CF2RtzgG.js → labelled-slider-input-FdCRi6st.js} +2 -2
  66. package/dist/{labelled-slider-input-CF2RtzgG.js.map → labelled-slider-input-FdCRi6st.js.map} +1 -1
  67. package/dist/labelled-slider-input.d.ts +1 -1
  68. package/dist/labelled-slider-input.js +1 -1
  69. package/dist/{list-native-drag-controller-B9reY4uM.d.ts → list-native-drag-controller-CllsHLxw.d.ts} +2 -2
  70. package/dist/{list-native-drag-controller-B9reY4uM.d.ts.map → list-native-drag-controller-CllsHLxw.d.ts.map} +1 -1
  71. package/dist/{menu-B2Qv3Oiz.js → menu-BHT6HRND.js} +2 -2
  72. package/dist/{menu-B2Qv3Oiz.js.map → menu-BHT6HRND.js.map} +1 -1
  73. package/dist/{menu-item-ZmkXNjek.d.ts → menu-item-DT7bKAIw.d.ts} +4 -2
  74. package/dist/menu-item-DT7bKAIw.d.ts.map +1 -0
  75. package/dist/{menu-item-Woc8jJc1.js → menu-item-JcpHCyF0.js} +30 -6
  76. package/dist/menu-item-JcpHCyF0.js.map +1 -0
  77. package/dist/menu.d.ts +2 -2
  78. package/dist/menu.js +2 -2
  79. package/dist/miller.d.ts +3 -3
  80. package/dist/miller.d.ts.map +1 -1
  81. package/dist/miller.js +18 -2
  82. package/dist/miller.js.map +1 -1
  83. package/dist/panel.d.ts +1 -1
  84. package/dist/range-input.js +8 -2
  85. package/dist/range-input.js.map +1 -1
  86. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-JK3xWVX2.d.ts} +2 -2
  87. package/dist/{split-layout-C5SYbDrq.d.ts.map → split-layout-JK3xWVX2.d.ts.map} +1 -1
  88. package/dist/split-layout.d.ts +1 -1
  89. package/dist/{tab-list-v8mKX7Fu.d.ts → tab-list-pqfKaVAE.d.ts} +2 -2
  90. package/dist/{tab-list-v8mKX7Fu.d.ts.map → tab-list-pqfKaVAE.d.ts.map} +1 -1
  91. package/dist/tabs.d.ts +1 -1
  92. package/dist/{titlebar-B5zSTZnF.d.ts → titlebar-Tqu-VvMU.d.ts} +2 -2
  93. package/dist/{titlebar-B5zSTZnF.d.ts.map → titlebar-Tqu-VvMU.d.ts.map} +1 -1
  94. package/dist/titlebar.d.ts +1 -1
  95. package/dist/{tree-component-CMGeqyjZ.d.ts → tree-component-DukLqesg.d.ts} +2 -2
  96. package/dist/{tree-component-CMGeqyjZ.d.ts.map → tree-component-DukLqesg.d.ts.map} +1 -1
  97. package/dist/tree.d.ts +4 -4
  98. package/dist/{types-3Jfcaxvo.d.ts → types-D-kYF16b.d.ts} +21 -1
  99. package/dist/types-D-kYF16b.d.ts.map +1 -0
  100. package/dist/{vertical-list-R25WqAVO.d.ts → vertical-list-DTp-UIUE.d.ts} +26 -3
  101. package/dist/vertical-list-DTp-UIUE.d.ts.map +1 -0
  102. package/dist/vertical-list.d.ts +2 -2
  103. package/dist/vertical-list.js +53 -4
  104. package/dist/vertical-list.js.map +1 -1
  105. package/docs-user/data-grid.md +51 -1
  106. package/docs-user/detail-list.md +46 -0
  107. package/docs-user/grid-list.md +49 -2
  108. package/docs-user/hyperbolic-tree.md +98 -18
  109. package/docs-user/index.json +2 -2
  110. package/docs-user/miller.md +52 -7
  111. package/docs-user/multicol-list.md +1 -0
  112. package/docs-user/vertical-list.md +63 -0
  113. package/package.json +1 -1
  114. package/dist/data-grid-BWrtE8H9.d.ts.map +0 -1
  115. package/dist/detail-list-DxQZkK-C.d.ts.map +0 -1
  116. package/dist/grid-list-ChgYpUUq.d.ts.map +0 -1
  117. package/dist/grid-list-k89JpiNy.js.map +0 -1
  118. package/dist/icon-DR_3rSyZ.d.ts.map +0 -1
  119. package/dist/labelled-input-base-hoKGwyKT.js.map +0 -1
  120. package/dist/menu-item-Woc8jJc1.js.map +0 -1
  121. package/dist/menu-item-ZmkXNjek.d.ts.map +0 -1
  122. package/dist/types-3Jfcaxvo.d.ts.map +0 -1
  123. package/dist/vertical-list-R25WqAVO.d.ts.map +0 -1
package/bundle/index.js CHANGED
@@ -7245,6 +7245,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7245
7245
  this._isOpen = false;
7246
7246
  this._hasMenu = false;
7247
7247
  this._hasTriggerSlot = false;
7248
+ this._hasContextTarget = false;
7248
7249
  this._anchorName = `--mt-${_MenuTrigger._count++}`;
7249
7250
  this._clickListener = this._handleClickOutside.bind(this);
7250
7251
  this._handlePopoverToggle = (e) => {
@@ -7297,11 +7298,14 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7297
7298
  this._propagateContext();
7298
7299
  }
7299
7300
  }
7301
+ const contextSlot = this.shadowRoot?.querySelector(`slot[name="context-target"]`);
7302
+ if (contextSlot) this._hasContextTarget = contextSlot.assignedElements({ flatten: true }).length > 0;
7300
7303
  this._updatePosition();
7301
7304
  });
7302
7305
  }
7303
7306
  updated(changedProperties) {
7304
7307
  if (changedProperties.has(`registry`) || changedProperties.has(`keyboardManager`)) this._propagateContext();
7308
+ if (changedProperties.has(`mode`)) this._updatePosition();
7305
7309
  }
7306
7310
  _updatePosition() {
7307
7311
  const pos = {
@@ -7348,6 +7352,10 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7348
7352
  const slot = e.target;
7349
7353
  this._hasTriggerSlot = slot.assignedElements({ flatten: true }).length > 0;
7350
7354
  }
7355
+ _handleContextTargetSlotChange(e) {
7356
+ const slot = e.target;
7357
+ this._hasContextTarget = slot.assignedElements({ flatten: true }).length > 0;
7358
+ }
7351
7359
  get isOpen() {
7352
7360
  return this._isOpen;
7353
7361
  }
@@ -7386,6 +7394,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7386
7394
  document.removeEventListener(`click`, this._clickListener);
7387
7395
  }
7388
7396
  _handleTriggerClick(e) {
7397
+ if (this.mode === `context`) return;
7389
7398
  e.stopPropagation();
7390
7399
  if (this._isOpen) {
7391
7400
  this._closeMenu();
@@ -7432,9 +7441,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7432
7441
  this._popup.style.left = `${e.clientX}px`;
7433
7442
  this._popup.style.position = `fixed`;
7434
7443
  this._popup.style.positionAnchor = `none`;
7435
- if (this._popup?.popover) try {
7436
- this._popup.showPopover();
7437
- } catch {}
7444
+ this._openMenu();
7438
7445
  }
7439
7446
  static {
7440
7447
  this.styles = [
@@ -7456,6 +7463,18 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7456
7463
  align-items: stretch;
7457
7464
  }
7458
7465
 
7466
+ /* In context mode the target is the consumer's own content surface (a
7467
+ list, canvas, whole panel) rather than a button wrapper. Dissolve the
7468
+ wrapper's box by default so slotted content participates directly in
7469
+ the layout established around the host and can be clipped/scroll within
7470
+ the space actually available. Set --menu-trigger-area-display: flex
7471
+ (plus --menu-trigger-area-flex) to keep a real box instead, e.g. for
7472
+ a mixed visible-trigger + context-target use. */
7473
+ :host([mode="context"]) .trigger-area {
7474
+ display: var(--menu-trigger-area-display, contents);
7475
+ flex: var(--menu-trigger-area-flex, initial);
7476
+ }
7477
+
7459
7478
  .trigger-btn {
7460
7479
  display: inline-flex;
7461
7480
  align-items: center;
@@ -7518,7 +7537,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7518
7537
  ];
7519
7538
  }
7520
7539
  render() {
7521
- const showButton = this.mode !== `dropdown` && this._hasMenu && !this._hasTriggerSlot;
7540
+ const showButton = this.mode !== `dropdown` && this._hasMenu && !this._hasTriggerSlot && !(this.mode === `context` && this._hasContextTarget);
7522
7541
  return html`
7523
7542
  <div
7524
7543
  class="trigger-area"
@@ -7526,6 +7545,7 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7526
7545
  @contextmenu=${this._handleContextMenu}
7527
7546
  >
7528
7547
  ${showButton ? html`<button class="trigger-btn" part="base" tabindex=${this.tabStop ? `0` : `-1`}>${this.iconName ? html`<ixfx-icon name=${this.iconName}></ixfx-icon>` : this.label || html`<ixfx-icon name="ellipsis-vertical"></ixfx-icon>`}</button>` : null}
7548
+ ${this.mode === `context` ? html`<slot name="context-target" @slotchange=${this._handleContextTargetSlotChange}></slot>` : null}
7529
7549
  <slot name="trigger" @slotchange=${this._handleTriggerSlotChange}></slot>
7530
7550
  </div>
7531
7551
  <div
@@ -7538,7 +7558,10 @@ let MenuTrigger = class MenuTrigger extends LitElement {
7538
7558
  `;
7539
7559
  }
7540
7560
  };
7541
- __decorate([property({ type: String })], MenuTrigger.prototype, "mode", void 0);
7561
+ __decorate([property({
7562
+ type: String,
7563
+ reflect: true
7564
+ })], MenuTrigger.prototype, "mode", void 0);
7542
7565
  __decorate([property({ type: String })], MenuTrigger.prototype, "label", void 0);
7543
7566
  __decorate([property({
7544
7567
  type: String,
@@ -7554,6 +7577,7 @@ __decorate([property({
7554
7577
  __decorate([state()], MenuTrigger.prototype, "_isOpen", void 0);
7555
7578
  __decorate([state()], MenuTrigger.prototype, "_hasMenu", void 0);
7556
7579
  __decorate([state()], MenuTrigger.prototype, "_hasTriggerSlot", void 0);
7580
+ __decorate([state()], MenuTrigger.prototype, "_hasContextTarget", void 0);
7557
7581
  __decorate([query(`.trigger-btn`)], MenuTrigger.prototype, "_triggerBtn", void 0);
7558
7582
  __decorate([query(`.popup`)], MenuTrigger.prototype, "_popup", void 0);
7559
7583
  MenuTrigger = _MenuTrigger = __decorate([safeCustomElement(`ixfx-menu-trigger`)], MenuTrigger);
@@ -8460,37 +8484,40 @@ var LabelledInputBase = class extends LitElement {
8460
8484
  this._isDragging = false;
8461
8485
  this._debounceTimer = void 0;
8462
8486
  this._handleNumberChange = (e) => {
8463
- const newValue = Number.parseFloat(e.detail.value);
8487
+ const valueStr = e.detail?.value ?? String(this.value);
8488
+ const newValue = Number.parseFloat(valueStr);
8464
8489
  this.value = newValue;
8465
8490
  this._syncValue(newValue);
8466
8491
  this.dispatchEvent(new CustomEvent(`change`, {
8467
- detail: { value: e.detail.value },
8492
+ detail: { value: valueStr },
8468
8493
  bubbles: true,
8469
8494
  composed: true
8470
8495
  }));
8471
8496
  };
8472
8497
  this._handleInputChange = (e) => {
8473
- this.value = e.detail.value;
8498
+ const value = e.detail?.value ?? this.value;
8499
+ this.value = value;
8474
8500
  clearTimeout(this._debounceTimer);
8475
8501
  this._debounceTimer = window.setTimeout(() => {
8476
8502
  this._isDragging = false;
8477
8503
  }, 300);
8478
8504
  this.dispatchEvent(new CustomEvent(`change`, {
8479
- detail: { value: String(e.detail.value) },
8505
+ detail: { value: String(value) },
8480
8506
  bubbles: true,
8481
8507
  composed: true
8482
8508
  }));
8483
8509
  };
8484
8510
  this._handleSliderInput = (e) => {
8511
+ const value = e.detail?.value ?? this.value;
8485
8512
  console.log(`[LABELLED-INPUT] _handleSliderInput called`, {
8486
- value: e.detail.value,
8513
+ value,
8487
8514
  composedPath: e.composedPath()
8488
8515
  });
8489
- this.value = e.detail.value;
8516
+ this.value = value;
8490
8517
  this._isDragging = true;
8491
8518
  clearTimeout(this._debounceTimer);
8492
8519
  this.dispatchEvent(new CustomEvent(`input`, {
8493
- detail: { value: e.detail.value },
8520
+ detail: { value },
8494
8521
  bubbles: true,
8495
8522
  composed: true
8496
8523
  }));
@@ -11986,6 +12013,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
11986
12013
  this.path = ``;
11987
12014
  this.separator = `/`;
11988
12015
  this.pathRoot = ``;
12016
+ this.hintPrevious = false;
11989
12017
  this._openIdx = null;
11990
12018
  this._editing = false;
11991
12019
  this._cachedSiblings = /* @__PURE__ */ new Map();
@@ -11997,6 +12025,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
11997
12025
  this._openCumPath = null;
11998
12026
  this._dragCumPath = null;
11999
12027
  this._dropCumPath = null;
12028
+ this._hintSegments = null;
12000
12029
  this.#uid = Math.random().toString(36).slice(2, 8);
12001
12030
  this.#clickSuppressUntil = 0;
12002
12031
  this.#onPopoverToggle = (e) => {
@@ -12132,6 +12161,19 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12132
12161
  #clicksSuppressed() {
12133
12162
  return Date.now() < this.#clickSuppressUntil || this._dragCumPath !== null;
12134
12163
  }
12164
+ #isSegmentPrefix(prefix, full) {
12165
+ return prefix.length <= full.length && prefix.every((seg, i) => seg === full[i]);
12166
+ }
12167
+ /**
12168
+ * Called when a real segment click shortens the path. Keeps the deepest
12169
+ * known branch: only overwrites `_hintSegments` when the current hint no
12170
+ * longer covers where we're navigating to.
12171
+ */
12172
+ #recordHintOnShorten(oldSegments, newSegments) {
12173
+ if (!this.hintPrevious) return;
12174
+ const hint = this._hintSegments;
12175
+ if (!(hint !== null && this.#isSegmentPrefix(newSegments, hint))) this._hintSegments = oldSegments;
12176
+ }
12135
12177
  #resolveSegmentIcon(fullPathSegments, index) {
12136
12178
  const direct = this.segmentIconLookup?.(fullPathSegments, index);
12137
12179
  if (typeof direct === `string` && direct.length > 0) return direct;
@@ -12277,12 +12319,63 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12277
12319
  composed: true
12278
12320
  }));
12279
12321
  }
12322
+ /**
12323
+ * The current leaf segment (deepest visible segment), used as the drop target
12324
+ * when a drag is released over blank space rather than a specific `.seg`.
12325
+ */
12326
+ #leafSegment() {
12327
+ const segments = this.#getSegments();
12328
+ if (segments.length > 0) {
12329
+ const index = segments.length - 1;
12330
+ return {
12331
+ cumPath: this.#getCumulativePath(index, false),
12332
+ fullPathSegments: this.#resolveFullPathSegments(index, false)
12333
+ };
12334
+ }
12335
+ const rootSegments = this.#getRootSegments();
12336
+ if (rootSegments.length > 0) {
12337
+ const index = rootSegments.length - 1;
12338
+ return {
12339
+ cumPath: this.#getCumulativePath(index, true),
12340
+ fullPathSegments: this.#resolveFullPathSegments(index, true)
12341
+ };
12342
+ }
12343
+ }
12344
+ /**
12345
+ * Is `event` already being handled by a `.seg`'s own drag handlers? Virtual
12346
+ * (hint) segments don't have drag handlers of their own, so a drag released
12347
+ * over one falls through to the host-level fallback (the real leaf).
12348
+ */
12349
+ #eventTargetsSegment(event) {
12350
+ return Boolean(event.target?.closest?.(`.seg:not(.seg-virtual)`));
12351
+ }
12352
+ #onHostDragOver(event) {
12353
+ if (!this.parseExternalDrop || !event.dataTransfer) return;
12354
+ if (this.#eventTargetsSegment(event)) return;
12355
+ const leaf = this.#leafSegment();
12356
+ if (!leaf) return;
12357
+ event.preventDefault();
12358
+ if (this._dropCumPath !== leaf.cumPath) this._dropCumPath = leaf.cumPath;
12359
+ }
12360
+ #onHostDragLeave(event) {
12361
+ if (!this.parseExternalDrop || this.#eventTargetsSegment(event)) return;
12362
+ const leaf = this.#leafSegment();
12363
+ if (leaf && this._dropCumPath === leaf.cumPath) this._dropCumPath = null;
12364
+ }
12365
+ #onHostDrop(event) {
12366
+ if (!this.parseExternalDrop || this.#eventTargetsSegment(event)) return;
12367
+ const leaf = this.#leafSegment();
12368
+ if (!leaf) return;
12369
+ this.#onSegDrop(leaf.fullPathSegments, event);
12370
+ }
12280
12371
  #onLabelClick(index, event) {
12281
12372
  if (this.#clicksSuppressed()) return;
12282
12373
  event.preventDefault();
12283
12374
  event.stopPropagation();
12284
12375
  this.#closeDropdown();
12285
- const truncatedSegments = this.#getSegments().slice(0, index + 1);
12376
+ const segments = this.#getSegments();
12377
+ const truncatedSegments = segments.slice(0, index + 1);
12378
+ this.#recordHintOnShorten(segments, truncatedSegments);
12286
12379
  const hadLeading = this.path.startsWith(this.separator);
12287
12380
  const newPath = truncatedSegments.join(this.separator);
12288
12381
  this.path = hadLeading ? this.separator + newPath : newPath;
@@ -12300,6 +12393,31 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12300
12393
  composed: true
12301
12394
  }));
12302
12395
  }
12396
+ #onVirtualSegmentClick(hintIndex, event) {
12397
+ if (this.#clicksSuppressed()) return;
12398
+ event.preventDefault();
12399
+ event.stopPropagation();
12400
+ this.#closeDropdown();
12401
+ const hint = this._hintSegments;
12402
+ if (!hint) return;
12403
+ const truncatedSegments = hint.slice(0, hintIndex + 1);
12404
+ const hadLeading = this.path.startsWith(this.separator);
12405
+ const newPath = truncatedSegments.join(this.separator);
12406
+ this.path = hadLeading ? this.separator + newPath : newPath;
12407
+ this._cachedSiblings = /* @__PURE__ */ new Map();
12408
+ this._leafSegmentIndex = null;
12409
+ const parentPath = truncatedSegments.slice(0, -1).join(this.separator);
12410
+ this.dispatchEvent(new CustomEvent(`navigate`, {
12411
+ detail: {
12412
+ index: hintIndex,
12413
+ label: truncatedSegments[hintIndex],
12414
+ parentPath,
12415
+ fullPath: newPath
12416
+ },
12417
+ bubbles: true,
12418
+ composed: true
12419
+ }));
12420
+ }
12303
12421
  #selectSibling(index, segment) {
12304
12422
  const cumPath = this._openCumPath;
12305
12423
  this.#closeDropdown();
@@ -12375,7 +12493,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12375
12493
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12376
12494
  <label title=${label}
12377
12495
  part="label"
12378
- ?draggable=${draggable}
12496
+ draggable=${draggable ? `true` : `false`}
12379
12497
  @click=${(e) => this.#onLabelClick(index, e)}
12380
12498
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12381
12499
  @dragend=${() => this.#onLabelDragEnd()}>
@@ -12392,6 +12510,26 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12392
12510
  </div>
12393
12511
  ` : nothing}
12394
12512
  </div>
12513
+ `;
12514
+ }
12515
+ /**
12516
+ * A translucent, click-to-restore segment beyond the current path, shown
12517
+ * while `hintPrevious` is enabled and the current path is a prefix of the
12518
+ * deepest path visited. Doesn't participate in drag'n'drop and isn't
12519
+ * keyboard-focusable.
12520
+ */
12521
+ #renderVirtualSegment(label, hintIndex) {
12522
+ const fullPathSegments = [...this.#getRootSegments(), ...(this._hintSegments ?? []).slice(0, hintIndex + 1)];
12523
+ const icon = this.#resolveSegmentIcon(fullPathSegments, fullPathSegments.length - 1);
12524
+ return html`
12525
+ <div class="seg seg-virtual" part="segment virtual">
12526
+ <label title=${label}
12527
+ part="label"
12528
+ @click=${(e) => this.#onVirtualSegmentClick(hintIndex, e)}>
12529
+ ${icon ? html`<ixfx-icon class="crumb-icon segment-icon" name=${icon}></ixfx-icon>` : nothing}
12530
+ ${label}
12531
+ </label>
12532
+ </div>
12395
12533
  `;
12396
12534
  }
12397
12535
  #renderRootSegment(label, index) {
@@ -12413,7 +12551,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12413
12551
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12414
12552
  <label title=${label}
12415
12553
  part="label"
12416
- ?draggable=${draggable}
12554
+ draggable=${draggable ? `true` : `false`}
12417
12555
  @click=${(e) => this.#onLabelClick(index, e)}
12418
12556
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12419
12557
  @dragend=${() => this.#onLabelDragEnd()}>${icon ? html`<ixfx-icon class="crumb-icon segment-icon" name=${icon}></ixfx-icon>` : nothing}${label}</label>
@@ -12519,6 +12657,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12519
12657
  #onHostKeyDown;
12520
12658
  render() {
12521
12659
  const segments = this.#getSegments();
12660
+ const hintSegments = this.hintPrevious ? this._hintSegments : null;
12661
+ const virtualSegments = hintSegments !== null && hintSegments.length > segments.length && this.#isSegmentPrefix(segments, hintSegments) ? hintSegments.slice(segments.length) : [];
12522
12662
  if (this._editing) {
12523
12663
  const editValue = this.path;
12524
12664
  return html`
@@ -12541,10 +12681,15 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12541
12681
  ${this.separator}
12542
12682
  </div>` : nothing;
12543
12683
  return html`
12544
- <div class="crumbs scroll-fade-x" tabindex="0" @keydown=${this.#onHostKeyDown}>
12684
+ <div class="crumbs scroll-fade-x" tabindex="0"
12685
+ @keydown=${this.#onHostKeyDown}
12686
+ @dragover=${(e) => this.#onHostDragOver(e)}
12687
+ @dragleave=${(e) => this.#onHostDragLeave(e)}
12688
+ @drop=${(e) => this.#onHostDrop(e)}>
12545
12689
  ${rootSegments.length > 0 ? rootSegments.map((label, i) => this.#renderRootSegment(label, i)) : nothing}
12546
12690
  ${leadingCaret}
12547
12691
  ${segments.map((label, i) => this.#renderSegment(label, i, i === segments.length - 1))}
12692
+ ${virtualSegments.map((label, i) => this.#renderVirtualSegment(label, segments.length + i))}
12548
12693
  <div class="trailing" @click=${this.#onTrailingClick}></div>
12549
12694
  </div>
12550
12695
  ${this._openIdx !== null ? html`
@@ -12571,6 +12716,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12571
12716
  --padding-m: var(--space-s);
12572
12717
  --hover-bg: var(--surface-5);
12573
12718
  --hover-fg: var(--surface-5-text);
12719
+ --crumb-hint-opacity: 0.35;
12720
+ --crumb-hint-hover-opacity: 0.6;
12574
12721
 
12575
12722
  display: flex;
12576
12723
  width: 100%;
@@ -12682,6 +12829,14 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12682
12829
  background: var(--crumb-drop-into-bg, color-mix(in srgb, var(--accent) 14%, transparent));
12683
12830
  }
12684
12831
 
12832
+ .seg-virtual {
12833
+ opacity: var(--crumb-hint-opacity);
12834
+ }
12835
+
12836
+ .seg-virtual:hover {
12837
+ opacity: var(--crumb-hint-hover-opacity);
12838
+ }
12839
+
12685
12840
  .crumb-icon {
12686
12841
  width: 1em;
12687
12842
  height: 1em;
@@ -12766,6 +12921,10 @@ __decorate([property({ type: Object })], CrumbPathElement.prototype, "siblingLoo
12766
12921
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentIconLookup", void 0);
12767
12922
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentDragLookup", void 0);
12768
12923
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "parseExternalDrop", void 0);
12924
+ __decorate([property({
12925
+ type: Boolean,
12926
+ attribute: `hint-previous`
12927
+ })], CrumbPathElement.prototype, "hintPrevious", void 0);
12769
12928
  __decorate([state()], CrumbPathElement.prototype, "_openIdx", void 0);
12770
12929
  __decorate([state()], CrumbPathElement.prototype, "_editing", void 0);
12771
12930
  __decorate([state()], CrumbPathElement.prototype, "_cachedSiblings", void 0);
@@ -12777,6 +12936,7 @@ __decorate([state()], CrumbPathElement.prototype, "_editStartIndex", void 0);
12777
12936
  __decorate([state()], CrumbPathElement.prototype, "_openCumPath", void 0);
12778
12937
  __decorate([state()], CrumbPathElement.prototype, "_dragCumPath", void 0);
12779
12938
  __decorate([state()], CrumbPathElement.prototype, "_dropCumPath", void 0);
12939
+ __decorate([state()], CrumbPathElement.prototype, "_hintSegments", void 0);
12780
12940
  CrumbPathElement = __decorate([safeCustomElement(`ixfx-crumb-path`)], CrumbPathElement);
12781
12941
  //#endregion
12782
12942
  //#region src/data-display/data-display.ts
@@ -13100,6 +13260,34 @@ let DataDisplayComponent = class DataDisplayComponent extends LitElement {
13100
13260
  __decorate([property({ type: Boolean })], DataDisplayComponent.prototype, "automap", void 0);
13101
13261
  DataDisplayComponent = __decorate([safeCustomElement(`ixfx-data-display`)], DataDisplayComponent);
13102
13262
  //#endregion
13263
+ //#region src/common/item-appearance.ts
13264
+ /**
13265
+ * Shared helpers for the list components' per-item appearance hooks — the
13266
+ * per-item icon colour / text colour / badge that miller-list, detail-list,
13267
+ * grid-list, data-grid and vertical-list all accept.
13268
+ *
13269
+ * Every component takes these three features in its own idiom (plain fields
13270
+ * on a `TreeItem`, a property-name lookup, an adapter function, a nominated
13271
+ * column) but they all agree on what "not supplied" means, and on never
13272
+ * emitting an empty slot for an absent value.
13273
+ */
13274
+ /**
13275
+ * Whether a per-item appearance value counts as "not supplied". `false` and
13276
+ * `''` are included so a consumer can clear a feature without rebuilding the
13277
+ * collection — e.g. `badge: flags && tagChip(flags.colour)`.
13278
+ */
13279
+ function appearanceAbsent(value) {
13280
+ return value === void 0 || value === null || value === false || value === ``;
13281
+ }
13282
+ /**
13283
+ * A usable CSS colour from a per-item value, or `undefined`. Non-strings and
13284
+ * empty strings are ignored rather than stringified, so a stray number or
13285
+ * `0` never lands in a `style` attribute.
13286
+ */
13287
+ function appearanceColor(value) {
13288
+ return typeof value === `string` && value.length > 0 ? value : void 0;
13289
+ }
13290
+ //#endregion
13103
13291
  //#region src/util/drag-ghost.ts
13104
13292
  /**
13105
13293
  * Theme-aware floating element helper.
@@ -14224,6 +14412,12 @@ __decorate([query(`.text`)], PanTextElement.prototype, "_textEl", void 0);
14224
14412
  PanTextElement = __decorate([safeCustomElement(`ixfx-pan-text`)], PanTextElement);
14225
14413
  //#endregion
14226
14414
  //#region src/data-grid/data-grid.ts
14415
+ const EMPTY_CELL_LOOK = {
14416
+ textColor: void 0,
14417
+ icon: void 0,
14418
+ iconColor: void 0,
14419
+ badge: void 0
14420
+ };
14227
14421
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
14228
14422
  function sameKeys$3(a, b) {
14229
14423
  if (a === void 0 || a.length !== b.length) return false;
@@ -14248,6 +14442,9 @@ let DataGridElement = class DataGridElement extends LitElement {
14248
14442
  this.allowGrouping = false;
14249
14443
  this.crossGroupSelection = false;
14250
14444
  this.columnMenu = `auto`;
14445
+ this.textColorColumn = ``;
14446
+ this.iconColumn = ``;
14447
+ this.badgeColumn = ``;
14251
14448
  this.itemsDraggable = false;
14252
14449
  this._columnFormatters = /* @__PURE__ */ new Map();
14253
14450
  this._resizingColKey = void 0;
@@ -14667,6 +14864,25 @@ let DataGridElement = class DataGridElement extends LitElement {
14667
14864
  }
14668
14865
  .cell.editable-text { cursor: text; }
14669
14866
  .cell input, .cell select { width: 100%; font: inherit; }
14867
+ /* Per-row icon / badge, enabled by nominating iconColumn / badgeColumn.
14868
+ The badge self-justifies to the cell's trailing edge; the value text
14869
+ keeps its own flex sizing so the cell still ellipsises. */
14870
+ .cell-icon {
14871
+ flex: none;
14872
+ display: inline-flex;
14873
+ align-items: center;
14874
+ justify-content: center;
14875
+ margin-inline-end: var(--data-grid-cell-icon-gap, 6px);
14876
+ width: var(--data-grid-cell-icon-size, 1em);
14877
+ height: var(--data-grid-cell-icon-size, 1em);
14878
+ }
14879
+ .cell-badge {
14880
+ flex: none;
14881
+ display: inline-flex;
14882
+ align-items: center;
14883
+ margin-inline-start: auto;
14884
+ padding-inline-start: var(--data-grid-badge-gap, 6px);
14885
+ }
14670
14886
  .checkbox-col { width: 28px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
14671
14887
  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; }
14672
14888
  .header-cell[drag-source] { opacity: 0.4; }
@@ -15438,6 +15654,27 @@ let DataGridElement = class DataGridElement extends LitElement {
15438
15654
  if (this.globalFormatter) return this.globalFormatter(value, col.key, row);
15439
15655
  return value;
15440
15656
  }
15657
+ /**
15658
+ * Resolve the per-row appearance for the cell at `colKey`, honouring the
15659
+ * nominated columns. Additive — `col.renderer` / `col.formatter` still own
15660
+ * the cell's value; this only decorates it. Returns all-`undefined` for
15661
+ * every cell when nothing is nominated (the common case).
15662
+ */
15663
+ _cellLook(row, colKey) {
15664
+ if (this.textColorColumn === `` && this.iconColumn === `` && this.badgeColumn === ``) return EMPTY_CELL_LOOK;
15665
+ const rec = typeof row === `object` && row !== null ? row : {};
15666
+ const textColor = colKey === this.textColorColumn ? appearanceColor(rec.textColor) : void 0;
15667
+ const icon = colKey === this.iconColumn && !appearanceAbsent(rec.icon) ? rec.icon : void 0;
15668
+ const iconColor = icon === void 0 ? void 0 : appearanceColor(rec.iconColor);
15669
+ const badge = colKey === this.badgeColumn && !appearanceAbsent(rec.badge) ? rec.badge : void 0;
15670
+ if (textColor === void 0 && icon === void 0 && badge === void 0) return EMPTY_CELL_LOOK;
15671
+ return {
15672
+ textColor,
15673
+ icon,
15674
+ iconColor,
15675
+ badge
15676
+ };
15677
+ }
15441
15678
  _handleCellSecondClick(rowKey, col) {
15442
15679
  if (this.editability !== `user`) return;
15443
15680
  const now = Date.now();
@@ -15895,7 +16132,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15895
16132
  const textEditable = this._isEditable(row, col) && this._isTextEditor(col);
15896
16133
  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));` : ``;
15897
16134
  const canPan = this.cellOverflow === `pan-text` && !col.renderer && (selected || tickled);
15898
- 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}"
16135
+ const look = this._cellLook(row, col.key);
16136
+ const cellStyle = look.textColor === void 0 ? `` : `color:${look.textColor};`;
16137
+ 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}"
15899
16138
  @click=${(e) => {
15900
16139
  this._lastClickedCell = {
15901
16140
  rowKey,
@@ -15917,7 +16156,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15917
16156
  composed: true
15918
16157
  }));
15919
16158
  }}
15920
- >${canPan ? html`<ixfx-pan-text mode="loop">${cellContent}</ixfx-pan-text>` : cellContent}</div>`;
16159
+ >${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>`;
15921
16160
  })}
15922
16161
  </div>
15923
16162
  `;
@@ -15977,6 +16216,18 @@ __decorate([property({
15977
16216
  attribute: `column-menu`
15978
16217
  })], DataGridElement.prototype, "columnMenu", void 0);
15979
16218
  __decorate([property({ attribute: false })], DataGridElement.prototype, "globalFormatter", void 0);
16219
+ __decorate([property({
16220
+ type: String,
16221
+ attribute: `text-color-column`
16222
+ })], DataGridElement.prototype, "textColorColumn", void 0);
16223
+ __decorate([property({
16224
+ type: String,
16225
+ attribute: `icon-column`
16226
+ })], DataGridElement.prototype, "iconColumn", void 0);
16227
+ __decorate([property({
16228
+ type: String,
16229
+ attribute: `badge-column`
16230
+ })], DataGridElement.prototype, "badgeColumn", void 0);
15980
16231
  __decorate([property({ attribute: false })], DataGridElement.prototype, "filterPredicate", void 0);
15981
16232
  __decorate([property({ attribute: false })], DataGridElement.prototype, "rowSearchText", void 0);
15982
16233
  __decorate([property({
@@ -16277,6 +16528,14 @@ DataTableElement = __decorate([safeCustomElement(`ixfx-data-table`)], DataTableE
16277
16528
  const ITEM_SELECTOR$3 = `:scope > li`;
16278
16529
  const SEARCH_HIGHLIGHT_KEY = `detail-list-search`;
16279
16530
  const LABEL_SPAN_CSS = `overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0`;
16531
+ /**
16532
+ * Layout for the per-item badge wrapper `#ensureBadge` adds. Applied as inline
16533
+ * `cssText` rather than shadow CSS because `::slotted()` only matches the
16534
+ * host's *direct* children — the badge lives inside a slotted `<li>`, so a
16535
+ * `::slotted()` rule could never reach it. The values still read the CSS
16536
+ * custom property, so it stays themeable from the outside.
16537
+ */
16538
+ 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)`;
16280
16539
  function pointInRect(x, y, rect) {
16281
16540
  return rect.width > 0 && rect.height > 0 && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
16282
16541
  }
@@ -16310,6 +16569,9 @@ let DetailListElement = class DetailListElement extends LitElement {
16310
16569
  this.items = [];
16311
16570
  this.displayProperty = ``;
16312
16571
  this.iconProperty = ``;
16572
+ this.iconColorProperty = ``;
16573
+ this.textColorProperty = ``;
16574
+ this.badgeProperty = ``;
16313
16575
  this.keyProperty = ``;
16314
16576
  this.itemWrap = `stop`;
16315
16577
  this.itemsDraggable = false;
@@ -16511,7 +16773,8 @@ let DetailListElement = class DetailListElement extends LitElement {
16511
16773
  };
16512
16774
  this._onWheel = (e) => {
16513
16775
  e.preventDefault();
16514
- this._listEl.scrollLeft += e.deltaY;
16776
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
16777
+ this._listEl.scrollLeft += delta;
16515
16778
  };
16516
16779
  }
16517
16780
  #itemData;
@@ -16716,7 +16979,7 @@ let DetailListElement = class DetailListElement extends LitElement {
16716
16979
  });
16717
16980
  return;
16718
16981
  }
16719
- if (changed.has(`items`) && this.provider === void 0 && this.items.length > 0) this.#rebuildFromObjects(this.items);
16982
+ 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);
16720
16983
  this.#syncFromProvider();
16721
16984
  if (changed.has(`sort`) || changed.has(`sortBy`) || changed.has(`sortComparator`)) this.#applySort();
16722
16985
  if (changed.has(`filterPredicate`)) {
@@ -16921,24 +17184,36 @@ let DetailListElement = class DetailListElement extends LitElement {
16921
17184
  const li = document.createElement(`li`);
16922
17185
  this.#itemData.set(li, item);
16923
17186
  const content = this.formatter ? this.formatter(item, index) : this.displayProperty ? this.#propertyValue(item, this.displayProperty) : item;
17187
+ const textColor = this.#colorValue(item, this.textColorProperty);
17188
+ const iconColor = this.#colorValue(item, this.iconColorProperty);
17189
+ if (iconColor !== void 0) li.setAttribute(`data-icon-color`, iconColor);
16924
17190
  if (typeof content === `string`) {
16925
- this._setLabel(li, content);
17191
+ const span = this._setLabel(li, content);
17192
+ if (textColor !== void 0) span.style.color = textColor;
16926
17193
  if (this.iconProperty) {
16927
17194
  const icon = this.#propertyValue(item, this.iconProperty);
16928
17195
  if (typeof icon === `string` && icon.length > 0) li.setAttribute(`data-icon`, icon);
16929
17196
  }
16930
- } else if (content instanceof Node) li.appendChild(content);
17197
+ } else if (content instanceof Node) {
17198
+ li.appendChild(content);
17199
+ if (textColor !== void 0) li.style.color = textColor;
17200
+ }
16931
17201
  if (this.keyProperty) {
16932
17202
  const key = this.#propertyValue(item, this.keyProperty);
16933
17203
  if (key !== void 0 && key !== null) li.setAttribute(`data-key`, String(key));
16934
17204
  }
16935
17205
  this.#ensureIcon(li);
17206
+ this.#ensureBadge(li, this.#propertyValue(item, this.badgeProperty));
16936
17207
  return li;
16937
17208
  }
16938
17209
  #propertyValue(item, prop) {
16939
17210
  if (typeof item !== `object` || item === null || !(prop in item)) return void 0;
16940
17211
  return item[prop];
16941
17212
  }
17213
+ /** A non-empty string from `item[prop]`, else `undefined`. */
17214
+ #colorValue(item, prop) {
17215
+ return prop ? appearanceColor(this.#propertyValue(item, prop)) : void 0;
17216
+ }
16942
17217
  #syncFromProvider() {
16943
17218
  if (this.provider === void 0) return;
16944
17219
  const items = this.#data.items;
@@ -17196,12 +17471,14 @@ let DetailListElement = class DetailListElement extends LitElement {
17196
17471
  if (this.interactionMode !== `checked`) return true;
17197
17472
  return !item.hasAttribute(`not-checked`);
17198
17473
  }
17474
+ /** Appends the caption span; returns it so callers can style it per item. */
17199
17475
  _setLabel(li, label) {
17200
17476
  const span = document.createElement(`span`);
17201
17477
  span.style.cssText = LABEL_SPAN_CSS;
17202
17478
  span.textContent = label;
17203
17479
  li.appendChild(span);
17204
17480
  li.setAttribute(`data-search-label`, label);
17481
+ return span;
17205
17482
  }
17206
17483
  /**
17207
17484
  * Inject a leading `<ixfx-icon>` into an item carrying `data-icon`.
@@ -17215,8 +17492,28 @@ let DetailListElement = class DetailListElement extends LitElement {
17215
17492
  icon.setAttribute(`name`, name);
17216
17493
  icon.style.marginInlineEnd = `var(--detail-list-icon-gap, 6px)`;
17217
17494
  icon.style.flexShrink = `0`;
17495
+ const color = li.getAttribute(`data-icon-color`);
17496
+ if (color) icon.style.color = color;
17218
17497
  li.prepend(icon);
17219
17498
  }
17499
+ /**
17500
+ * Append a right-justified Finder-style badge. `badge` may be a DOM `Node`
17501
+ * or anything Lit can render (typically a `TemplateResult`). Idempotent —
17502
+ * a second call replaces the existing badge.
17503
+ */
17504
+ #ensureBadge(li, badge) {
17505
+ if (appearanceAbsent(badge)) return;
17506
+ let holder = li.querySelector(`:scope > .item-badge`);
17507
+ if (!holder) {
17508
+ holder = document.createElement(`span`);
17509
+ holder.className = `item-badge`;
17510
+ holder.style.cssText = BADGE_EL_CSS;
17511
+ li.appendChild(holder);
17512
+ }
17513
+ holder.replaceChildren();
17514
+ if (badge instanceof Node) holder.appendChild(badge);
17515
+ else render(badge, holder);
17516
+ }
17220
17517
  /** Scan light-DOM children for unprocessed `data-icon` items. */
17221
17518
  #processChildren() {
17222
17519
  for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) this.#ensureIcon(el);
@@ -17490,6 +17787,10 @@ let DetailListElement = class DetailListElement extends LitElement {
17490
17787
  opacity: 0.6;
17491
17788
  }
17492
17789
 
17790
+ /* Per-item icon / badge (adapter mode) are styled inline by
17791
+ #ensureBadge — ::slotted() only reaches the host's direct children,
17792
+ and these spans live inside a slotted <li>. */
17793
+
17493
17794
  /* checked mode: the injected checkbox communicates selection — suppress bg */
17494
17795
  :host([interaction-mode="checked"]) ::slotted([data-selected]) {
17495
17796
  background: transparent;
@@ -17564,6 +17865,18 @@ __decorate([property({
17564
17865
  type: String,
17565
17866
  attribute: `icon-property`
17566
17867
  })], DetailListElement.prototype, "iconProperty", void 0);
17868
+ __decorate([property({
17869
+ type: String,
17870
+ attribute: `icon-color-property`
17871
+ })], DetailListElement.prototype, "iconColorProperty", void 0);
17872
+ __decorate([property({
17873
+ type: String,
17874
+ attribute: `text-color-property`
17875
+ })], DetailListElement.prototype, "textColorProperty", void 0);
17876
+ __decorate([property({
17877
+ type: String,
17878
+ attribute: `badge-property`
17879
+ })], DetailListElement.prototype, "badgeProperty", void 0);
17567
17880
  __decorate([property({
17568
17881
  type: String,
17569
17882
  attribute: `key-property`
@@ -23295,6 +23608,10 @@ var ElementGridListSource = class {
23295
23608
  if (eff === void 0) return void 0;
23296
23609
  return html`<slot name=${this.slotNameFor(this.#items[eff])}></slot>`;
23297
23610
  }
23611
+ iconAt(_index) {}
23612
+ iconColorAt(_index) {}
23613
+ textColorAt(_index) {}
23614
+ badgeAt(_index) {}
23298
23615
  setFilters(opts) {
23299
23616
  if (`predicate` in opts) this.#predicate = opts.predicate;
23300
23617
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23411,6 +23728,22 @@ var DataGridListSource = class {
23411
23728
  const eff = this.#effectiveIndices[index];
23412
23729
  return eff === void 0 ? void 0 : this.#adapter.render(this.#items[eff], eff);
23413
23730
  }
23731
+ iconAt(index) {
23732
+ const eff = this.#effectiveIndices[index];
23733
+ return eff === void 0 || this.#adapter.icon === void 0 ? void 0 : this.#adapter.icon(this.#items[eff], eff);
23734
+ }
23735
+ iconColorAt(index) {
23736
+ const eff = this.#effectiveIndices[index];
23737
+ return eff === void 0 ? void 0 : this.#adapter.iconColor?.(this.#items[eff], eff);
23738
+ }
23739
+ textColorAt(index) {
23740
+ const eff = this.#effectiveIndices[index];
23741
+ return eff === void 0 ? void 0 : this.#adapter.textColor?.(this.#items[eff], eff);
23742
+ }
23743
+ badgeAt(index) {
23744
+ const eff = this.#effectiveIndices[index];
23745
+ return eff === void 0 || this.#adapter.badge === void 0 ? void 0 : this.#adapter.badge(this.#items[eff], eff);
23746
+ }
23414
23747
  setFilters(opts) {
23415
23748
  if (`predicate` in opts) this.#predicate = opts.predicate;
23416
23749
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23951,6 +24284,50 @@ let GridListElement = class GridListElement extends LitElement {
23951
24284
  color: inherit;
23952
24285
  }
23953
24286
 
24287
+ /* ---- Per-item icon (data mode) ----
24288
+ Only [has-icon] switches to a column, so a cell without an icon keeps
24289
+ exactly the layout it had before. The main content stays stretched;
24290
+ the icon centres itself above it. */
24291
+ .content.has-icon {
24292
+ display: flex;
24293
+ flex-direction: column;
24294
+ min-height: 0;
24295
+ gap: var(--grid-list-icon-gap, 2px);
24296
+ }
24297
+ .cell-icon {
24298
+ flex: none;
24299
+ align-self: center;
24300
+ display: inline-flex;
24301
+ align-items: center;
24302
+ justify-content: center;
24303
+ width: var(--grid-list-icon-size, 1.25em);
24304
+ height: var(--grid-list-icon-size, 1.25em);
24305
+ }
24306
+
24307
+ /* ---- Per-item badge (data mode) ----
24308
+ Finder thumbnails put the tag inline, immediately before the name, so
24309
+ the caption becomes a flex row. The text is wrapped in its own
24310
+ shrinkable box so the ellipsis survives. */
24311
+ .caption.has-badge {
24312
+ display: flex;
24313
+ align-items: center;
24314
+ gap: var(--grid-list-badge-gap, 3px);
24315
+ text-align: initial;
24316
+ }
24317
+ :host([caption-align="left"]) .caption.has-badge { justify-content: flex-start; }
24318
+ :host([caption-align="right"]) .caption.has-badge { justify-content: flex-end; }
24319
+ .caption.has-badge .caption-text {
24320
+ flex: 0 1 auto;
24321
+ min-width: 0;
24322
+ overflow: hidden;
24323
+ text-overflow: ellipsis;
24324
+ }
24325
+ .caption .badge {
24326
+ flex: none;
24327
+ display: inline-flex;
24328
+ align-items: center;
24329
+ }
24330
+
23954
24331
  /* ---- Box modes (hidden / under / overlaid): the cell is one box with a
23955
24332
  single selection outline ---- */
23956
24333
  :host(:not([caption-style="separate"])) .cell {
@@ -24959,6 +25336,8 @@ let GridListElement = class GridListElement extends LitElement {
24959
25336
  const x = win.horizontal ? index * win.colStride : columnOf(index, win.cols) * win.colStride;
24960
25337
  const y = win.horizontal ? Math.max(0, (this._viewportHeight - cellHeight) / 2) : rowOf(index, win.cols) * win.rowStride;
24961
25338
  const captionCss = separate ? `height:${Math.max(0, this.captionHeight - 4)}px` : style === `under` ? `height:${this.captionHeight}px` : ``;
25339
+ const rawIcon = this.#source.iconAt(index);
25340
+ const icon = appearanceAbsent(rawIcon) ? void 0 : rawIcon;
24962
25341
  return html`
24963
25342
  <div
24964
25343
  class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${marquee ? ` marquee` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
@@ -24976,8 +25355,11 @@ let GridListElement = class GridListElement extends LitElement {
24976
25355
  @dblclick=${() => this.#onCellActivate(key)}
24977
25356
  @pointerenter=${() => this.#handleCellPointerEnter(key)}
24978
25357
  >
24979
- <div class="content" part="content">${this.#source.renderCellContent(index)}</div>
24980
- ${showCaption ? this.#renderCaption(caption, captionCss, tickled) : ``}
25358
+ <div class="content${icon === void 0 ? `` : ` has-icon`}" part="content">
25359
+ ${icon === void 0 ? nothing : html`<span class="cell-icon" part="icon" style=${this.#colorStyle(this.#source.iconColorAt(index))}>${icon}</span>`}
25360
+ ${this.#source.renderCellContent(index)}
25361
+ </div>
25362
+ ${showCaption ? this.#renderCaption(caption, captionCss, tickled, this.#source.badgeAt(index), this.#source.textColorAt(index)) : ``}
24981
25363
  </div>
24982
25364
  `;
24983
25365
  }
@@ -24985,10 +25367,25 @@ let GridListElement = class GridListElement extends LitElement {
24985
25367
  * A tickled cell swaps its plain label for an `ixfx-pan-text` in loop mode
24986
25368
  * so a truncated label scrolls instead of staying an ellipsis. Unticked
24987
25369
  * cells keep the cheap plain-text caption.
25370
+ *
25371
+ * With no `badge` and no `textColor` the DOM is exactly as it was — the
25372
+ * flex row (and its extra `<span>` around the text) only appears when a
25373
+ * badge needs to sit inline ahead of the name.
24988
25374
  */
24989
- #renderCaption(caption, captionCss, tickled) {
24990
- if (tickled) return html`<div class="caption" part="caption" style=${captionCss} title=${caption}><ixfx-pan-text mode="loop">${caption}</ixfx-pan-text></div>`;
24991
- return html`<div class="caption" part="caption" style=${captionCss} title=${caption}>${caption}</div>`;
25375
+ #renderCaption(caption, captionCss, tickled, badge, textColor) {
25376
+ const color = appearanceColor(textColor);
25377
+ const style = color === void 0 ? captionCss : `${captionCss}${captionCss.length > 0 ? `;` : ``}color:${color};`;
25378
+ if (appearanceAbsent(badge)) {
25379
+ if (tickled) return html`<div class="caption" part="caption" style=${style} title=${caption}><ixfx-pan-text mode="loop">${caption}</ixfx-pan-text></div>`;
25380
+ return html`<div class="caption" part="caption" style=${style} title=${caption}>${caption}</div>`;
25381
+ }
25382
+ const label = tickled ? html`<ixfx-pan-text mode="loop">${caption}</ixfx-pan-text>` : html`<span class="caption-text">${caption}</span>`;
25383
+ return html`<div class="caption has-badge" part="caption" style=${style} title=${caption}><span class="badge" part="badge">${badge}</span>${label}</div>`;
25384
+ }
25385
+ /** `color:<c>;` for a non-empty colour, else `nothing` (attribute removed). */
25386
+ #colorStyle(color) {
25387
+ const c = appearanceColor(color);
25388
+ return c === void 0 ? nothing : `color:${c};`;
24992
25389
  }
24993
25390
  /** Stop the active caption pan, if any, and forget it. */
24994
25391
  #stopActiveCaptionPan() {
@@ -28191,6 +28588,16 @@ const ITEM_SELECTOR = `li:not([data-virtual-spacer]), [data-list-item]:not([data
28191
28588
  * vertical rows are shorter relative to the viewport, so 3.
28192
28589
  */
28193
28590
  const VIRTUAL_OVERSCAN = 3;
28591
+ /**
28592
+ * Layout for the per-item icon / badge wrappers `#decorateAdapterItem` adds.
28593
+ *
28594
+ * Applied as inline `cssText` rather than shadow CSS because `::slotted()` only
28595
+ * matches the host's *direct* children — these spans live inside a slotted
28596
+ * `<li>`, so a `::slotted()` rule could never reach them. The values still read
28597
+ * the CSS custom properties, so they stay themeable from the outside.
28598
+ */
28599
+ 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)`;
28600
+ 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)`;
28194
28601
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
28195
28602
  function sameKeys(a, b) {
28196
28603
  if (a === void 0 || a.length !== b.length) return false;
@@ -28600,7 +29007,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28600
29007
  }
28601
29008
  this.#syncSearchCommands();
28602
29009
  }
28603
- if ((changed.has(`items`) || changed.has(`formatter`) || changed.has(`keyProperty`)) && this.items !== void 0) {
29010
+ 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) {
28604
29011
  if (this.#suspendAdapterRebuild) this.#suspendAdapterRebuild = false;
28605
29012
  else if (this.#virtualActive()) this.#rebuildAdapterVirtual();
28606
29013
  else this.#rebuildFromAdapter();
@@ -28870,7 +29277,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28870
29277
  const fresh = this.formatter ? this.formatter(data, index) : data;
28871
29278
  if (el instanceof HTMLLIElement) {
28872
29279
  for (const child of [...el.childNodes]) child.remove();
28873
- this.#fillAdapterItem(el, fresh, data);
29280
+ this.#fillAdapterItem(el, fresh, data, index);
28874
29281
  if (this.interactionMode === `checked`) this._syncCheckboxes();
28875
29282
  this._syncItemStates();
28876
29283
  } else if (fresh instanceof Element && this.#isRowElement(fresh)) {
@@ -28925,17 +29332,47 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28925
29332
  return content;
28926
29333
  }
28927
29334
  const li = document.createElement(`li`);
28928
- this.#fillAdapterItem(li, content, item);
29335
+ this.#fillAdapterItem(li, content, item, index);
28929
29336
  return li;
28930
29337
  }
28931
- #fillAdapterItem(li, content, item) {
29338
+ #fillAdapterItem(li, content, item, index) {
28932
29339
  if (typeof content === `string`) li.textContent = content;
28933
29340
  else if (content instanceof Node) li.appendChild(content);
28934
29341
  else if (this.formatter && content !== void 0 && content !== null) render(content, li);
29342
+ this.#decorateAdapterItem(li, item, index);
28935
29343
  this.#stampAdapterKey(li, item);
28936
29344
  this.#itemData.set(li, item);
28937
29345
  }
28938
29346
  /**
29347
+ * Apply the per-item icon / text colour / badge to a built row. Split out
29348
+ * from {@link #fillAdapterItem} so the parts stay readable.
29349
+ */
29350
+ #decorateAdapterItem(li, item, index) {
29351
+ const textColor = appearanceColor(this.textColor?.(item, index));
29352
+ if (textColor !== void 0) li.style.color = textColor;
29353
+ else li.style.removeProperty(`color`);
29354
+ const icon = this.icon?.(item, index);
29355
+ if (!appearanceAbsent(icon)) {
29356
+ const holder = document.createElement(`span`);
29357
+ holder.className = `item-icon`;
29358
+ holder.style.cssText = ITEM_ICON_EL_CSS;
29359
+ const iconColor = appearanceColor(this.iconColor?.(item, index));
29360
+ if (iconColor !== void 0) holder.style.color = iconColor;
29361
+ if (icon instanceof Node) holder.appendChild(icon);
29362
+ else render(icon, holder);
29363
+ li.prepend(holder);
29364
+ }
29365
+ const badge = this.badge?.(item, index);
29366
+ if (!appearanceAbsent(badge)) {
29367
+ const holder = document.createElement(`span`);
29368
+ holder.className = `item-badge`;
29369
+ holder.style.cssText = ITEM_BADGE_EL_CSS;
29370
+ if (badge instanceof Node) holder.appendChild(badge);
29371
+ else render(badge, holder);
29372
+ li.appendChild(holder);
29373
+ }
29374
+ }
29375
+ /**
28939
29376
  * Whether a formatter-produced element is a whole row (used directly)
28940
29377
  * rather than row content (rendered into a `<li>`): an `<li>`, already
28941
29378
  * marked `[data-list-item]`, or a custom element.
@@ -29457,6 +29894,10 @@ let VerticalListElement = class VerticalListElement extends LitElement {
29457
29894
  outline: 1px solid transparent;
29458
29895
  }
29459
29896
 
29897
+ /* Per-item icon / badge (adapter mode) are styled inline by
29898
+ #decorateAdapterItem — ::slotted() only reaches the host's direct
29899
+ children, and these spans live inside a slotted <li>. */
29900
+
29460
29901
  /* Custom shadow-DOM components handle their own internal layout/padding.
29461
29902
  Wrapped in :where() so the selector stays at zero specificity and a
29462
29903
  consumer's own :host rule (e.g. display: flex) wins without
@@ -29603,6 +30044,10 @@ __decorate([property({
29603
30044
  attribute: `key-property`
29604
30045
  })], VerticalListElement.prototype, "keyProperty", void 0);
29605
30046
  __decorate([property({ attribute: false })], VerticalListElement.prototype, "formatter", void 0);
30047
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "icon", void 0);
30048
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "iconColor", void 0);
30049
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "textColor", void 0);
30050
+ __decorate([property({ attribute: false })], VerticalListElement.prototype, "badge", void 0);
29606
30051
  __decorate([property({
29607
30052
  type: Boolean,
29608
30053
  attribute: `virtualize`,
@@ -30878,6 +31323,90 @@ __decorate([state()], IxfxHexEditorElement.prototype, "_containerH", void 0);
30878
31323
  __decorate([state()], IxfxHexEditorElement.prototype, "_shadeMap", void 0);
30879
31324
  __decorate([state()], IxfxHexEditorElement.prototype, "_focusSide", void 0);
30880
31325
  IxfxHexEditorElement = __decorate([safeCustomElement(`ixfx-hex-editor`)], IxfxHexEditorElement);
31326
+ /**
31327
+ * Whether `n` deserves a preview: it has children and at least one of them
31328
+ * isn't legible right now — laid out without a label, or not laid out at
31329
+ * all (outside the `maxRadius` window).
31330
+ */
31331
+ function needsChildPreview(n, visible, labelIds) {
31332
+ const children = n.node.children ?? [];
31333
+ if (children.length === 0) return false;
31334
+ const shown = visible.filter((v) => v.parentId === n.id);
31335
+ return shown.length < children.length || shown.some((v) => !labelIds.has(v.id));
31336
+ }
31337
+ /** Where node position `p` (disk units) lands on screen, in CSS px from the host's top-left. */
31338
+ function diskToHostPx(p, zoom, width, height) {
31339
+ const pxPerUnit = Math.min(width, height) / 2.1;
31340
+ return {
31341
+ x: width / 2 + (zoom.offset.x + zoom.zoom * p.x) * pxPerUnit,
31342
+ y: height / 2 + (zoom.offset.y + zoom.zoom * p.y) * pxPerUnit
31343
+ };
31344
+ }
31345
+ function renderChildPreview(preview, handlers) {
31346
+ if (!preview) return nothing;
31347
+ const children = preview.node.children ?? [];
31348
+ const listed = children.slice(0, 12);
31349
+ const more = children.length - listed.length;
31350
+ return html`
31351
+ <div
31352
+ class="child-preview ${preview.above ? `above` : `below`}"
31353
+ part="child-preview"
31354
+ role="dialog"
31355
+ aria-label=${`Children of ${preview.node.item.label}`}
31356
+ style="left: ${preview.anchor.x}px; top: ${preview.anchor.y}px"
31357
+ @pointerenter=${handlers.onPointerEnter}
31358
+ @pointerleave=${handlers.onPointerLeave}
31359
+ @pointerdown=${(e) => e.stopPropagation()}
31360
+ >
31361
+ <div class="child-preview-title">${preview.node.item.label}</div>
31362
+ <ul>
31363
+ ${listed.map((child) => html`<li>
31364
+ <button type="button" tabindex="-1" @click=${() => handlers.onPick(child)}>${child.item.label}</button>
31365
+ </li>`)}
31366
+ </ul>
31367
+ ${more > 0 ? html`<div class="child-preview-more">+${more} more</div>` : nothing}
31368
+ </div>
31369
+ `;
31370
+ }
31371
+ /**
31372
+ * Show/hide timing for the preview: shows at once for a qualifying node;
31373
+ * otherwise hides after a short grace period, unless the pointer is over the
31374
+ * popover itself — so it can travel from the node onto the list.
31375
+ */
31376
+ function createChildPreviewTiming(host) {
31377
+ let hovered = false;
31378
+ let timer;
31379
+ const update = (id) => {
31380
+ clearTimeout(timer);
31381
+ const candidate = host.candidate(id);
31382
+ if (candidate !== void 0) {
31383
+ host.setPreviewId(candidate);
31384
+ return;
31385
+ }
31386
+ timer = setTimeout(() => {
31387
+ if (!hovered) host.setPreviewId(void 0);
31388
+ }, 250);
31389
+ };
31390
+ return {
31391
+ update,
31392
+ pointerEnter() {
31393
+ hovered = true;
31394
+ clearTimeout(timer);
31395
+ },
31396
+ pointerLeave(tickledId) {
31397
+ hovered = false;
31398
+ update(tickledId);
31399
+ },
31400
+ hide() {
31401
+ hovered = false;
31402
+ clearTimeout(timer);
31403
+ host.setPreviewId(void 0);
31404
+ },
31405
+ dispose() {
31406
+ clearTimeout(timer);
31407
+ }
31408
+ };
31409
+ }
30881
31410
  //#endregion
30882
31411
  //#region src/hyperbolic-tree/poincare-math.ts
30883
31412
  const EPSILON = 1e-9;
@@ -30982,8 +31511,625 @@ function geodesicInterpolate(a, b, t) {
30982
31511
  if (localNorm < EPSILON) return a;
30983
31512
  return mobiusFromOrigin(complexScale(complexScale(bLocal, 1 / localNorm), euclideanRadiusForHyperbolicDistance(hyperbolicDistanceForEuclideanRadius(localNorm) * t)), a);
30984
31513
  }
31514
+ const identityIsometry = {
31515
+ a: {
31516
+ x: 1,
31517
+ y: 0
31518
+ },
31519
+ b: {
31520
+ x: 0,
31521
+ y: 0
31522
+ }
31523
+ };
31524
+ /** Apply `m` to `z`. */
31525
+ function applyIsometry(m, z) {
31526
+ return clampToDisk(complexDiv(complexAdd(complexMul(m.a, z), m.b), complexAdd(complexMul(complexConjugate(m.b), z), complexConjugate(m.a))));
31527
+ }
31528
+ /** `outer ∘ inner` — apply `inner` first, then `outer`. */
31529
+ function composeIsometry(outer, inner) {
31530
+ const a = complexAdd(complexMul(outer.a, inner.a), complexMul(outer.b, complexConjugate(inner.b)));
31531
+ const b = complexAdd(complexMul(outer.a, inner.b), complexMul(outer.b, complexConjugate(inner.a)));
31532
+ const det = Math.sqrt(Math.max(EPSILON, a.x * a.x + a.y * a.y - b.x * b.x - b.y * b.y));
31533
+ return {
31534
+ a: complexScale(a, 1 / det),
31535
+ b: complexScale(b, 1 / det)
31536
+ };
31537
+ }
31538
+ function invertIsometry(m) {
31539
+ return {
31540
+ a: complexConjugate(m.a),
31541
+ b: complexScale(m.b, -1)
31542
+ };
31543
+ }
31544
+ /** Pure translation carrying the origin to `p` (same map as {@link mobiusFromOrigin}). */
31545
+ function translationFromOrigin(p) {
31546
+ const scale = 1 / Math.sqrt(Math.max(EPSILON, 1 - p.x * p.x - p.y * p.y));
31547
+ return {
31548
+ a: {
31549
+ x: scale,
31550
+ y: 0
31551
+ },
31552
+ b: complexScale(p, scale)
31553
+ };
31554
+ }
31555
+ /** `z ↦ e^{iθ}·z`. */
31556
+ function rotationIsometry(theta) {
31557
+ return {
31558
+ a: {
31559
+ x: Math.cos(theta / 2),
31560
+ y: Math.sin(theta / 2)
31561
+ },
31562
+ b: {
31563
+ x: 0,
31564
+ y: 0
31565
+ }
31566
+ };
31567
+ }
31568
+ /**
31569
+ * The view map that sends canonical point `center` to the origin, then
31570
+ * rotates by `rotation` radians: `z ↦ e^{i·rotation}·mobiusToOrigin(z, center)`.
31571
+ */
31572
+ function viewIsometry(center, rotation = 0) {
31573
+ return composeIsometry(rotationIsometry(rotation), invertIsometry(translationFromOrigin(center)));
31574
+ }
31575
+ /** Decompose `m` into the `{ center, rotation }` accepted by {@link viewIsometry}. */
31576
+ function decomposeViewIsometry(m) {
31577
+ return {
31578
+ center: applyIsometry(invertIsometry(m), {
31579
+ x: 0,
31580
+ y: 0
31581
+ }),
31582
+ rotation: 2 * Math.atan2(m.a.y, m.a.x)
31583
+ };
31584
+ }
31585
+ function geodesicSegment(p, q) {
31586
+ const det = p.x * q.y - p.y * q.x;
31587
+ const chord = Math.hypot(q.x - p.x, q.y - p.y);
31588
+ if (Math.abs(det) < 1e-6 * Math.max(chord, EPSILON)) return { kind: `line` };
31589
+ const rp = (p.x * p.x + p.y * p.y + 1) / 2;
31590
+ const rq = (q.x * q.x + q.y * q.y + 1) / 2;
31591
+ const center = {
31592
+ x: (rp * q.y - rq * p.y) / det,
31593
+ y: (p.x * rq - q.x * rp) / det
31594
+ };
31595
+ return {
31596
+ kind: `arc`,
31597
+ center,
31598
+ radius: Math.sqrt(Math.max(0, center.x * center.x + center.y * center.y - 1)),
31599
+ sweep: (p.x - center.x) * (q.y - center.y) - (p.y - center.y) * (q.x - center.x) > 0 ? 1 : 0
31600
+ };
31601
+ }
31602
+ /** SVG path `d` for the geodesic from `p` to `q`, with coordinates multiplied by `scale`. */
31603
+ function geodesicPath(p, q, scale = 1) {
31604
+ const fmt = (n) => (n * scale).toFixed(2);
31605
+ const segment = geodesicSegment(p, q);
31606
+ const start = `M${fmt(p.x)},${fmt(p.y)}`;
31607
+ if (segment.kind === `line` || segment.radius * scale > 1e6) return `${start} L${fmt(q.x)},${fmt(q.y)}`;
31608
+ return `${start} A${fmt(segment.radius)},${fmt(segment.radius)} 0 0 ${segment.sweep} ${fmt(q.x)},${fmt(q.y)}`;
31609
+ }
31610
+ //#endregion
31611
+ //#region src/hyperbolic-tree/view-motion.ts
31612
+ /** Wrap an angle into `(-π, π]`. */
31613
+ function wrapAngle(theta) {
31614
+ return Math.atan2(Math.sin(theta), Math.cos(theta));
31615
+ }
31616
+ function easeInOutCubic(t) {
31617
+ return t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
31618
+ }
31619
+ function prefersReducedMotion$1() {
31620
+ return typeof matchMedia === `function` && matchMedia(`(prefers-reduced-motion: reduce)`).matches;
31621
+ }
31622
+ /**
31623
+ * The view after dragging from `start` to `current` (both disk
31624
+ * coordinates), starting from `baseView`. Applies the hyperbolic translation
31625
+ * along the geodesic carrying the grabbed point to the pointer — a true
31626
+ * isometry, so the grabbed point tracks the pointer exactly and the tree
31627
+ * never distorts. (Adding a Euclidean offset to the canonical center
31628
+ * instead is not an isometry: it lurches badly near the disk boundary.)
31629
+ */
31630
+ function panView(baseView, start, current) {
31631
+ const toStart = translationFromOrigin(start);
31632
+ return composeIsometry(composeIsometry(toStart, composeIsometry(translationFromOrigin(mobiusToOrigin(current, start)), invertIsometry(toStart))), baseView);
31633
+ }
31634
+ /**
31635
+ * A focus-change animation from view `from` — the old view, re-expressed in
31636
+ * the *new* focus's local frame, so the new focus currently sits somewhere
31637
+ * off-center — to a settled view with the new focus at the center. The
31638
+ * center travels along the hyperbolic geodesic; the rotation is held, so the
31639
+ * tree glides into place without spinning, and the settled view keeps
31640
+ * whatever orientation it arrived with (`settledRotation`).
31641
+ */
31642
+ function focusTransition(from) {
31643
+ const { center, rotation } = decomposeViewIsometry(from);
31644
+ const settledRotation = wrapAngle(rotation);
31645
+ const origin = {
31646
+ x: 0,
31647
+ y: 0
31648
+ };
31649
+ return {
31650
+ settledRotation,
31651
+ viewAt: (t) => {
31652
+ const eased = easeInOutCubic(Math.min(1, Math.max(0, t)));
31653
+ return viewIsometry(geodesicInterpolate(center, origin, eased), settledRotation);
31654
+ }
31655
+ };
31656
+ }
31657
+ //#endregion
31658
+ //#region src/hyperbolic-tree/gestures.ts
31659
+ const MIN_ZOOM = .5;
31660
+ /**
31661
+ * Keep the view center over the disk (so you can't zoom off into empty
31662
+ * space), and re-center entirely once the whole disk fits again.
31663
+ */
31664
+ function clampZoomState(state) {
31665
+ const zoom = Math.min(8, Math.max(MIN_ZOOM, state.zoom));
31666
+ if (zoom <= 1) return {
31667
+ zoom,
31668
+ offset: {
31669
+ x: 0,
31670
+ y: 0
31671
+ }
31672
+ };
31673
+ const limit = zoom - 1;
31674
+ const length = Math.hypot(state.offset.x, state.offset.y);
31675
+ return {
31676
+ zoom,
31677
+ offset: length > limit ? {
31678
+ x: state.offset.x * limit / length,
31679
+ y: state.offset.y * limit / length
31680
+ } : state.offset
31681
+ };
31682
+ }
31683
+ /**
31684
+ * Zoom by `factor` keeping the disk point under `screenPoint` fixed on
31685
+ * screen — the "zoom where I'm pointing" behaviour of maps and image viewers.
31686
+ */
31687
+ function zoomAt(state, screenPoint, factor) {
31688
+ const zoom = Math.min(8, Math.max(MIN_ZOOM, state.zoom * factor));
31689
+ const disk = screenToDisk(screenPoint, state);
31690
+ return clampZoomState({
31691
+ zoom,
31692
+ offset: {
31693
+ x: screenPoint.x - zoom * disk.x,
31694
+ y: screenPoint.y - zoom * disk.y
31695
+ }
31696
+ });
31697
+ }
31698
+ /**
31699
+ * A client-space position in screen units: disk radii from the SVG's center,
31700
+ * before zoom. The disk fills the SVG's smaller dimension, with the
31701
+ * viewBox's 5% margin.
31702
+ */
31703
+ function clientToScreen(rect, clientX, clientY) {
31704
+ const unitsPerPixel = 2.1 / (Math.min(rect.width, rect.height) || 1);
31705
+ return {
31706
+ x: (clientX - (rect.left + rect.width / 2)) * unitsPerPixel,
31707
+ y: (clientY - (rect.top + rect.height / 2)) * unitsPerPixel
31708
+ };
31709
+ }
31710
+ function screenToDisk(p, state) {
31711
+ const zoom = state.zoom || 1;
31712
+ return {
31713
+ x: (p.x - state.offset.x) / zoom,
31714
+ y: (p.y - state.offset.y) / zoom
31715
+ };
31716
+ }
31717
+ /** Is `event` on empty disk space (the SVG itself or the boundary circle), rather than a node? */
31718
+ function isBackground(event) {
31719
+ const target = event.target;
31720
+ return target === event.currentTarget || target?.classList.contains(`disk-boundary`) === true;
31721
+ }
31722
+ function createGestureHandlers(host) {
31723
+ const pointers = /* @__PURE__ */ new Map();
31724
+ let pan;
31725
+ let pinch;
31726
+ const screenOf = (event) => {
31727
+ const rect = host.svgRect();
31728
+ return rect ? clientToScreen(rect, event.clientX, event.clientY) : void 0;
31729
+ };
31730
+ const diskOf = (screen) => clampToDisk(screenToDisk(screen, host.zoomState()), .98);
31731
+ const midpoint = (a, b) => ({
31732
+ x: (a.x + b.x) / 2,
31733
+ y: (a.y + b.y) / 2
31734
+ });
31735
+ const startPan = (pointerId, screen) => {
31736
+ pan = {
31737
+ pointerId,
31738
+ start: diskOf(screen),
31739
+ baseView: host.takeView()
31740
+ };
31741
+ };
31742
+ const startPinch = () => {
31743
+ const [[idA, a], [idB, b]] = [...pointers.entries()];
31744
+ pan = void 0;
31745
+ const { zoom } = host.zoomState();
31746
+ pinch = {
31747
+ ids: [idA, idB],
31748
+ startDistance: Math.max(1e-6, Math.hypot(a.x - b.x, a.y - b.y)),
31749
+ startZoom: zoom,
31750
+ anchor: screenToDisk(midpoint(a, b), host.zoomState())
31751
+ };
31752
+ };
31753
+ return {
31754
+ onPointerDown(event) {
31755
+ if (!isBackground(event)) return;
31756
+ host.focusTree();
31757
+ const screen = screenOf(event);
31758
+ if (!screen) return;
31759
+ event.currentTarget.setPointerCapture?.(event.pointerId);
31760
+ pointers.set(event.pointerId, screen);
31761
+ if (pointers.size === 2 && host.zoomGesturesEnabled()) startPinch();
31762
+ else if (pointers.size === 1) startPan(event.pointerId, screen);
31763
+ },
31764
+ onPointerMove(event) {
31765
+ if (!pointers.has(event.pointerId)) return;
31766
+ const screen = screenOf(event);
31767
+ if (!screen) return;
31768
+ pointers.set(event.pointerId, screen);
31769
+ if (pinch) {
31770
+ const a = pointers.get(pinch.ids[0]);
31771
+ const b = pointers.get(pinch.ids[1]);
31772
+ if (!a || !b) return;
31773
+ const zoom = pinch.startZoom * Math.hypot(a.x - b.x, a.y - b.y) / pinch.startDistance;
31774
+ const mid = midpoint(a, b);
31775
+ const clampedZoom = Math.min(8, Math.max(MIN_ZOOM, zoom));
31776
+ host.setZoomState(clampZoomState({
31777
+ zoom: clampedZoom,
31778
+ offset: {
31779
+ x: mid.x - clampedZoom * pinch.anchor.x,
31780
+ y: mid.y - clampedZoom * pinch.anchor.y
31781
+ }
31782
+ }));
31783
+ } else if (pan && pan.pointerId === event.pointerId) host.setView(panView(pan.baseView, pan.start, diskOf(screen)));
31784
+ },
31785
+ onPointerUp(event) {
31786
+ if (!pointers.delete(event.pointerId)) return;
31787
+ if (pinch) {
31788
+ pinch = void 0;
31789
+ const [remaining] = [...pointers.entries()];
31790
+ if (remaining) startPan(remaining[0], remaining[1]);
31791
+ } else if (pan?.pointerId === event.pointerId) pan = void 0;
31792
+ },
31793
+ /**
31794
+ * Ctrl/⌘-wheel — which is also what a trackpad pinch produces — always
31795
+ * zooms while the pointer is over the tree. A plain wheel only zooms
31796
+ * once the tree has keyboard focus (the library's focus-gated wheel
31797
+ * convention), so scrolling past it doesn't get hijacked.
31798
+ */
31799
+ onWheel(event) {
31800
+ const modified = event.ctrlKey || event.metaKey;
31801
+ if (!host.zoomGesturesEnabled() || !modified && !host.hasFocus()) return;
31802
+ const screen = screenOf(event);
31803
+ if (!screen) return;
31804
+ event.preventDefault();
31805
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
31806
+ const factor = Math.exp(-delta * (modified ? .01 : .002));
31807
+ host.setZoomState(zoomAt(host.zoomState(), screen, factor));
31808
+ },
31809
+ /** Forget any in-progress pan (e.g. when a focus change takes over the view). */
31810
+ cancel() {
31811
+ pan = void 0;
31812
+ pinch = void 0;
31813
+ pointers.clear();
31814
+ }
31815
+ };
31816
+ }
31817
+ //#endregion
31818
+ //#region src/hyperbolic-tree/hyperbolictree-styles.ts
31819
+ /** Shadow-DOM styles for `ixfx-hyperbolictree-list`. */
31820
+ const hyperbolicTreeStyles = css`
31821
+ :host {
31822
+ --hyperbolictree-node-color: var(--surface-4);
31823
+ --hyperbolictree-node-text: var(--surface-3-text);
31824
+ --hyperbolictree-edge-color: var(--surface-6);
31825
+ --hyperbolictree-boundary-color: var(--border-subtle);
31826
+ /* Real screen px — converted to SVG units from the element's rendered size. */
31827
+ --hyperbolictree-font-size: 13px;
31828
+ --hyperbolictree-box-color: var(--hyperbolictree-node-color);
31829
+ --hyperbolictree-box-text: var(--surface-4-text);
31830
+ --hyperbolictree-box-border: var(--surface-2);
31831
+ --hyperbolictree-branch-hue-start: 250;
31832
+ --hyperbolictree-branch-chroma: 0.13;
31833
+ --hyperbolictree-branch-lightness: 62%;
31834
+ --hyperbolictree-branch-mix: 55%;
31835
+
31836
+ display: block;
31837
+ position: relative;
31838
+ overflow: hidden;
31839
+ outline: none;
31840
+ overscroll-behavior: contain;
31841
+ }
31842
+
31843
+ /*
31844
+ * delegatesFocus means the host itself reports as focused whenever
31845
+ * the shadow DOM's focused descendant (the svg) is — without this,
31846
+ * the host also picks up the browser's own default focus ring/fill
31847
+ * sized to the whole box.
31848
+ */
31849
+ :host(:focus),
31850
+ :host(:focus-visible) {
31851
+ outline: none;
31852
+ }
31853
+
31854
+ .hyperbolictree {
31855
+ display: block;
31856
+ width: 100%;
31857
+ height: 100%;
31858
+ outline: none;
31859
+ touch-action: none;
31860
+ overscroll-behavior: contain;
31861
+ }
31862
+
31863
+ .disk-boundary {
31864
+ fill: none;
31865
+ stroke: var(--hyperbolictree-boundary-color);
31866
+ stroke-width: 1;
31867
+ }
31868
+
31869
+ .edge {
31870
+ fill: none;
31871
+ stroke: var(--hyperbolictree-edge-color);
31872
+ /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
31873
+ stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
31874
+ /* Constant on-screen width, however far the disk is zoomed. */
31875
+ vector-effect: non-scaling-stroke;
31876
+ }
31877
+
31878
+ .node {
31879
+ cursor: pointer;
31880
+ }
31881
+
31882
+ /*
31883
+ * color-by-branch: each top-level branch gets an evenly-spaced hue,
31884
+ * mixed into the normal node/box/edge colour (rather than replacing it)
31885
+ * so it stays in keeping with — and legible in — the current theme.
31886
+ * Only sets the base colour vars, so selected/tickled states still win.
31887
+ */
31888
+ :host([color-by-branch]) .node,
31889
+ :host([color-by-branch]) .edge {
31890
+ --_branch-color: oklch(
31891
+ var(--hyperbolictree-branch-lightness)
31892
+ var(--hyperbolictree-branch-chroma)
31893
+ calc(var(--hyperbolictree-branch-hue-start) + var(--branch-index) * 360 / var(--branch-count))
31894
+ );
31895
+ }
31896
+
31897
+ :host([color-by-branch]) .node {
31898
+ --_node-base: color-mix(in oklab, var(--hyperbolictree-node-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
31899
+ --_box-base: color-mix(in oklab, var(--hyperbolictree-box-color), var(--_branch-color) var(--hyperbolictree-branch-mix));
31900
+ }
31901
+
31902
+ :host([color-by-branch]) .edge {
31903
+ stroke: color-mix(in oklab, var(--hyperbolictree-edge-color), var(--_branch-color) 50%);
31904
+ }
31905
+
31906
+ .node-hit {
31907
+ fill: transparent;
31908
+ pointer-events: all;
31909
+ }
31910
+
31911
+ .node-dot {
31912
+ /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
31913
+ fill: color-mix(in oklab, var(--_node-base, var(--hyperbolictree-node-color)), var(--accent) calc(var(--node-weight, 0.5) * 50%));
31914
+ stroke: var(--surface-2);
31915
+ stroke-width: 1;
31916
+ pointer-events: none;
31917
+ }
31918
+
31919
+ .node-box {
31920
+ fill: color-mix(in oklab, var(--_box-base, var(--hyperbolictree-box-color)), var(--accent) calc(var(--node-weight, 0.5) * 25%));
31921
+ stroke: var(--hyperbolictree-box-border);
31922
+ stroke-width: 1.5;
31923
+ /* Keep the border crisp however far the box has been scaled down. */
31924
+ vector-effect: non-scaling-stroke;
31925
+ }
31926
+
31927
+ /* Text-less stand-in for a box too small, or too crowded, to label. */
31928
+ .node-box.marker {
31929
+ stroke-width: 1;
31930
+ }
31931
+
31932
+ /* Pure user-space font-size; set per render from --hyperbolictree-font-size. */
31933
+ .hyperbolictree-label {
31934
+ fill: var(--hyperbolictree-node-text);
31935
+ cursor: pointer;
31936
+ user-select: none;
31937
+ }
31938
+
31939
+ .box-label {
31940
+ fill: var(--hyperbolictree-box-text);
31941
+ pointer-events: none;
31942
+ }
31943
+
31944
+ /*
31945
+ * State fills come paired with their theme text tokens: a box label sits
31946
+ * on the state fill itself, so e.g. --item-bg-selected needs
31947
+ * --item-text-selected on top, not the resting box text colour.
31948
+ */
31949
+ .node.tickled .node-dot,
31950
+ .node.tickled .node-box {
31951
+ fill: var(--item-bg-tickled);
31952
+ }
31953
+
31954
+ .node.tickled .box-label {
31955
+ fill: var(--item-text-tickled);
31956
+ }
31957
+
31958
+ .node.selected .node-dot,
31959
+ .node.selected .node-box {
31960
+ fill: var(--item-bg-selected);
31961
+ }
31962
+
31963
+ .node.selected .box-label {
31964
+ fill: var(--item-text-selected);
31965
+ }
31966
+
31967
+ .node.selected.tickled .node-dot,
31968
+ .node.selected.tickled .node-box {
31969
+ fill: var(--item-bg-selected-tickled);
31970
+ }
31971
+
31972
+ .node.focused .node-dot,
31973
+ .node.focused .node-box {
31974
+ stroke: var(--accent);
31975
+ stroke-width: 3;
31976
+ }
31977
+
31978
+ /* A hovered label may sit over busy neighbours — give it a halo so it always reads. */
31979
+ .node.tickled .hyperbolictree-label:not(.box-label) {
31980
+ paint-order: stroke;
31981
+ stroke: var(--surface-2);
31982
+ stroke-width: 4px;
31983
+ stroke-linejoin: round;
31984
+ }
31985
+
31986
+ .node.filtered {
31987
+ opacity: 0.3;
31988
+ }
31989
+
31990
+ .node.loading .node-dot,
31991
+ .node.loading .node-box {
31992
+ opacity: 0.6;
31993
+ }
31994
+
31995
+
31996
+ /* Child preview: a clickable list of a crowded node's children. */
31997
+ .child-preview {
31998
+ position: absolute;
31999
+ z-index: 1;
32000
+ min-width: 8rem;
32001
+ max-width: min(18rem, 60%);
32002
+ max-height: 45%;
32003
+ overflow: auto;
32004
+ padding: var(--space-xs, 4px);
32005
+ background: var(--surface-3);
32006
+ color: var(--surface-3-text);
32007
+ border: var(--border-subtle);
32008
+ border-radius: var(--radius-m, 6px);
32009
+ box-shadow: var(--shadow-m);
32010
+ font-size: var(--hyperbolictree-font-size);
32011
+ transform: translate(-50%, 14px);
32012
+ }
32013
+
32014
+ .child-preview.above {
32015
+ transform: translate(-50%, calc(-100% - 14px));
32016
+ }
32017
+
32018
+ .child-preview-title {
32019
+ padding: 2px 6px 4px;
32020
+ font-weight: 600;
32021
+ overflow: hidden;
32022
+ text-overflow: ellipsis;
32023
+ white-space: nowrap;
32024
+ }
32025
+
32026
+ .child-preview ul {
32027
+ margin: 0;
32028
+ padding: 0;
32029
+ list-style: none;
32030
+ }
32031
+
32032
+ .child-preview button {
32033
+ display: block;
32034
+ width: 100%;
32035
+ padding: 2px 6px;
32036
+ border: none;
32037
+ border-radius: var(--radius-s, 4px);
32038
+ background: none;
32039
+ color: inherit;
32040
+ font: inherit;
32041
+ text-align: start;
32042
+ overflow: hidden;
32043
+ text-overflow: ellipsis;
32044
+ white-space: nowrap;
32045
+ cursor: pointer;
32046
+ }
32047
+
32048
+ .child-preview button:hover {
32049
+ background: var(--item-bg-tickled);
32050
+ color: var(--item-text-tickled);
32051
+ }
32052
+
32053
+ .child-preview-more {
32054
+ padding: 2px 6px;
32055
+ color: var(--surface-muted-text);
32056
+ }
32057
+ `;
30985
32058
  //#endregion
30986
32059
  //#region src/hyperbolic-tree/layout.ts
32060
+ const ID_SEPARATOR = `\u001F`;
32061
+ /**
32062
+ * Path-derived ids for `children` of the node with id `parentId`. `TreeItem.key`
32063
+ * is meant to be unique, but real data (e.g. a file system, where `readme.md`
32064
+ * recurs in many folders) often only has keys unique among siblings. Keying
32065
+ * the layout on `item.key` alone would silently merge those nodes — edges get
32066
+ * attached to the wrong parent and clicks focus the wrong node — so the
32067
+ * layout identifies nodes by their key path instead. Repeated keys among
32068
+ * siblings are disambiguated by occurrence count. The root's id is its key.
32069
+ */
32070
+ function hyperbolicChildIds(parentId, children) {
32071
+ const seen = /* @__PURE__ */ new Map();
32072
+ return children.map((child) => {
32073
+ const key = child.item.key;
32074
+ const count = seen.get(key) ?? 0;
32075
+ seen.set(key, count + 1);
32076
+ return `${parentId}${ID_SEPARATOR}${count === 0 ? key : `${key}#${count}`}`;
32077
+ });
32078
+ }
32079
+ /** Depth (root is 0) of the node with layout id `id`. */
32080
+ function hyperbolicIdDepth(id) {
32081
+ return id.split(ID_SEPARATOR).length - 1;
32082
+ }
32083
+ /**
32084
+ * Layout id of the root's child that `id` descends from (or is) — i.e. which
32085
+ * top-level branch it belongs to. `undefined` for the root itself.
32086
+ */
32087
+ function hyperbolicBranchId(id) {
32088
+ const parts = id.split(ID_SEPARATOR);
32089
+ return parts.length < 2 ? void 0 : `${parts[0]}${ID_SEPARATOR}${parts[1]}`;
32090
+ }
32091
+ /** Whether layout id `id` is a strict descendant of `ancestorId`. */
32092
+ function isHyperbolicDescendantId(id, ancestorId) {
32093
+ return id.startsWith(`${ancestorId}${ID_SEPARATOR}`);
32094
+ }
32095
+ /** Chain of nodes from `root` down to the node with layout id `id`, or `undefined` if absent. */
32096
+ function findHyperbolicPathById(root, id) {
32097
+ if (!root) return void 0;
32098
+ const walk = (node, nodeId) => {
32099
+ if (nodeId === id) return [node];
32100
+ if (!isHyperbolicDescendantId(id, nodeId)) return void 0;
32101
+ const children = node.children ?? [];
32102
+ const ids = hyperbolicChildIds(nodeId, children);
32103
+ for (let i = 0; i < children.length; i++) {
32104
+ const hit = walk(children[i], ids[i]);
32105
+ if (hit) return [node, ...hit];
32106
+ }
32107
+ };
32108
+ return walk(root, root.item.key);
32109
+ }
32110
+ /** Layout id of `target` (matched by object identity) within `root`, or `undefined` if absent. */
32111
+ function findHyperbolicIdOf(root, target) {
32112
+ if (!root) return void 0;
32113
+ const walk = (node, nodeId) => {
32114
+ if (node === target) return nodeId;
32115
+ const children = node.children ?? [];
32116
+ const ids = hyperbolicChildIds(nodeId, children);
32117
+ for (let i = 0; i < children.length; i++) {
32118
+ const hit = walk(children[i], ids[i]);
32119
+ if (hit !== void 0) return hit;
32120
+ }
32121
+ };
32122
+ return walk(root, root.item.key);
32123
+ }
32124
+ /** Key path (from the root's children) to the first node, depth-first, satisfying `predicate`. */
32125
+ function findHyperbolicKeyPath(nodes, predicate, prefix = []) {
32126
+ for (const node of nodes) {
32127
+ const path = [...prefix, node.item.key];
32128
+ if (predicate(node)) return path;
32129
+ const hit = node.children?.length ? findHyperbolicKeyPath(node.children, predicate, path) : void 0;
32130
+ if (hit) return hit;
32131
+ }
32132
+ }
30987
32133
  /** Depth-first search for the node with `key`. Returns `undefined` when absent. */
30988
32134
  function findHyperbolicNode(root, key) {
30989
32135
  if (!root) return void 0;
@@ -31058,21 +32204,58 @@ function stableUnitJitter(key) {
31058
32204
  * radially).
31059
32205
  */
31060
32206
  const CHAIN_JITTER_RADIANS = .22;
32207
+ /** Default minimum hyperbolic distance between adjacent siblings. See {@link childDistance}. */
32208
+ const DEFAULT_SIBLING_SPACING = .5;
32209
+ /**
32210
+ * Hyperbolic distance from parent to a child whose angular slice (in the
32211
+ * parent's frame) is `share`, such that the child sits at least `spacing`
32212
+ * from a neighbour one slice-width away: from the hyperbolic law of cosines
32213
+ * for an isosceles triangle, `sinh(d/2) = sinh(L)·sin(Δθ/2)`, solved for L.
32214
+ * Never less than `minimum` (the `curvature`), and capped so a sliver-thin
32215
+ * slice can't fling a child off to infinity.
32216
+ *
32217
+ * This is what makes panning *useful* in a crowded fan: panning is an
32218
+ * isometry — it can't change the hyperbolic distance between siblings, only
32219
+ * move them to where the disk magnifies more. If siblings are laid out
32220
+ * nearly on top of each other in hyperbolic terms, no amount of dragging
32221
+ * separates them; guaranteeing a minimum spacing means dragging a crowd
32222
+ * towards the center always fans it out.
32223
+ */
32224
+ function childDistance(share, spacing, minimum) {
32225
+ if (spacing <= 0) return minimum;
32226
+ const halfAngle = Math.min(share, Math.PI) / 2;
32227
+ const needed = Math.asinh(Math.sinh(spacing / 2) / Math.max(Math.sin(halfAngle), 1e-6));
32228
+ return Math.min(Math.max(minimum, needed), minimum + 6);
32229
+ }
31061
32230
  /**
31062
32231
  * Canonical (true-root-anchored) hyperbolic position of every currently-loaded
31063
- * node, keyed by node key. Positions never depend on the current focus —
32232
+ * node, keyed by layout id (see {@link hyperbolicChildIds}). Positions never depend on the current focus —
31064
32233
  * refocusing is purely a Möbius re-projection of these fixed coordinates, so
31065
32234
  * the tree never needs to be re-laid-out when the focus changes.
31066
32235
  *
31067
32236
  * Each node holds an angular wedge — inherited from its parent, expressed in
31068
32237
  * its *own* local frame (root starts with the full `[-π, π]`) — across which
31069
- * its children are placed at hyperbolic distance `curvature` (default `1`).
31070
- * That wedge is partitioned among the children, weighted by each child's own
32238
+ * its children are placed at hyperbolic distance of at least `curvature`
32239
+ * (default `1`) — further for a thin slice, per `siblingSpacing` (see
32240
+ * {@link childDistance}).
32241
+ * That wedge is partitioned among the children, weighted by √ of each child's own
31071
32242
  * subtree size rather than split evenly, and each child's own share is
31072
32243
  * carried into its local frame via {@link boundaryAngleInLocalFrame} before
31073
- * recursing. This is a strict partition (slices never overlap and always sum
31074
- * to the parent's own wedge), which is what guarantees sibling subtrees never
31075
- * spatially cross regardless of how deep or bushy they are.
32244
+ * recursing. This is a strict partition, and each child's wedge region
32245
+ * (bounded by geodesic rays to the same two ideal boundary points as its
32246
+ * slice) nests inside its parent's, which is what guarantees sibling
32247
+ * subtrees never spatially cross regardless of how deep or bushy they are.
32248
+ *
32249
+ * Every node's local frame is tracked as a full {@link DiskIsometry}
32250
+ * (local → canonical), built by composing its parent's frame with the
32251
+ * translation to the child. Composing Möbius translations introduces a
32252
+ * rotation (gyration); dropping it — i.e. assuming a node's frame is simply
32253
+ * `mobiusToOrigin(·, position)` — makes every wedge from depth 3 onward
32254
+ * point in a subtly wrong direction, so subtrees cross each other.
32255
+ *
32256
+ * A wedge wider than π is narrowed to π (around the same bisector) before
32257
+ * the child recurses into it: a near-full-circle wedge would otherwise fan
32258
+ * that child's own children back around towards — and across — its parent.
31076
32259
  *
31077
32260
  * `curvature` scales how far apart (in hyperbolic distance) each generation
31078
32261
  * sits. Raising it pushes each generation further out, so the Poincaré
@@ -31081,7 +32264,7 @@ const CHAIN_JITTER_RADIANS = .22;
31081
32264
  * in" on the focus. Lowering it spreads generations out more evenly,
31082
32265
  * flattening the view toward a Euclidean-feeling layout.
31083
32266
  */
31084
- function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
32267
+ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1, siblingSpacing = DEFAULT_SIBLING_SPACING) {
31085
32268
  const map = /* @__PURE__ */ new Map();
31086
32269
  if (!root) return map;
31087
32270
  const origin = {
@@ -31090,70 +32273,122 @@ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
31090
32273
  };
31091
32274
  map.set(root.item.key, {
31092
32275
  node: root,
32276
+ id: root.item.key,
31093
32277
  depth: 0,
31094
32278
  parentKey: void 0,
32279
+ parentId: void 0,
31095
32280
  position: origin,
32281
+ toParent: identityIsometry,
31096
32282
  angularShare: 2 * Math.PI
31097
32283
  });
31098
- const place = (node, position, wedgeStart, wedgeEnd, depth) => {
32284
+ const place = (node, id, frame, wedgeStart, wedgeEnd, depth) => {
31099
32285
  if (depth >= maxDepth) return;
31100
32286
  const children = node.children ?? [];
31101
32287
  const n = children.length;
31102
32288
  if (n === 0) return;
31103
- const r0 = euclideanRadiusForHyperbolicDistance(curvature);
31104
32289
  const angularWidth = wedgeEnd - wedgeStart;
31105
- const weights = children.map((child) => subtreeSize(child, maxDepth - depth - 1));
32290
+ const weights = children.map((child) => Math.sqrt(subtreeSize(child, maxDepth - depth - 1)));
31106
32291
  const totalWeight = weights.reduce((a, b) => a + b, 0);
32292
+ const ids = hyperbolicChildIds(id, children);
31107
32293
  let cursor = wedgeStart;
31108
32294
  children.forEach((child, index) => {
31109
32295
  const share = n === 1 ? angularWidth : angularWidth * weights[index] / totalWeight;
31110
- const childStart = cursor;
31111
- const childEnd = cursor + share;
31112
- cursor = childEnd;
31113
- const theta = (childStart + childEnd) / 2 + (n === 1 ? stableUnitJitter(child.item.key) * Math.min(CHAIN_JITTER_RADIANS, share * .4) : 0);
32296
+ const bisector = cursor + share / 2;
32297
+ cursor += share;
32298
+ const theta = bisector + (n === 1 ? stableUnitJitter(child.item.key) * Math.min(CHAIN_JITTER_RADIANS, share * .4) : 0);
32299
+ const r = euclideanRadiusForHyperbolicDistance(n === 1 ? curvature : childDistance(share, siblingSpacing, curvature));
31114
32300
  const local = {
31115
- x: r0 * Math.cos(theta),
31116
- y: r0 * Math.sin(theta)
32301
+ x: r * Math.cos(theta),
32302
+ y: r * Math.sin(theta)
31117
32303
  };
31118
- const canonicalPosition = mobiusFromOrigin(local, position);
31119
- map.set(child.item.key, {
32304
+ const toParent = translationFromOrigin(local);
32305
+ const childFrame = composeIsometry(frame, toParent);
32306
+ map.set(ids[index], {
31120
32307
  node: child,
32308
+ id: ids[index],
31121
32309
  depth: depth + 1,
31122
32310
  parentKey: node.item.key,
31123
- position: canonicalPosition,
32311
+ parentId: id,
32312
+ position: applyIsometry(childFrame, origin),
32313
+ toParent,
31124
32314
  angularShare: share
31125
32315
  });
31126
- const localStart = boundaryAngleInLocalFrame(childStart, local);
31127
- let localWidth = boundaryAngleInLocalFrame(childEnd, local) - localStart;
32316
+ const halfWedge = Math.min(share, Math.PI) / 2;
32317
+ const localStart = boundaryAngleInLocalFrame(bisector - halfWedge, local);
32318
+ let localWidth = boundaryAngleInLocalFrame(bisector + halfWedge, local) - localStart;
31128
32319
  if (localWidth <= 0) localWidth += 2 * Math.PI;
31129
- place(child, canonicalPosition, localStart, localStart + localWidth, depth + 1);
32320
+ place(child, ids[index], childFrame, localStart, localStart + localWidth, depth + 1);
31130
32321
  });
31131
32322
  };
31132
- place(root, origin, -Math.PI, Math.PI, 0);
32323
+ place(root, root.item.key, identityIsometry, -Math.PI, Math.PI, 0);
31133
32324
  return map;
31134
32325
  }
31135
- /** Nodes within `maxRadius` tree-graph steps of `focusedKey`, mapped to their step distance. */
31136
- function ringDistances(canonical, focusedKey, maxRadius) {
31137
- const ring = /* @__PURE__ */ new Map();
31138
- if (!canonical.has(focusedKey)) return ring;
31139
- ring.set(focusedKey, 0);
31140
- let frontier = [focusedKey];
31141
- for (let distance = 1; distance <= maxRadius && frontier.length > 0; distance++) {
32326
+ /**
32327
+ * Every node within `maxRadius` tree-graph steps of `focusedId`, with its
32328
+ * frame relative to the focus. Built by walking outward from the focus and
32329
+ * composing one short {@link CanonicalEntry.toParent} hop per edge — so
32330
+ * precision depends only on distance *from the focus*, never on how far the
32331
+ * focus is from the root.
32332
+ */
32333
+ function focusFrames(entries, focusedId, maxRadius) {
32334
+ const frames = /* @__PURE__ */ new Map();
32335
+ if (!entries.has(focusedId)) return frames;
32336
+ frames.set(focusedId, {
32337
+ ring: 0,
32338
+ frame: identityIsometry
32339
+ });
32340
+ let frontier = [focusedId];
32341
+ for (let ring = 1; ring <= maxRadius && frontier.length > 0; ring++) {
31142
32342
  const next = [];
31143
- for (const key of frontier) {
31144
- const entry = canonical.get(key);
31145
- if (!entry) continue;
31146
- const neighbours = [];
31147
- if (entry.parentKey !== void 0) neighbours.push(entry.parentKey);
31148
- for (const child of entry.node.children ?? []) neighbours.push(child.item.key);
31149
- for (const neighbour of neighbours) if (!ring.has(neighbour) && canonical.has(neighbour)) {
31150
- ring.set(neighbour, distance);
31151
- next.push(neighbour);
31152
- }
32343
+ for (const id of frontier) {
32344
+ const entry = entries.get(id);
32345
+ const { frame } = frames.get(id);
32346
+ const visit = (neighbourId, neighbourFrame) => {
32347
+ if (frames.has(neighbourId) || !entries.has(neighbourId)) return;
32348
+ frames.set(neighbourId, {
32349
+ ring,
32350
+ frame: neighbourFrame()
32351
+ });
32352
+ next.push(neighbourId);
32353
+ };
32354
+ for (const childId of hyperbolicChildIds(id, entry.node.children ?? [])) visit(childId, () => composeIsometry(frame, entries.get(childId).toParent));
32355
+ if (entry.parentId !== void 0) visit(entry.parentId, () => composeIsometry(frame, invertIsometry(entry.toParent)));
31153
32356
  }
31154
32357
  frontier = next;
31155
32358
  }
31156
- return ring;
32359
+ return frames;
32360
+ }
32361
+ /** `toParent` hops composed from `id` up to (not including) ancestor `ancestorId`: `id`-local → `ancestorId`-local. */
32362
+ function frameUpTo(entries, id, ancestorId) {
32363
+ let frame = identityIsometry;
32364
+ for (let cursor = id; cursor !== void 0 && cursor !== ancestorId; cursor = entries.get(cursor)?.parentId) frame = composeIsometry(entries.get(cursor).toParent, frame);
32365
+ return frame;
32366
+ }
32367
+ /**
32368
+ * The frame of node `toId` expressed in node `fromId`'s local frame (maps
32369
+ * `toId`-local coordinates to `fromId`-local ones), composed along the tree
32370
+ * path between them via their lowest common ancestor. Used to hand a view
32371
+ * over from one focus to another without passing through root-anchored
32372
+ * coordinates. `undefined` if either id is absent.
32373
+ */
32374
+ function hyperbolicRelativeFrame(entries, fromId, toId) {
32375
+ if (!entries.has(fromId) || !entries.has(toId)) return void 0;
32376
+ const ancestors = /* @__PURE__ */ new Set();
32377
+ for (let cursor = fromId; cursor !== void 0; cursor = entries.get(cursor)?.parentId) ancestors.add(cursor);
32378
+ let lca = toId;
32379
+ while (lca !== void 0 && !ancestors.has(lca)) lca = entries.get(lca)?.parentId;
32380
+ if (lca === void 0) return void 0;
32381
+ return composeIsometry(invertIsometry(frameUpTo(entries, fromId, lca)), frameUpTo(entries, toId, lca));
32382
+ }
32383
+ /**
32384
+ * Resolve `focusedKey` — either a layout id, or (as a convenience, and for
32385
+ * data with globally-unique keys) a plain `item.key`, matching the first
32386
+ * node in depth-first order carrying it.
32387
+ */
32388
+ function resolveFocusId(canonical, focusedKey) {
32389
+ if (focusedKey === void 0) return void 0;
32390
+ if (canonical.has(focusedKey)) return focusedKey;
32391
+ for (const entry of canonical.values()) if (entry.node.item.key === focusedKey) return entry.id;
31157
32392
  }
31158
32393
  /**
31159
32394
  * Compute the visible nodes/edges of a hyperbolic tree view: every
@@ -31161,58 +32396,69 @@ function ringDistances(canonical, focusedKey, maxRadius) {
31161
32396
  * node, projected onto the Poincaré disk with the focus at its center.
31162
32397
  *
31163
32398
  * @param root True tree root. `undefined` yields an empty layout.
31164
- * @param options `focusedKey` (default the true root; unknown keys fall back
31165
- * to the true root), `maxRadius` (default unlimited), `maxDepth` (safety
31166
- * cap on canonical-position recursion depth, default 24), `centerOverride`
31167
- * (project around this canonical-space point instead of the focus node's
31168
- * own position — used to animate smoothly between foci).
32399
+ * @param options `focusedKey` (a layout id or plain `item.key`; default the
32400
+ * true root; unknown keys fall back to the true root), `maxRadius` (default unlimited), `maxDepth` (safety
32401
+ * cap on layout recursion depth, default 24), `curvature`,
32402
+ * `siblingSpacing`, `centerOverride` (put this point of the focus's local
32403
+ * frame at the center instead of the focus itself), `view` (a full
32404
+ * focus-local → screen isometry, taking precedence over `centerOverride` —
32405
+ * used for panning and animation, which also rotate).
31169
32406
  */
31170
32407
  function computeHyperbolicLayout(root, options = {}) {
31171
32408
  if (!root) return {
31172
32409
  nodes: [],
31173
32410
  edges: []
31174
32411
  };
31175
- const { maxRadius = Number.POSITIVE_INFINITY, maxDepth = 24, curvature = 1 } = options;
31176
- const canonical = computeCanonicalPositions(root, maxDepth, curvature);
31177
- const focusedKey = options.focusedKey !== void 0 && canonical.has(options.focusedKey) ? options.focusedKey : root.item.key;
31178
- const focusEntry = canonical.get(focusedKey);
31179
- if (!focusEntry) return {
32412
+ const { maxRadius = Number.POSITIVE_INFINITY, maxDepth = 24, curvature = 1, siblingSpacing = DEFAULT_SIBLING_SPACING } = options;
32413
+ const canonical = computeCanonicalPositions(root, maxDepth, curvature, siblingSpacing);
32414
+ const focusedId = resolveFocusId(canonical, options.focusedKey) ?? root.item.key;
32415
+ if (!canonical.has(focusedId)) return {
31180
32416
  nodes: [],
31181
32417
  edges: []
31182
32418
  };
31183
- const projectionCenter = options.centerOverride ?? focusEntry.position;
31184
- const ring = ringDistances(canonical, focusedKey, maxRadius);
31185
- const rawWeights = [...ring.keys()].map((key) => canonical.get(key).node.item.weight).filter((w) => typeof w === `number`);
32419
+ const view = options.view ?? viewIsometry(options.centerOverride ?? {
32420
+ x: 0,
32421
+ y: 0
32422
+ });
32423
+ const frames = focusFrames(canonical, focusedId, maxRadius);
32424
+ const rawWeights = [...frames.keys()].map((key) => canonical.get(key).node.item.weight).filter((w) => typeof w === `number`);
31186
32425
  const minWeight = rawWeights.length > 0 ? Math.min(...rawWeights) : void 0;
31187
32426
  const maxWeight = rawWeights.length > 0 ? Math.max(...rawWeights) : void 0;
31188
32427
  const normalizeWeight = (weight) => {
31189
32428
  if (weight === void 0 || minWeight === void 0 || maxWeight === void 0 || maxWeight === minWeight) return .5;
31190
32429
  return (weight - minWeight) / (maxWeight - minWeight);
31191
32430
  };
31192
- const nodes = [...ring.entries()].map(([key, distance]) => {
31193
- const entry = canonical.get(key);
32431
+ const nodes = [...frames.entries()].map(([id, { ring, frame }]) => {
32432
+ const entry = canonical.get(id);
31194
32433
  const weight = entry.node.item.weight;
31195
32434
  return {
31196
32435
  node: entry.node,
31197
- key,
32436
+ key: entry.node.item.key,
32437
+ id,
31198
32438
  depth: entry.depth,
31199
- ring: distance,
32439
+ ring,
31200
32440
  parentKey: entry.parentKey,
31201
- position: mobiusToOrigin(entry.position, projectionCenter),
32441
+ parentId: entry.parentId,
32442
+ position: applyIsometry(composeIsometry(view, frame), {
32443
+ x: 0,
32444
+ y: 0
32445
+ }),
31202
32446
  angularShare: entry.angularShare,
31203
32447
  weight,
31204
32448
  weightNormalized: normalizeWeight(weight)
31205
32449
  };
31206
32450
  }).sort((a, b) => a.ring - b.ring);
31207
- const byKey = new Map(nodes.map((n) => [n.key, n]));
32451
+ const byId = new Map(nodes.map((n) => [n.id, n]));
31208
32452
  const edges = [];
31209
32453
  for (const n of nodes) {
31210
- if (n.parentKey === void 0) continue;
31211
- const parent = byKey.get(n.parentKey);
32454
+ if (n.parentId === void 0) continue;
32455
+ const parent = byId.get(n.parentId);
31212
32456
  if (!parent) continue;
31213
32457
  edges.push({
31214
32458
  fromKey: parent.key,
31215
32459
  toKey: n.key,
32460
+ fromId: parent.id,
32461
+ toId: n.id,
31216
32462
  from: parent.position,
31217
32463
  to: n.position,
31218
32464
  weightNormalized: (parent.weightNormalized + n.weightNormalized) / 2
@@ -31224,17 +32470,476 @@ function computeHyperbolicLayout(root, options = {}) {
31224
32470
  };
31225
32471
  }
31226
32472
  //#endregion
31227
- //#region src/hyperbolic-tree/hyperbolictree-list.ts
32473
+ //#region src/hyperbolic-tree/keyboard.ts
32474
+ const unhandled = {
32475
+ action: { kind: `none` },
32476
+ handled: false
32477
+ };
32478
+ /** Angular distance (radians, 0–π) of a layout node from screen-"up" (−π/2). */
32479
+ function angleDistanceFromUp(n) {
32480
+ const angle = Math.atan2(n.position.y, n.position.x);
32481
+ let diff = Math.abs(angle - -Math.PI / 2);
32482
+ if (diff > Math.PI) diff = 2 * Math.PI - diff;
32483
+ return diff;
32484
+ }
32485
+ /**
32486
+ * Resolve `key` against the focused node (layout id `focusId`):
32487
+ * - `ArrowUp` — the visible child closest to screen-"up"
32488
+ * - `ArrowDown` — the parent
32489
+ * - `ArrowLeft` / `ArrowRight` — previous / next sibling, wrapping
32490
+ * - `Enter` — activate the focus
32491
+ * - `Escape` — back to the root (unhandled when already there)
32492
+ */
32493
+ function resolveKeyboardAction(key, root, focusId, visibleNodes) {
32494
+ const path = findHyperbolicPathById(root, focusId);
32495
+ const focusNode = path?.at(-1);
32496
+ if (!path || !focusNode) return unhandled;
32497
+ const parent = path.length >= 2 ? path[path.length - 2] : void 0;
32498
+ const none = {
32499
+ action: { kind: `none` },
32500
+ handled: true
32501
+ };
32502
+ const focus = (node) => node ? {
32503
+ action: {
32504
+ kind: `focus`,
32505
+ node
32506
+ },
32507
+ handled: true
32508
+ } : none;
32509
+ switch (key) {
32510
+ case `ArrowUp`: {
32511
+ const children = visibleNodes.filter((n) => n.parentId === focusId);
32512
+ if (children.length === 0) return none;
32513
+ return focus(children.reduce((a, b) => angleDistanceFromUp(a) < angleDistanceFromUp(b) ? a : b).node);
32514
+ }
32515
+ case `ArrowDown`: return focus(parent);
32516
+ case `ArrowLeft`:
32517
+ case `ArrowRight`: {
32518
+ const siblings = parent?.children ?? [];
32519
+ const index = siblings.indexOf(focusNode);
32520
+ if (index === -1 || siblings.length <= 1) return none;
32521
+ return focus(siblings[(index + (key === `ArrowRight` ? 1 : -1) + siblings.length) % siblings.length]);
32522
+ }
32523
+ case `Enter`: return {
32524
+ action: {
32525
+ kind: `activate`,
32526
+ node: focusNode,
32527
+ depth: path.length - 1
32528
+ },
32529
+ handled: true
32530
+ };
32531
+ case `Escape`: return focusNode === root ? unhandled : focus(root);
32532
+ default: return unhandled;
32533
+ }
32534
+ }
32535
+ //#endregion
32536
+ //#region src/hyperbolic-tree/lazy-children.ts
32537
+ function createLazyChildLoader(source, onChange) {
32538
+ const pending = /* @__PURE__ */ new Set();
32539
+ const aborters = /* @__PURE__ */ new Map();
32540
+ const fetchChildren = async (node, depth, signal) => {
32541
+ const { model, loadChildren } = source();
32542
+ if (model) return model.getChildren(node, depth, signal);
32543
+ return [...await collectDataResult(loadChildren({
32544
+ node,
32545
+ depth
32546
+ }, signal), signal)];
32547
+ };
32548
+ const load = async (node, depth) => {
32549
+ pending.add(node);
32550
+ const controller = new AbortController();
32551
+ aborters.set(node, controller);
32552
+ onChange();
32553
+ try {
32554
+ const children = await fetchChildren(node, depth, controller.signal);
32555
+ if (!controller.signal.aborted) node.children = children;
32556
+ } catch {
32557
+ if (!controller.signal.aborted) node.children = [];
32558
+ } finally {
32559
+ pending.delete(node);
32560
+ aborters.delete(node);
32561
+ if (!controller.signal.aborted) onChange();
32562
+ }
32563
+ };
32564
+ return {
32565
+ /** Start loading children for every node in `visible` that has none yet. */
32566
+ ensure(visible) {
32567
+ const { model, loadChildren } = source();
32568
+ if (!model && !loadChildren) return;
32569
+ for (const { node, depth } of visible) if (node.children === void 0 && !pending.has(node)) load(node, depth);
32570
+ },
32571
+ isPending: (node) => pending.has(node),
32572
+ abortAll() {
32573
+ for (const aborter of aborters.values()) aborter.abort();
32574
+ aborters.clear();
32575
+ pending.clear();
32576
+ }
32577
+ };
32578
+ }
32579
+ //#endregion
32580
+ //#region src/hyperbolic-tree/render-utils.ts
32581
+ /**
32582
+ * The Poincaré disk's conformal factor relative to the origin, `1 − |p|²`:
32583
+ * how much a shape of fixed hyperbolic size shrinks (in Euclidean terms) when
32584
+ * moved from the center to `p`. Sizing nodes by this makes them shrink
32585
+ * exactly as the space does, rather than by an ad-hoc falloff.
32586
+ */
32587
+ function conformalScale(p) {
32588
+ return Math.max(0, 1 - (p.x * p.x + p.y * p.y));
32589
+ }
32590
+ /**
32591
+ * Euclidean distance from each node to its nearest visible neighbour, keyed
32592
+ * by layout id (`Infinity` for a lone node). Used to cap on-screen node size
32593
+ * so crowded nodes never overlap — measured in the *projected* view, so it
32594
+ * stays right whichever node is focused. O(n²), fine for the few hundred
32595
+ * nodes a `maxRadius` window typically holds.
32596
+ */
32597
+ function nearestNeighbourDistances(nodes) {
32598
+ const out = /* @__PURE__ */ new Map();
32599
+ for (const n of nodes) out.set(n.id, Number.POSITIVE_INFINITY);
32600
+ for (let i = 0; i < nodes.length; i++) {
32601
+ const a = nodes[i].position;
32602
+ for (let j = i + 1; j < nodes.length; j++) {
32603
+ const b = nodes[j].position;
32604
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
32605
+ if (d < out.get(nodes[i].id)) out.set(nodes[i].id, d);
32606
+ if (d < out.get(nodes[j].id)) out.set(nodes[j].id, d);
32607
+ }
32608
+ }
32609
+ return out;
32610
+ }
32611
+ /** Weight multiplier for `n`, interpolating between the min/max weight scales. */
32612
+ function weightScale(n, options) {
32613
+ return options.minWeightScale + (options.maxWeightScale - options.minWeightScale) * n.weightNormalized;
32614
+ }
32615
+ /**
32616
+ * A dot's rendered radius: base size × weight × conformal shrink, capped to
32617
+ * under half the distance to its nearest neighbour so crowded siblings keep
32618
+ * a visible gap instead of merging into a blob.
32619
+ */
32620
+ function dotRadius(n, nearest, options) {
32621
+ const radius = options.nodeRadius * conformalScale(n.position) * weightScale(n, options);
32622
+ return Math.max(Math.min(radius, nearest * .45), .003);
32623
+ }
32624
+ /**
32625
+ * Footprint of a `2·halfWidth × 2·halfHeight` rectangle centered on
32626
+ * `center` and rotated by `angle` radians.
32627
+ */
32628
+ function orientedRect(id, center, halfWidth, halfHeight, angle = 0) {
32629
+ const cos = Math.cos(angle);
32630
+ const sin = Math.sin(angle);
32631
+ const corner = (u, v) => ({
32632
+ x: center.x + u * cos - v * sin,
32633
+ y: center.y + u * sin + v * cos
32634
+ });
32635
+ return {
32636
+ id,
32637
+ corners: [
32638
+ corner(-halfWidth, -halfHeight),
32639
+ corner(halfWidth, -halfHeight),
32640
+ corner(halfWidth, halfHeight),
32641
+ corner(-halfWidth, halfHeight)
32642
+ ]
32643
+ };
32644
+ }
32645
+ function polygonArea(points) {
32646
+ let area = 0;
32647
+ for (let i = 0; i < points.length; i++) {
32648
+ const a = points[i];
32649
+ const b = points[(i + 1) % points.length];
32650
+ area += a.x * b.y - b.x * a.y;
32651
+ }
32652
+ return Math.abs(area) / 2;
32653
+ }
32654
+ /** Sutherland–Hodgman: `subject` clipped to convex `clip` (either winding). */
32655
+ function clipPolygon(subject, clip) {
32656
+ 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;
32657
+ let output = [...subject];
32658
+ for (let i = 0; i < clip.length && output.length > 0; i++) {
32659
+ const a = clip[i];
32660
+ const b = clip[(i + 1) % clip.length];
32661
+ const side = (p) => orientation * ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x));
32662
+ const input = output;
32663
+ output = [];
32664
+ for (let j = 0; j < input.length; j++) {
32665
+ const current = input[j];
32666
+ const previous = input[(j + input.length - 1) % input.length];
32667
+ const sc = side(current);
32668
+ const sp = side(previous);
32669
+ if (sc >= 0 !== sp >= 0) {
32670
+ const t = sp / (sp - sc);
32671
+ output.push({
32672
+ x: previous.x + (current.x - previous.x) * t,
32673
+ y: previous.y + (current.y - previous.y) * t
32674
+ });
32675
+ }
32676
+ if (sc >= 0) output.push(current);
32677
+ }
32678
+ }
32679
+ return output;
32680
+ }
32681
+ function bounds(points) {
32682
+ const xs = points.map((p) => p.x);
32683
+ const ys = points.map((p) => p.y);
32684
+ return {
32685
+ x0: Math.min(...xs),
32686
+ y0: Math.min(...ys),
32687
+ x1: Math.max(...xs),
32688
+ y1: Math.max(...ys)
32689
+ };
32690
+ }
32691
+ /** Fraction (0–1) of `candidate`'s area covered by `other`. */
32692
+ function overlapFraction(candidate, other) {
32693
+ const a = bounds(candidate.corners);
32694
+ const b = bounds(other.corners);
32695
+ if (a.x1 <= b.x0 || b.x1 <= a.x0 || a.y1 <= b.y0 || b.y1 <= a.y0) return 0;
32696
+ const area = polygonArea(candidate.corners);
32697
+ if (area <= 0) return 1;
32698
+ return polygonArea(clipPolygon(candidate.corners, other.corners)) / area;
32699
+ }
32700
+ /**
32701
+ * Greedy label placement: walk `shapes` in priority order and keep each one
32702
+ * whose overlap with every already-kept shape is at most `maxOverlap` of its
32703
+ * own area. Returns the ids of the kept labels. Checking actual (possibly
32704
+ * rotated) label footprints, not just node positions, is what stops a fan of
32705
+ * same-radius siblings from stacking their text into an unreadable pile.
32706
+ */
32707
+ function cullOverlappingLabels(shapes, maxOverlap = .03) {
32708
+ const kept = [];
32709
+ for (const shape of shapes) if (kept.every((k) => overlapFraction(shape, k) <= maxOverlap)) kept.push(shape);
32710
+ return new Set(kept.map((r) => r.id));
32711
+ }
32712
+ /** `label` cut to at most `maxChars` characters, ending in `…` when cut. `0` or less disables truncation. */
32713
+ function truncateLabel(label, maxChars) {
32714
+ if (maxChars <= 0 || label.length <= maxChars) return label;
32715
+ return `${label.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
32716
+ }
32717
+ let measureContext;
32718
+ const widthCache = /* @__PURE__ */ new Map();
32719
+ /**
32720
+ * Rendered width of `text` at `fontSize` px in `fontFamily`, via an offscreen
32721
+ * canvas (cached). Falls back to a per-character estimate where canvas text
32722
+ * measurement is unavailable (e.g. non-browser test environments).
32723
+ */
32724
+ function measureTextWidth(text, fontSize, fontFamily) {
32725
+ const cacheKey = `${fontFamily}\u0000${text}`;
32726
+ let atUnitSize = widthCache.get(cacheKey);
32727
+ if (atUnitSize === void 0) {
32728
+ if (measureContext === void 0) try {
32729
+ measureContext = typeof document === `undefined` ? null : document.createElement(`canvas`).getContext(`2d`);
32730
+ } catch {
32731
+ measureContext = null;
32732
+ }
32733
+ const reference = 100;
32734
+ let measured;
32735
+ if (measureContext) {
32736
+ measureContext.font = `${reference}px ${fontFamily || `sans-serif`}`;
32737
+ const width = measureContext.measureText(text).width;
32738
+ if (Number.isFinite(width) && width > 0) measured = width / reference;
32739
+ }
32740
+ atUnitSize = measured ?? text.length * .6;
32741
+ widthCache.set(cacheKey, atUnitSize);
32742
+ }
32743
+ return atUnitSize * fontSize;
32744
+ }
32745
+ /**
32746
+ * Smallest scale a box shrinks to, however far from the focus. Pure conformal
32747
+ * scaling (`1 − |z|²`) is geometrically exact but shrinks the second ring to
32748
+ * well under half size, taking labels below legibility long before the
32749
+ * boundary — flooring it keeps nearby rings readable while still reading as
32750
+ * "further away".
32751
+ */
32752
+ const BOX_SCALE_FLOOR = .45;
32753
+ /**
32754
+ * Geometry for a `box`-shaped node. The box is laid out at its focus size and
32755
+ * then uniformly scaled — by the conformal factor, floored at
32756
+ * {@link BOX_SCALE_FLOOR} — so the label is measured just once. Weight
32757
+ * nudges the scale gently (square-rooted), so heavy nodes read as larger
32758
+ * without dwarfing their neighbours' labels.
32759
+ */
32760
+ function boxGeometry(n, options) {
32761
+ const conformal = BOX_SCALE_FLOOR + .55 * conformalScale(n.position);
32762
+ const scale = Math.min(1, conformal * (options.zoom ?? 1)) * Math.sqrt(weightScale(n, options));
32763
+ const text = truncateLabel(n.node.item.label, options.maxChars);
32764
+ const padX = options.fontSize * .5;
32765
+ const height = options.fontSize * 1.55;
32766
+ return {
32767
+ scale,
32768
+ width: measureTextWidth(text, options.fontSize, options.fontFamily) + padX * 2,
32769
+ height,
32770
+ markerSize: options.fontSize * .55,
32771
+ text
32772
+ };
32773
+ }
32774
+ /** Label footprint of a box of `geometry` centered at `center` and rotated by `angle` (render units). */
32775
+ function boxLabelShape(id, center, geometry, angle = 0) {
32776
+ return orientedRect(id, center, geometry.width * geometry.scale / 2, geometry.height * geometry.scale / 2, angle);
32777
+ }
32778
+ //#endregion
32779
+ //#region src/hyperbolic-tree/render-context.ts
32780
+ const HORIZONTAL = {
32781
+ direction: 0,
32782
+ textAngle: 0,
32783
+ flipped: false
32784
+ };
32785
+ /**
32786
+ * Label font metrics. `--hyperbolictree-font-size` is in real screen px;
32787
+ * the SVG's viewBox is fixed, so it's converted to render units using the
32788
+ * on-screen disk size (`extentPx`) and zoom — keeping text legible whatever
32789
+ * size the element is laid out at, and constant-size while zooming
32790
+ * (zooming in spreads crowded labels apart rather than just magnifying the
32791
+ * pile).
32792
+ */
32793
+ function fontMetrics(host, extentPx, zoom) {
32794
+ const style = typeof getComputedStyle === `function` ? getComputedStyle(host) : void 0;
32795
+ const parsed = Number.parseFloat(style?.getPropertyValue(`--hyperbolictree-font-size`) ?? ``);
32796
+ const px = Number.isFinite(parsed) && parsed > 0 ? parsed : 13;
32797
+ return {
32798
+ px,
32799
+ units: px * (1050 / (extentPx > 0 ? extentPx : 420) / (zoom || 1)),
32800
+ family: style?.fontFamily ?? ``
32801
+ };
32802
+ }
32803
+ /** Map of top-level-branch layout id → index among the root's children. */
32804
+ function branchIndices(root) {
32805
+ if (!root) return /* @__PURE__ */ new Map();
32806
+ return new Map(hyperbolicChildIds(root.item.key, root.children ?? []).map((id, i) => [id, i]));
32807
+ }
32808
+ /** Inline `--branch-index`/`--branch-count` declarations for the node/edge with layout id `id` (empty for the root). */
32809
+ function branchStyle(id, branches) {
32810
+ const branch = hyperbolicBranchId(id);
32811
+ const index = branch === void 0 ? void 0 : branches.get(branch);
32812
+ return index === void 0 ? `` : ` --branch-index: ${index}; --branch-count: ${branches.size};`;
32813
+ }
32814
+ function groupSiblings(nodes) {
32815
+ const groups = /* @__PURE__ */ new Map();
32816
+ for (const n of nodes) {
32817
+ const group = groups.get(n.parentId) ?? [];
32818
+ group.push(n);
32819
+ groups.set(n.parentId, group);
32820
+ }
32821
+ return groups;
32822
+ }
32823
+ /**
32824
+ * Label priority: the focus, then selected nodes, then larger (nearer the
32825
+ * center) first.
32826
+ */
32827
+ function byLabelPriority(nodes, sizeOf, isSelected) {
32828
+ const rank = (n) => n.ring === 0 ? 0 : isSelected(n.node) ? 1 : 2;
32829
+ return [...nodes].sort((a, b) => rank(a) - rank(b) || sizeOf(b) - sizeOf(a) || a.ring - b.ring);
32830
+ }
32831
+ /**
32832
+ * Each node's label orientation. The reference neighbour is the one a step
32833
+ * nearer the focus: usually the parent, but for the focus's own ancestors
32834
+ * (which sit *behind* it) the child on the path back to the focus. The focus
32835
+ * itself stays horizontal.
32836
+ */
32837
+ function labelOrientations(nodes, radial) {
32838
+ const out = /* @__PURE__ */ new Map();
32839
+ if (!radial) {
32840
+ for (const n of nodes) out.set(n.id, HORIZONTAL);
32841
+ return out;
32842
+ }
32843
+ const byId = new Map(nodes.map((n) => [n.id, n]));
32844
+ const nearer = /* @__PURE__ */ new Map();
32845
+ for (const n of nodes) {
32846
+ const parent = n.parentId === void 0 ? void 0 : byId.get(n.parentId);
32847
+ if (!parent) continue;
32848
+ if (parent.ring < n.ring) nearer.set(n.id, parent);
32849
+ else if (n.ring < parent.ring) nearer.set(parent.id, n);
32850
+ }
32851
+ for (const n of nodes) {
32852
+ const from = nearer.get(n.id);
32853
+ if (!from || n.ring === 0) {
32854
+ out.set(n.id, HORIZONTAL);
32855
+ continue;
32856
+ }
32857
+ const direction = Math.atan2(n.position.y - from.position.y, n.position.x - from.position.x);
32858
+ const flipped = Math.cos(direction) < 0;
32859
+ out.set(n.id, {
32860
+ direction,
32861
+ textAngle: flipped ? direction + Math.PI : direction,
32862
+ flipped
32863
+ });
32864
+ }
32865
+ return out;
32866
+ }
32867
+ const renderCenter = (n) => ({
32868
+ x: n.position.x * 500,
32869
+ y: n.position.y * 500
32870
+ });
32871
+ function boxLabels(nodes, options, orientations) {
32872
+ const { font } = options;
32873
+ const boxes = new Map(nodes.map((n) => [n.id, boxGeometry(n, {
32874
+ fontSize: font.units,
32875
+ fontFamily: font.family,
32876
+ maxChars: options.boxMaxChars,
32877
+ minWeightScale: options.minWeightScale,
32878
+ maxWeightScale: options.maxWeightScale,
32879
+ zoom: options.zoom
32880
+ })]));
32881
+ return {
32882
+ boxes,
32883
+ 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)))
32884
+ };
32885
+ }
32886
+ function dotLabels(nodes, options, orientations) {
32887
+ const { font } = options;
32888
+ const nearest = nearestNeighbourDistances(nodes);
32889
+ const radii = new Map(nodes.map((n) => [n.id, dotRadius(n, nearest.get(n.id), options)]));
32890
+ return {
32891
+ radii,
32892
+ 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) => {
32893
+ const { direction } = orientations.get(n.id);
32894
+ const halfWidth = measureTextWidth(n.node.item.label, font.units, font.family) / 2;
32895
+ const along = (radii.get(n.id) + .01) * 500 + halfWidth;
32896
+ const c = renderCenter(n);
32897
+ const center = {
32898
+ x: c.x + Math.cos(direction) * along,
32899
+ y: c.y + Math.sin(direction) * along
32900
+ };
32901
+ return orientedRect(n.id, center, halfWidth + font.units * .6, font.units * .6, direction);
32902
+ }))
32903
+ };
32904
+ }
32905
+ /**
32906
+ * Everything the node templates need for one render. Labels are placed
32907
+ * greedily by priority and dropped where they'd overlap; hovered/cursor
32908
+ * nodes are exempt from culling entirely — always labelled — so hovering a
32909
+ * marker reveals it without reshuffling everyone else's labels.
32910
+ */
32911
+ function buildNodeRenderContext(nodes, options) {
32912
+ const orientations = labelOrientations(nodes, options.radialLabels);
32913
+ const shapeData = options.nodeShape === `box` ? {
32914
+ ...boxLabels(nodes, options, orientations),
32915
+ radii: /* @__PURE__ */ new Map()
32916
+ } : {
32917
+ ...dotLabels(nodes, options, orientations),
32918
+ boxes: /* @__PURE__ */ new Map()
32919
+ };
32920
+ const labelIds = new Set(shapeData.labelIds);
32921
+ for (const n of nodes) if (options.isTickled(n.id)) labelIds.add(n.id);
32922
+ return {
32923
+ siblingGroups: groupSiblings(nodes),
32924
+ font: options.font,
32925
+ branches: options.branches,
32926
+ boxes: shapeData.boxes,
32927
+ radii: shapeData.radii,
32928
+ labelIds,
32929
+ orientations
32930
+ };
32931
+ }
31228
32932
  /**
31229
- * The Poincaré disk's math operates in a `-1..1` unit radius, but rendering
31230
- * an SVG `viewBox` at that literal scale (then letting CSS stretch it to a
31231
- * few hundred device pixels) is an extreme, unusual upscale factor that has
31232
- * been observed to trigger a Chromium GPU-rasterization glitch — a `fill:
31233
- * none` shape painting as solid-filled, matching its computed style exactly
31234
- * but not what's on screen. Rendering in a "normal"-magnitude SVG coordinate
31235
- * system (viewBox radius {@link RENDER_SCALE} instead of `1`) avoids it.
32933
+ * Paint order: far-from-focus first so nearer (larger) nodes sit on top;
32934
+ * then labelled over unlabelled; the hovered node above everything.
31236
32935
  */
31237
- const RENDER_SCALE = 500;
32936
+ function paintOrder(nodes, ctx, isTickled) {
32937
+ const layer = (n) => isTickled(n.id) ? 2 : ctx.labelIds.has(n.id) ? 1 : 0;
32938
+ const distance = (n) => n.position.x ** 2 + n.position.y ** 2;
32939
+ return [...nodes].sort((a, b) => layer(a) - layer(b) || distance(b) - distance(a));
32940
+ }
32941
+ //#endregion
32942
+ //#region src/hyperbolic-tree/render-templates.ts
31238
32943
  /**
31239
32944
  * Sanitizes a node key into a token safe to use inside a `part` attribute /
31240
32945
  * `::part()` selector — arbitrary key characters (spaces, quotes, etc.)
@@ -31243,13 +32948,121 @@ const RENDER_SCALE = 500;
31243
32948
  function partToken(key) {
31244
32949
  return key.replace(/[^\w-]/g, `-`);
31245
32950
  }
31246
- /** Angular distance (radians, 0–π) of a layout node from screen-"up" (−π/2). */
31247
- function angleDistanceFromUp(n) {
31248
- const angle = Math.atan2(n.position.y, n.position.x);
31249
- let diff = Math.abs(angle - -Math.PI / 2);
31250
- if (diff > Math.PI) diff = 2 * Math.PI - diff;
31251
- return diff;
32951
+ const preventMouseDownFocus = (e) => e.preventDefault();
32952
+ /** The shared `<g part="node">` wrapper — ARIA, parts, weight/branch vars, tooltip, and pointer handling. */
32953
+ function renderNodeGroup(n, args, transform, content) {
32954
+ const { node, key, id } = n;
32955
+ const { ctx, state, handlers } = args;
32956
+ const selected = state.isSelected(node);
32957
+ const classes = [
32958
+ `node`,
32959
+ selected ? `selected` : ``,
32960
+ state.isTickled(id) ? `tickled` : ``,
32961
+ state.isFiltered(n) ? `filtered` : ``,
32962
+ state.isPending(node) ? `loading` : ``,
32963
+ n.ring === 0 ? `focused` : ``
32964
+ ].filter(Boolean).join(` `);
32965
+ const siblings = ctx.siblingGroups.get(n.parentId) ?? [n];
32966
+ const weightStyle = `--node-weight: ${n.weightNormalized};${n.weight === void 0 ? `` : ` --node-weight-raw: ${n.weight};`}`;
32967
+ return svg`
32968
+ <g
32969
+ class=${classes}
32970
+ data-key=${key}
32971
+ role="treeitem"
32972
+ tabindex="-1"
32973
+ aria-level=${n.depth + 1}
32974
+ aria-selected=${selected}
32975
+ aria-setsize=${siblings.length}
32976
+ aria-posinset=${siblings.findIndex((sib) => sib.id === id) + 1}
32977
+ aria-label=${node.item.label}
32978
+ part="node node-${partToken(key)}"
32979
+ style=${weightStyle + branchStyle(id, ctx.branches)}
32980
+ transform=${transform}
32981
+ @pointerdown=${handlers.onPointerDown}
32982
+ @mousedown=${preventMouseDownFocus}
32983
+ @click=${(e) => handlers.onClick(n, e)}
32984
+ @dblclick=${(e) => handlers.onDblClick(n, e)}
32985
+ @pointerenter=${() => handlers.onPointerEnter(id)}
32986
+ >${state.showTitle(n) ? svg`<title>${node.item.label}</title>` : nothing}${content}</g>
32987
+ `;
31252
32988
  }
32989
+ const degrees = (radians) => radians * 180 / Math.PI;
32990
+ /** A dot's label: just past the dot, running along its orientation — flipped labels anchor at their end so they still read outward. */
32991
+ function renderDotLabel(n, ctx, scaledRadius) {
32992
+ const { textAngle, flipped } = ctx.orientations.get(n.id);
32993
+ const gap = scaledRadius + 5;
32994
+ return svg`<text
32995
+ class="hyperbolictree-label"
32996
+ part="label"
32997
+ x=${flipped ? -gap : gap}
32998
+ y="0"
32999
+ transform=${textAngle === 0 ? nothing : `rotate(${degrees(textAngle)})`}
33000
+ text-anchor=${flipped ? `end` : `start`}
33001
+ font-size=${ctx.font.units}
33002
+ dominant-baseline="middle"
33003
+ >${n.node.item.label}</text>`;
33004
+ }
33005
+ function renderDotNode(n, args) {
33006
+ const { ctx } = args;
33007
+ const scaledRadius = ctx.radii.get(n.id) * 500;
33008
+ return renderNodeGroup(n, args, `translate(${n.position.x * 500} ${n.position.y * 500})`, svg`
33009
+ <circle class="node-hit" r=${Math.max(scaledRadius, 17.5)} part="hit" />
33010
+ <circle class="node-dot" r=${scaledRadius} part="dot" />
33011
+ ${ctx.labelIds.has(n.id) ? renderDotLabel(n, ctx, scaledRadius) : nothing}
33012
+ `);
33013
+ }
33014
+ function renderBoxNode(n, args) {
33015
+ const { ctx } = args;
33016
+ const box = ctx.boxes.get(n.id);
33017
+ const showText = ctx.labelIds.has(n.id);
33018
+ const width = showText ? box.width : box.markerSize;
33019
+ const height = showText ? box.height : box.markerSize;
33020
+ const { textAngle } = ctx.orientations.get(n.id);
33021
+ const rotate = showText && textAngle !== 0 ? ` rotate(${degrees(textAngle)})` : ``;
33022
+ return renderNodeGroup(n, args, `translate(${n.position.x * 500} ${n.position.y * 500})${rotate} scale(${box.scale})`, svg`
33023
+ <rect
33024
+ class="node-box ${showText ? `` : `marker`}"
33025
+ part="box"
33026
+ x=${-width / 2}
33027
+ y=${-height / 2}
33028
+ width=${width}
33029
+ height=${height}
33030
+ rx=${ctx.font.units * .2}
33031
+ />
33032
+ ${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}
33033
+ `);
33034
+ }
33035
+ /** Every visible node, in paint order, in the shape `ctx` was built for. */
33036
+ function renderNodes(nodes, shape, args) {
33037
+ const ordered = paintOrder(nodes, args.ctx, args.state.isTickled);
33038
+ const renderOne = shape === `box` ? renderBoxNode : renderDotNode;
33039
+ return repeat(ordered, (n) => n.id, (n) => renderOne(n, args));
33040
+ }
33041
+ function renderDefaultEdge(edge, branches) {
33042
+ return svg`<path
33043
+ class="edge"
33044
+ d=${geodesicPath(edge.from, edge.to, 500)}
33045
+ part="edge edge-to-${partToken(edge.toKey)} edge-from-${partToken(edge.fromKey)}"
33046
+ data-from=${edge.fromKey}
33047
+ data-to=${edge.toKey}
33048
+ style="--edge-weight: ${edge.weightNormalized};${branchStyle(edge.toId, branches)}"
33049
+ />`;
33050
+ }
33051
+ /** Every visible edge — via `edgeRenderer` when given, else the default geodesic arc. */
33052
+ function renderEdges(edges, byId, branches, edgeRenderer) {
33053
+ return repeat(edges, (e) => `${e.fromId}->${e.toId}`, (edge) => {
33054
+ const fromNode = byId.get(edge.fromId)?.node;
33055
+ const toNode = byId.get(edge.toId)?.node;
33056
+ if (edgeRenderer && fromNode && toNode) return svg`<g transform="scale(${500})">${edgeRenderer({
33057
+ edge,
33058
+ fromNode,
33059
+ toNode
33060
+ })}</g>`;
33061
+ return renderDefaultEdge(edge, branches);
33062
+ });
33063
+ }
33064
+ //#endregion
33065
+ //#region src/hyperbolic-tree/hyperbolictree-list.ts
31253
33066
  let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitElement {
31254
33067
  constructor(..._args) {
31255
33068
  super(..._args);
@@ -31257,37 +33070,63 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31257
33070
  this.selectionFilter = `all`;
31258
33071
  this.interactionMode = `standard`;
31259
33072
  this.maxRadius = 4;
33073
+ this.nodeShape = `dot`;
33074
+ this.boxMaxChars = 24;
33075
+ this.minLabelSize = 9;
33076
+ this.colorByBranch = false;
31260
33077
  this.nodeRadius = .05;
31261
33078
  this.minNodeRadius = .015;
31262
33079
  this.minWeightScale = .6;
31263
33080
  this.maxWeightScale = 1.6;
31264
33081
  this.zoom = 1;
33082
+ this.disableZoomGestures = false;
33083
+ this.disableRadialLabels = false;
33084
+ this.disableChildPreview = false;
31265
33085
  this.curvature = 1;
33086
+ this.siblingSpacing = DEFAULT_SIBLING_SPACING;
31266
33087
  this.focusAnimationDuration = 400;
31267
33088
  this._selectedNodes = /* @__PURE__ */ new Set();
31268
33089
  this.disableKeyboardNavigation = false;
31269
33090
  this.registry = void 0;
31270
- this._focusKey = void 0;
31271
- this._animCenter = void 0;
31272
- this._panOffset = {
33091
+ this._focusId = void 0;
33092
+ this._viewOverride = void 0;
33093
+ this._focusRotation = 0;
33094
+ this._zoomOffset = {
31273
33095
  x: 0,
31274
33096
  y: 0
31275
33097
  };
33098
+ this._previewId = void 0;
33099
+ this.#preview = createChildPreviewTiming({
33100
+ candidate: (id) => {
33101
+ const nodes = this.#layout().nodes;
33102
+ const n = id === void 0 || this.disableChildPreview ? void 0 : nodes.find((v) => v.id === id);
33103
+ return n && needsChildPreview(n, nodes, this.#lastLabelIds) ? n.id : void 0;
33104
+ },
33105
+ setPreviewId: (id) => {
33106
+ this._previewId = id;
33107
+ }
33108
+ });
33109
+ this.#lazy = createLazyChildLoader(() => ({
33110
+ model: this.model,
33111
+ loadChildren: this.loadChildren
33112
+ }), () => this.requestUpdate());
33113
+ this.#lastLabelIds = /* @__PURE__ */ new Set();
31276
33114
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
31277
33115
  labelSelector: `.hyperbolictree-label`,
31278
33116
  highlightKey: `incr-search-highlight`
31279
33117
  }) });
31280
33118
  this.tickled = new TickledController(this, {
31281
33119
  getItems: () => this.#layout().nodes.map((n) => ({
31282
- id: n.key,
33120
+ id: n.id,
31283
33121
  enabled: true
31284
33122
  })),
31285
33123
  onStateChange: () => {
31286
33124
  this.setAttribute(`data-input-mode`, this.tickled.inputMode);
31287
33125
  },
31288
33126
  onTickle: (id) => {
33127
+ this.#preview.update(id);
31289
33128
  if (id === void 0) return;
31290
- const n = this.#layout().nodes.find((n) => n.key === id);
33129
+ const n = this.#layout().nodes.find((n) => n.id === id);
31291
33130
  if (!n) return;
31292
33131
  this.dispatchEvent(new CustomEvent(`tickle`, {
31293
33132
  detail: {
@@ -31299,8 +33138,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31299
33138
  }));
31300
33139
  }
31301
33140
  });
31302
- this.#pendingKeys = /* @__PURE__ */ new Set();
31303
- this.#aborters = /* @__PURE__ */ new Map();
33141
+ this._hostSize = {
33142
+ width: 0,
33143
+ height: 0
33144
+ };
33145
+ this.#gestures = createGestureHandlers({
33146
+ svgRect: () => this.renderRoot.querySelector(`svg.hyperbolictree`)?.getBoundingClientRect(),
33147
+ takeView: () => {
33148
+ const view = this.#currentView();
33149
+ this.#cancelAnimation();
33150
+ this._viewOverride = view;
33151
+ return view;
33152
+ },
33153
+ setView: (view) => {
33154
+ this._viewOverride = view;
33155
+ },
33156
+ zoomState: () => this.#zoomState(),
33157
+ setZoomState: (state) => {
33158
+ this.zoom = state.zoom;
33159
+ this._zoomOffset = state.offset;
33160
+ },
33161
+ zoomGesturesEnabled: () => !this.disableZoomGestures,
33162
+ hasFocus: () => this.renderRoot.activeElement !== null,
33163
+ focusTree: () => this.#focusTree()
33164
+ });
31304
33165
  }
31305
33166
  static {
31306
33167
  this.shadowRootOptions = {
@@ -31313,37 +33174,55 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31313
33174
  }
31314
33175
  /** The node currently centered on the disk. Pure view-state — `root` is never mutated by focus changes. */
31315
33176
  get focusedNode() {
31316
- if (this._focusKey === void 0) return this.#effectiveRoot;
31317
- return findHyperbolicNode(this.#effectiveRoot, this._focusKey) ?? this.#effectiveRoot;
33177
+ if (this._focusId === void 0) return this.#effectiveRoot;
33178
+ return findHyperbolicPathById(this.#effectiveRoot, this._focusId)?.at(-1) ?? this.#effectiveRoot;
31318
33179
  }
33180
+ #preview;
33181
+ #lazy;
33182
+ /** Labels shown in the last render — what the child preview judges legibility by. */
33183
+ #lastLabelIds;
31319
33184
  #modelUnsubscribe;
31320
- #pendingKeys;
31321
- #aborters;
31322
33185
  #ownedRegistry;
31323
33186
  #unregisterSearchCommands;
31324
33187
  #animFrame;
31325
- #panStart;
33188
+ #resizeObserver;
33189
+ #gestures;
31326
33190
  get #effectiveRoot() {
31327
33191
  return this.model?.root ?? this.root;
31328
33192
  }
31329
33193
  #layout() {
31330
33194
  return computeHyperbolicLayout(this.#effectiveRoot, {
31331
- focusedKey: this._focusKey,
33195
+ focusedKey: this._focusId,
31332
33196
  maxRadius: this.maxRadius,
31333
33197
  curvature: this.curvature,
31334
- centerOverride: this._panOffset.x === 0 && this._panOffset.y === 0 && this._animCenter === void 0 ? void 0 : this.#effectiveCenter()
33198
+ siblingSpacing: this.siblingSpacing,
33199
+ view: this.#currentView()
31335
33200
  });
31336
33201
  }
31337
- #effectiveCenter() {
31338
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31339
- const focusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31340
- const base = this._animCenter ?? (focusKey === void 0 ? void 0 : canonical.get(focusKey)?.position);
31341
- if (base === void 0) return void 0;
31342
- if (this._panOffset.x === 0 && this._panOffset.y === 0) return base;
31343
- return clampToDisk({
31344
- x: base.x + this._panOffset.x,
31345
- y: base.y + this._panOffset.y
31346
- }, .95);
33202
+ get #rootId() {
33203
+ return this.#effectiveRoot?.item.key;
33204
+ }
33205
+ /** The projection currently on screen, from the focus's local frame. */
33206
+ #currentView() {
33207
+ return this._viewOverride ?? rotationIsometry(this._focusRotation);
33208
+ }
33209
+ /**
33210
+ * The current view re-expressed in `targetId`'s local frame — i.e. the same
33211
+ * picture, but with `targetId` as the reference node. Where the view hands
33212
+ * over on a focus change.
33213
+ */
33214
+ #viewFromFrameOf(targetId, currentId) {
33215
+ const entries = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature, this.siblingSpacing);
33216
+ const relative = currentId === void 0 ? void 0 : hyperbolicRelativeFrame(entries, currentId, targetId);
33217
+ return relative ? composeIsometry(this.#currentView(), relative) : rotationIsometry(this._focusRotation);
33218
+ }
33219
+ /** Layout id of `node` — by identity, falling back to the first node sharing its key. */
33220
+ #idOf(node) {
33221
+ const root = this.#effectiveRoot;
33222
+ const direct = findHyperbolicIdOf(root, node);
33223
+ if (direct !== void 0) return direct;
33224
+ const byKey = findHyperbolicNode(root, node.item.key);
33225
+ return byKey ? findHyperbolicIdOf(root, byKey) : void 0;
31347
33226
  }
31348
33227
  connectedCallback() {
31349
33228
  super.connectedCallback();
@@ -31363,14 +33242,25 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31363
33242
  });
31364
33243
  this.registry = registry;
31365
33244
  this.#syncSearchCommands();
33245
+ if (typeof ResizeObserver === `function`) {
33246
+ this.#resizeObserver = new ResizeObserver(([entry]) => {
33247
+ const { width, height } = entry.contentRect;
33248
+ this._hostSize = {
33249
+ width,
33250
+ height
33251
+ };
33252
+ });
33253
+ this.#resizeObserver.observe(this);
33254
+ }
31366
33255
  }
31367
33256
  disconnectedCallback() {
31368
33257
  super.disconnectedCallback();
33258
+ this.#resizeObserver?.disconnect();
33259
+ this.#resizeObserver = void 0;
31369
33260
  this.#modelUnsubscribe?.();
31370
33261
  this.#modelUnsubscribe = void 0;
31371
- for (const aborter of this.#aborters.values()) aborter.abort();
31372
- this.#aborters.clear();
31373
- this.#pendingKeys.clear();
33262
+ this.#preview.dispose();
33263
+ this.#lazy.abortAll();
31374
33264
  if (this.#animFrame !== void 0) {
31375
33265
  cancelAnimationFrame(this.#animFrame);
31376
33266
  this.#animFrame = void 0;
@@ -31411,58 +33301,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31411
33301
  }
31412
33302
  if (changedProps.has(`interactionMode`)) this.clearSelection();
31413
33303
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
31414
- if (this._focusKey !== void 0 && !findHyperbolicNode(this.#effectiveRoot, this._focusKey)) {
31415
- this._focusKey = void 0;
31416
- this._animCenter = void 0;
33304
+ if (this._focusId !== void 0 && !findHyperbolicPathById(this.#effectiveRoot, this._focusId)) {
33305
+ this._focusId = void 0;
33306
+ this._viewOverride = void 0;
33307
+ this._focusRotation = 0;
31417
33308
  }
31418
- this.#ensureVisibleChildren();
33309
+ this.#lazy.ensure(this.#layout().nodes);
31419
33310
  }
31420
33311
  #resetViewState() {
31421
33312
  this.#clearSelectionInternal();
31422
33313
  this.tickled.clearTickled();
31423
- this._focusKey = void 0;
31424
- this._animCenter = void 0;
31425
- this._panOffset = {
33314
+ this._focusId = void 0;
33315
+ this._viewOverride = void 0;
33316
+ this._focusRotation = 0;
33317
+ this._zoomOffset = {
31426
33318
  x: 0,
31427
33319
  y: 0
31428
33320
  };
33321
+ this._previewId = void 0;
33322
+ this.#gestures.cancel();
31429
33323
  if (this.#animFrame !== void 0) {
31430
33324
  cancelAnimationFrame(this.#animFrame);
31431
33325
  this.#animFrame = void 0;
31432
33326
  }
31433
33327
  }
31434
- #ensureVisibleChildren() {
31435
- if (!this.model && !this.loadChildren) return;
31436
- for (const n of this.#layout().nodes) {
31437
- const { node } = n;
31438
- if (node.children !== void 0 || this.#pendingKeys.has(node.item.key)) continue;
31439
- this.#loadChildren(node, n.depth);
31440
- }
31441
- }
31442
- async #loadChildren(node, depth) {
31443
- const key = node.item.key;
31444
- this.#pendingKeys.add(key);
31445
- const controller = new AbortController();
31446
- this.#aborters.set(key, controller);
31447
- this.requestUpdate();
31448
- try {
31449
- let children;
31450
- if (this.model) children = await this.model.getChildren(node, depth, controller.signal);
31451
- else children = [...await collectDataResult(this.loadChildren({
31452
- node,
31453
- depth
31454
- }, controller.signal), controller.signal)];
31455
- if (controller.signal.aborted) return;
31456
- node.children = children;
31457
- } catch {
31458
- if (controller.signal.aborted) return;
31459
- node.children = [];
31460
- } finally {
31461
- this.#pendingKeys.delete(key);
31462
- this.#aborters.delete(key);
31463
- if (!controller.signal.aborted) this.requestUpdate();
31464
- }
31465
- }
31466
33328
  #canSelect(node) {
31467
33329
  if (this.selectionFilter === `none`) return false;
31468
33330
  if (this.selectionFilter === `leaf`) return node.item.isLeaf === true;
@@ -31538,83 +33400,83 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31538
33400
  * the previous one, for interop with generic `TreeComponent` listeners.
31539
33401
  */
31540
33402
  focusOn(node) {
31541
- const targetKey = node.item.key;
31542
- const currentFocusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31543
- if (currentFocusKey === targetKey) return;
31544
- const previousNode = currentFocusKey === void 0 ? void 0 : findHyperbolicNode(this.#effectiveRoot, currentFocusKey);
31545
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31546
- const fromPosition = (currentFocusKey === void 0 ? void 0 : canonical.get(currentFocusKey)?.position) ?? {
31547
- x: 0,
31548
- y: 0
31549
- };
31550
- const toPosition = canonical.get(targetKey)?.position ?? {
31551
- x: 0,
31552
- y: 0
31553
- };
31554
- this._focusKey = targetKey;
31555
- this._panOffset = {
31556
- x: 0,
31557
- y: 0
31558
- };
31559
- this.#animateTo(fromPosition, toPosition);
33403
+ const root = this.#effectiveRoot;
33404
+ const targetId = this.#idOf(node);
33405
+ const currentId = this._focusId ?? this.#rootId;
33406
+ if (targetId === void 0 || currentId === targetId) return;
33407
+ const targetNode = findHyperbolicPathById(root, targetId)?.at(-1) ?? node;
33408
+ const previousNode = currentId === void 0 ? void 0 : findHyperbolicPathById(root, currentId)?.at(-1);
33409
+ const fromView = this.#viewFromFrameOf(targetId, currentId);
33410
+ this._focusId = targetId;
33411
+ this.#gestures.cancel();
33412
+ this._previewId = void 0;
33413
+ this.#animateTo(fromView);
31560
33414
  this.dispatchEvent(new CustomEvent(`focus`, {
31561
33415
  detail: {
31562
- node,
33416
+ node: targetNode,
31563
33417
  previous: previousNode
31564
33418
  },
31565
33419
  bubbles: true,
31566
33420
  composed: true
31567
33421
  }));
31568
- if (previousNode) {
31569
- if (findHyperbolicNode(previousNode, targetKey)) {
31570
- const depth = (findHyperbolicPath(this.#effectiveRoot, targetKey)?.length ?? 1) - 1;
31571
- this.dispatchEvent(new CustomEvent(`expand`, {
31572
- detail: {
31573
- node,
31574
- depth
31575
- },
31576
- bubbles: true,
31577
- composed: true
31578
- }));
31579
- } else if (findHyperbolicNode(node, previousNode.item.key)) {
31580
- const depth = (findHyperbolicPath(this.#effectiveRoot, previousNode.item.key)?.length ?? 1) - 1;
31581
- this.dispatchEvent(new CustomEvent(`collapse`, {
31582
- detail: {
31583
- node: previousNode,
31584
- depth
31585
- },
31586
- bubbles: true,
31587
- composed: true
31588
- }));
31589
- }
33422
+ if (previousNode && currentId !== void 0) {
33423
+ if (isHyperbolicDescendantId(targetId, currentId)) this.dispatchEvent(new CustomEvent(`expand`, {
33424
+ detail: {
33425
+ node: targetNode,
33426
+ depth: hyperbolicIdDepth(targetId)
33427
+ },
33428
+ bubbles: true,
33429
+ composed: true
33430
+ }));
33431
+ else if (isHyperbolicDescendantId(currentId, targetId)) this.dispatchEvent(new CustomEvent(`collapse`, {
33432
+ detail: {
33433
+ node: previousNode,
33434
+ depth: hyperbolicIdDepth(currentId)
33435
+ },
33436
+ bubbles: true,
33437
+ composed: true
33438
+ }));
31590
33439
  }
31591
33440
  this.requestUpdate();
31592
33441
  }
31593
- #prefersReducedMotion() {
31594
- return typeof matchMedia === `function` && matchMedia(`(prefers-reduced-motion: reduce)`).matches;
31595
- }
31596
- #animateTo(from, to) {
33442
+ #cancelAnimation() {
31597
33443
  if (this.#animFrame !== void 0) {
31598
33444
  cancelAnimationFrame(this.#animFrame);
31599
33445
  this.#animFrame = void 0;
31600
33446
  }
31601
- if (this.focusAnimationDuration <= 0 || this.#prefersReducedMotion() || typeof requestAnimationFrame !== `function`) {
31602
- this._animCenter = void 0;
33447
+ }
33448
+ /** Animate from view `from` (in the new focus's frame) to the settled, centered view — see {@link focusTransition}. */
33449
+ #animateTo(from) {
33450
+ this.#cancelAnimation();
33451
+ const { viewAt, settledRotation } = focusTransition(from);
33452
+ this._focusRotation = settledRotation;
33453
+ const fromOffset = this._zoomOffset;
33454
+ if (this.focusAnimationDuration <= 0 || prefersReducedMotion$1() || typeof requestAnimationFrame !== `function`) {
33455
+ this._viewOverride = void 0;
33456
+ this._zoomOffset = {
33457
+ x: 0,
33458
+ y: 0
33459
+ };
31603
33460
  return;
31604
33461
  }
31605
33462
  const start = performance.now();
31606
33463
  const duration = this.focusAnimationDuration;
31607
33464
  const step = (now) => {
31608
33465
  const t = Math.min(1, (now - start) / duration);
31609
- this._animCenter = t >= 1 ? void 0 : geodesicInterpolate(from, to, t);
31610
- this.requestUpdate();
33466
+ const remaining = 1 - easeInOutCubic(t);
33467
+ this._zoomOffset = {
33468
+ x: fromOffset.x * remaining,
33469
+ y: fromOffset.y * remaining
33470
+ };
33471
+ this._viewOverride = t >= 1 ? void 0 : viewAt(t);
31611
33472
  this.#animFrame = t < 1 ? requestAnimationFrame(step) : void 0;
31612
33473
  };
31613
33474
  this.#animFrame = requestAnimationFrame(step);
31614
33475
  }
31615
33476
  #focusAndMaybeSelect(node) {
31616
33477
  this.focusOn(node);
31617
- this.tickled.setTickled(node.item.key);
33478
+ const id = this.#idOf(node);
33479
+ if (id !== void 0) this.tickled.setTickled(id);
31618
33480
  if (this.interactionMode === `implicit`) this.select(node);
31619
33481
  }
31620
33482
  /** Focus + select the node at `keys` (path from the root's children). Returns `true` if found. */
@@ -31629,7 +33491,8 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31629
33491
  }
31630
33492
  this.focusOn(current);
31631
33493
  this.select(current);
31632
- this.tickled.setTickled(current.item.key);
33494
+ const id = this.#idOf(current);
33495
+ if (id !== void 0) this.tickled.setTickled(id);
31633
33496
  this.requestUpdate();
31634
33497
  return true;
31635
33498
  }
@@ -31637,7 +33500,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31637
33500
  navigateToNode(predicate) {
31638
33501
  const root = this.#effectiveRoot;
31639
33502
  if (!root?.children) return false;
31640
- const keyPath = this.#findKeyPath(root.children, predicate);
33503
+ const keyPath = findHyperbolicKeyPath(root.children, predicate);
31641
33504
  if (!keyPath) return false;
31642
33505
  return this.navigateTo(keyPath);
31643
33506
  }
@@ -31648,22 +33511,13 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31648
33511
  */
31649
33512
  revealKey(key) {
31650
33513
  const node = findHyperbolicNode(this.#effectiveRoot, key);
31651
- if (!node) return false;
31652
- if (!this.#layout().nodes.some((n) => n.key === key)) this.focusOn(node);
31653
- this.tickled.setTickled(key);
33514
+ const id = node ? this.#idOf(node) : void 0;
33515
+ if (!node || id === void 0) return false;
33516
+ if (!this.#layout().nodes.some((n) => n.id === id)) this.focusOn(node);
33517
+ this.tickled.setTickled(id);
31654
33518
  this.requestUpdate();
31655
33519
  return true;
31656
33520
  }
31657
- #findKeyPath(nodes, predicate, prefix = []) {
31658
- for (const node of nodes) {
31659
- const path = [...prefix, node.item.key];
31660
- if (predicate(node)) return path;
31661
- if (node.children?.length) {
31662
- const hit = this.#findKeyPath(node.children, predicate, path);
31663
- if (hit) return hit;
31664
- }
31665
- }
31666
- }
31667
33521
  /**
31668
33522
  * All nodes currently laid out on the disk (within `maxRadius` of the
31669
33523
  * focus), in render (ring-major) order. Unlike `ixfx-icicletree-list`, the
@@ -31700,7 +33554,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31700
33554
  this.requestUpdate();
31701
33555
  }
31702
33556
  } else if (this.interactionMode !== `manual`) this.select(n.node);
31703
- this.tickled.setTickled(n.key);
33557
+ this.tickled.setTickled(n.id);
31704
33558
  this.focusOn(n.node);
31705
33559
  }
31706
33560
  #handleItemDblClick(n) {
@@ -31722,239 +33576,147 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31722
33576
  }));
31723
33577
  }
31724
33578
  #handleKeyDown(event) {
31725
- if (this.disableKeyboardNavigation) return;
31726
33579
  const root = this.#effectiveRoot;
31727
- const { nodes } = this.#layout();
31728
- if (!root || nodes.length === 0) return;
31729
- const focusNode = findHyperbolicNode(root, this._focusKey ?? root.item.key);
31730
- if (!focusNode) return;
31731
- switch (event.key) {
31732
- case `ArrowUp`: {
31733
- event.preventDefault();
31734
- const children = nodes.filter((n) => n.parentKey === focusNode.item.key);
31735
- if (children.length === 0) return;
31736
- const best = children.reduce((a, b) => angleDistanceFromUp(a) < angleDistanceFromUp(b) ? a : b);
31737
- this.#focusAndMaybeSelect(best.node);
31738
- return;
31739
- }
31740
- case `ArrowDown`: {
31741
- event.preventDefault();
31742
- const path = findHyperbolicPath(root, focusNode.item.key);
31743
- const parent = path && path.length >= 2 ? path[path.length - 2] : void 0;
31744
- if (parent) this.#focusAndMaybeSelect(parent);
31745
- return;
31746
- }
31747
- case `ArrowLeft`:
31748
- case `ArrowRight`: {
31749
- event.preventDefault();
31750
- const path = findHyperbolicPath(root, focusNode.item.key);
31751
- const siblings = (path && path.length >= 2 ? path[path.length - 2] : void 0)?.children ?? (focusNode === root ? [focusNode] : []);
31752
- const index = siblings.findIndex((n) => n.item.key === focusNode.item.key);
31753
- if (index === -1 || siblings.length <= 1) return;
31754
- const next = siblings[(index + (event.key === `ArrowRight` ? 1 : -1) + siblings.length) % siblings.length];
31755
- this.#focusAndMaybeSelect(next);
31756
- return;
31757
- }
31758
- case `Enter`: {
31759
- event.preventDefault();
31760
- if (this.interactionMode !== `manual`) this.select(focusNode);
31761
- const depth = (findHyperbolicPath(root, focusNode.item.key)?.length ?? 1) - 1;
31762
- this.dispatchEvent(new CustomEvent(`activate`, {
31763
- detail: {
31764
- node: focusNode,
31765
- depth
31766
- },
31767
- bubbles: true,
31768
- composed: true
31769
- }));
31770
- return;
31771
- }
31772
- case `Escape`: if (this._focusKey !== void 0 && this._focusKey !== root.item.key) {
31773
- event.preventDefault();
31774
- this.focusOn(root);
31775
- }
33580
+ if (this.disableKeyboardNavigation || !root) return;
33581
+ if (event.key === `Escape` && this._previewId !== void 0) {
33582
+ event.preventDefault();
33583
+ this._previewId = void 0;
33584
+ return;
33585
+ }
33586
+ const { action, handled } = resolveKeyboardAction(event.key, root, this._focusId ?? root.item.key, this.#layout().nodes);
33587
+ if (handled) event.preventDefault();
33588
+ if (action.kind === `focus`) this.#focusAndMaybeSelect(action.node);
33589
+ else if (action.kind === `activate`) {
33590
+ if (this.interactionMode !== `manual`) this.select(action.node);
33591
+ this.dispatchEvent(new CustomEvent(`activate`, {
33592
+ detail: {
33593
+ node: action.node,
33594
+ depth: action.depth
33595
+ },
33596
+ bubbles: true,
33597
+ composed: true
33598
+ }));
31776
33599
  }
31777
33600
  }
31778
- #isBackgroundTarget(event) {
31779
- const target = event.target;
31780
- return target === event.currentTarget || target?.classList.contains(`disk-boundary`) === true;
31781
- }
31782
- #handleBackgroundPointerDown(event) {
31783
- if (!this.#isBackgroundTarget(event)) return;
31784
- event.currentTarget.setPointerCapture(event.pointerId);
31785
- this.#panStart = {
31786
- pointerId: event.pointerId,
31787
- startClient: {
31788
- x: event.clientX,
31789
- y: event.clientY
31790
- },
31791
- baseOffset: this._panOffset
31792
- };
31793
- }
31794
- #handleBackgroundPointerMove(event) {
31795
- if (!this.#panStart || event.pointerId !== this.#panStart.pointerId) return;
31796
- const rect = event.currentTarget.getBoundingClientRect();
31797
- const scale = 2.1 / (Math.min(rect.width, rect.height) || 1);
31798
- const dx = (event.clientX - this.#panStart.startClient.x) * scale;
31799
- const dy = (event.clientY - this.#panStart.startClient.y) * scale;
31800
- this._panOffset = clampToDisk({
31801
- x: this.#panStart.baseOffset.x - dx,
31802
- y: this.#panStart.baseOffset.y - dy
31803
- }, .9);
31804
- this.requestUpdate();
33601
+ #zoomState() {
33602
+ return clampZoomState({
33603
+ zoom: this.zoom,
33604
+ offset: this._zoomOffset
33605
+ });
31805
33606
  }
31806
- #handleBackgroundPointerUp(event) {
31807
- if (this.#panStart?.pointerId === event.pointerId) this.#panStart = void 0;
31808
- }
31809
- #defaultEdgeRenderer(edge) {
31810
- const steps = 8;
31811
- let d = ``;
31812
- for (let i = 0; i <= steps; i++) {
31813
- const p = geodesicInterpolate(edge.from, edge.to, i / steps);
31814
- d += `${i === 0 ? `M` : `L`}${(p.x * RENDER_SCALE).toFixed(2)},${(p.y * RENDER_SCALE).toFixed(2)} `;
31815
- }
31816
- return svg`<path
31817
- class="edge"
31818
- d=${d}
31819
- part="edge edge-to-${partToken(edge.toKey)} edge-from-${partToken(edge.fromKey)}"
31820
- data-from=${edge.fromKey}
31821
- data-to=${edge.toKey}
31822
- style="--edge-weight: ${edge.weightNormalized}"
31823
- />`;
33607
+ /**
33608
+ * Any pointer interaction (a node press, or a pan) moves keyboard focus to
33609
+ * the tree, so the user can carry straight on with the arrow keys.
33610
+ * Focuses the stable `<svg role="tree">` — not the node, which paint-order
33611
+ * re-sorting may move in the DOM (dropping its focus) — without scrolling.
33612
+ */
33613
+ #focusTree() {
33614
+ this.renderRoot.querySelector(`svg.hyperbolictree`)?.focus({ preventScroll: true });
31824
33615
  }
31825
- #renderEdge(edge, byKey) {
31826
- if (this.edgeRenderer) {
31827
- const fromNode = byKey.get(edge.fromKey)?.node;
31828
- const toNode = byKey.get(edge.toKey)?.node;
31829
- if (fromNode && toNode) return svg`<g transform="scale(${RENDER_SCALE})">${this.edgeRenderer({
31830
- edge,
31831
- fromNode,
31832
- toNode
31833
- })}</g>`;
31834
- }
31835
- return this.#defaultEdgeRenderer(edge);
31836
- }
31837
- /**
31838
- * A node's rendered dot radius: shrinks with distance from focus (existing
31839
- * behaviour), but also caps itself to the angular room {@link
31840
- * HyperbolicLayoutNode.angularShare} actually gives it at its radius —
31841
- * without this, a cluster of siblings squeezed into a narrow wedge (a
31842
- * bushy ancestor elsewhere in the tree, or many siblings at this level)
31843
- * all render at the same distance-only size and physically overlap into
31844
- * an unreadable blob, however small {@link minNodeRadius} suppresses their
31845
- * labels. `angularShare * distance` approximates the Euclidean arc length
31846
- * available to the node; capping the diameter to a fraction of that keeps
31847
- * a visible gap between crowded neighbours.
31848
- */
31849
- #nodeRenderRadius(n) {
31850
- const distance = Math.hypot(n.position.x, n.position.y);
31851
- const scale = Math.max(.2, 1 - distance);
31852
- const weightScale = this.minWeightScale + (this.maxWeightScale - this.minWeightScale) * n.weightNormalized;
31853
- const radius = this.nodeRadius * scale * weightScale;
31854
- if (distance < 1e-6) return radius;
31855
- const arcLength = n.angularShare * distance;
31856
- return Math.min(radius, Math.max(arcLength * .35, .003));
31857
- }
31858
- /**
31859
- * Which nodes get a rendered label, greedily thinned to avoid overlap: a
31860
- * per-node radius threshold alone ({@link minNodeRadius}) still lets many
31861
- * same-sized nodes crowded into a small angular wedge all show labels that
31862
- * visually collide. Nodes are considered in priority order (closer ring
31863
- * first, then larger on-screen radius) and a label is kept only if its
31864
- * position isn't too close to an already-kept one.
31865
- */
31866
- #computeLabelKeys(nodes) {
31867
- const minSpacing = .07;
31868
- const prioritised = [...nodes.filter((n) => this.#nodeRenderRadius(n) >= this.minNodeRadius)].sort((a, b) => {
31869
- if (a.ring !== b.ring) return a.ring - b.ring;
31870
- return Math.hypot(a.position.x, a.position.y) - Math.hypot(b.position.x, b.position.y);
33616
+ #handleNodePointerDown(event) {
33617
+ event.stopPropagation();
33618
+ this.#focusTree();
33619
+ }
33620
+ #pickPreviewChild(child) {
33621
+ this.#preview.hide();
33622
+ if (this.interactionMode !== `manual`) this.select(child);
33623
+ const id = this.#idOf(child);
33624
+ if (id !== void 0) this.tickled.setTickled(id);
33625
+ this.focusOn(child);
33626
+ this.#focusTree();
33627
+ }
33628
+ #previewFor(nodes) {
33629
+ const n = this._previewId === void 0 || this.disableChildPreview ? void 0 : nodes.find((v) => v.id === this._previewId);
33630
+ if (!n) return void 0;
33631
+ const { width, height } = this._hostSize;
33632
+ const anchor = diskToHostPx(n.position, this.#zoomState(), width || 420, height || 420);
33633
+ return {
33634
+ node: n.node,
33635
+ anchor,
33636
+ above: anchor.y > (height || 420) / 2
33637
+ };
33638
+ }
33639
+ #nodeContext(nodes, branches) {
33640
+ return buildNodeRenderContext(nodes, {
33641
+ nodeShape: this.nodeShape,
33642
+ radialLabels: !this.disableRadialLabels,
33643
+ zoom: this.#zoomState().zoom,
33644
+ font: fontMetrics(this, Math.min(this._hostSize.width, this._hostSize.height), this.zoom),
33645
+ branches,
33646
+ boxMaxChars: this.boxMaxChars,
33647
+ minLabelSize: this.minLabelSize,
33648
+ nodeRadius: this.nodeRadius,
33649
+ minNodeRadius: this.minNodeRadius,
33650
+ minWeightScale: this.minWeightScale,
33651
+ maxWeightScale: this.maxWeightScale,
33652
+ isSelected: (node) => this._selectedNodes.has(node),
33653
+ isTickled: (id) => this.tickled.isTickled(id)
31871
33654
  });
31872
- const kept = [];
31873
- const keys = /* @__PURE__ */ new Set();
31874
- for (const n of prioritised) if (!kept.some((k) => Math.hypot(k.position.x - n.position.x, k.position.y - n.position.y) < minSpacing)) {
31875
- kept.push(n);
31876
- keys.add(n.key);
31877
- }
31878
- return keys;
31879
33655
  }
31880
- #renderNode(n, siblingGroups, labelKeys) {
31881
- const { node, key, position } = n;
31882
- const selected = this._selectedNodes.has(node);
31883
- const tickled = this.tickled.isTickled(key);
31884
- const filtered = !this.#matchesFilter(node, n.depth);
31885
- const pending = this.#pendingKeys.has(key);
31886
- const isFocused = n.ring === 0;
31887
- const radius = this.#nodeRenderRadius(n);
31888
- const showLabel = labelKeys.has(key);
31889
- const siblings = siblingGroups.get(n.parentKey) ?? [n];
31890
- const index = siblings.findIndex((s) => s.key === key);
31891
- const scaledRadius = radius * RENDER_SCALE;
31892
- const weightStyle = `--node-weight: ${n.weightNormalized};${n.weight === void 0 ? `` : ` --node-weight-raw: ${n.weight};`}`;
31893
- return svg`
31894
- <g
31895
- class="node ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${filtered ? `filtered` : ``} ${pending ? `loading` : ``} ${isFocused ? `focused` : ``}"
31896
- data-key=${key}
31897
- role="treeitem"
31898
- tabindex="-1"
31899
- aria-level=${n.depth + 1}
31900
- aria-selected=${selected}
31901
- aria-setsize=${siblings.length}
31902
- aria-posinset=${index + 1}
31903
- aria-label=${node.item.label}
31904
- part="node node-${partToken(key)}"
31905
- style=${weightStyle}
31906
- transform="translate(${position.x * RENDER_SCALE} ${position.y * RENDER_SCALE})"
31907
- @pointerdown=${(e) => e.stopPropagation()}
31908
- @click=${(e) => {
31909
- e.stopPropagation();
31910
- this.#handleItemClick(n, e);
31911
- }}
31912
- @dblclick=${(e) => {
31913
- e.stopPropagation();
31914
- this.#handleItemDblClick(n);
31915
- }}
31916
- @pointerenter=${() => this.tickled.handlePointerEnter(key)}
31917
- >
31918
- <circle class="node-hit" r=${Math.max(scaledRadius, .035 * RENDER_SCALE)} part="hit" />
31919
- <circle class="node-dot" r=${scaledRadius} part="dot" />
31920
- ${showLabel ? svg`<text class="hyperbolictree-label" part="label" x=${scaledRadius + .01 * RENDER_SCALE} y="0" dominant-baseline="middle">${node.item.label}</text>` : nothing}
31921
- </g>
31922
- `;
33656
+ #renderNodes(nodes, ctx) {
33657
+ return renderNodes(nodes, this.nodeShape, {
33658
+ ctx,
33659
+ state: {
33660
+ isSelected: (node) => this._selectedNodes.has(node),
33661
+ isTickled: (id) => this.tickled.isTickled(id),
33662
+ showTitle: (n) => this.disableChildPreview || !n.node.children?.length,
33663
+ isFiltered: (n) => !this.#matchesFilter(n.node, n.depth),
33664
+ isPending: (node) => this.#lazy.isPending(node)
33665
+ },
33666
+ handlers: {
33667
+ onPointerDown: (e) => this.#handleNodePointerDown(e),
33668
+ onClick: (n, e) => {
33669
+ e.stopPropagation();
33670
+ this.#handleItemClick(n, e);
33671
+ },
33672
+ onDblClick: (n, e) => {
33673
+ e.stopPropagation();
33674
+ this.#handleItemDblClick(n);
33675
+ },
33676
+ onPointerEnter: (id) => this.tickled.handlePointerEnter(id)
33677
+ }
33678
+ });
31923
33679
  }
31924
33680
  render() {
31925
33681
  const { nodes, edges } = this.#layout();
31926
- const byKey = new Map(nodes.map((n) => [n.key, n]));
31927
- const siblingGroups = /* @__PURE__ */ new Map();
31928
- for (const n of nodes) {
31929
- const group = siblingGroups.get(n.parentKey) ?? [];
31930
- group.push(n);
31931
- siblingGroups.set(n.parentKey, group);
31932
- }
31933
- const labelKeys = this.#computeLabelKeys(nodes);
33682
+ const byId = new Map(nodes.map((n) => [n.id, n]));
33683
+ const branches = branchIndices(this.#effectiveRoot);
33684
+ const ctx = this.#nodeContext(nodes, branches);
33685
+ this.#lastLabelIds = ctx.labelIds;
33686
+ const { zoom, offset } = this.#zoomState();
33687
+ const pan = offset.x === 0 && offset.y === 0 ? `` : `translate(${offset.x * 500} ${offset.y * 500}) `;
31934
33688
  return html`
31935
33689
  <svg
31936
33690
  class="hyperbolictree"
31937
- viewBox="${-1.05 * RENDER_SCALE} ${-1.05 * RENDER_SCALE} ${2.1 * RENDER_SCALE} ${2.1 * RENDER_SCALE}"
33691
+ viewBox="${-525} ${-525} ${1050} ${1050}"
31938
33692
  preserveAspectRatio="xMidYMid meet"
31939
33693
  role="tree"
31940
33694
  tabindex="0"
31941
33695
  data-input-mode=${this.tickled.inputMode}
33696
+ data-node-shape=${this.nodeShape}
31942
33697
  @keydown=${(e) => this.#handleKeyDown(e)}
31943
- @pointerdown=${(e) => this.#handleBackgroundPointerDown(e)}
31944
- @pointermove=${(e) => this.#handleBackgroundPointerMove(e)}
31945
- @pointerup=${(e) => this.#handleBackgroundPointerUp(e)}
33698
+ @pointerdown=${(e) => this.#gestures.onPointerDown(e)}
33699
+ @pointermove=${(e) => this.#gestures.onPointerMove(e)}
33700
+ @pointerup=${(e) => this.#gestures.onPointerUp(e)}
33701
+ @pointercancel=${(e) => this.#gestures.onPointerUp(e)}
33702
+ @wheel=${(e) => this.#gestures.onWheel(e)}
31946
33703
  @pointerleave=${() => this.tickled.clearTickled()}
31947
33704
  >
31948
- <g class="zoom" transform="scale(${this.zoom})">
31949
- <circle class="disk-boundary" part="boundary" cx="0" cy="0" r=${RENDER_SCALE} />
33705
+ <g class="zoom" transform="${pan}scale(${zoom})">
33706
+ <circle class="disk-boundary" part="boundary" cx="0" cy="0" r=${500} />
31950
33707
  <g class="edges" part="edges">
31951
- ${repeat(edges, (e) => `${e.fromKey}->${e.toKey}`, (e) => this.#renderEdge(e, byKey))}
33708
+ ${renderEdges(edges, byId, branches, this.edgeRenderer)}
31952
33709
  </g>
31953
33710
  <g class="nodes" part="nodes">
31954
- ${repeat(nodes, (n) => n.key, (n) => this.#renderNode(n, siblingGroups, labelKeys))}
33711
+ ${this.#renderNodes(nodes, ctx)}
31955
33712
  </g>
31956
33713
  </g>
31957
33714
  </svg>
33715
+ ${renderChildPreview(this.#previewFor(nodes), {
33716
+ onPointerEnter: () => this.#preview.pointerEnter(),
33717
+ onPointerLeave: () => this.#preview.pointerLeave(this.tickled.tickledId),
33718
+ onPick: (child) => this.#pickPreviewChild(child)
33719
+ })}
31958
33720
  ${this.search.renderField()}
31959
33721
  `;
31960
33722
  }
@@ -31962,102 +33724,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31962
33724
  this.styles = [
31963
33725
  themeFallbacks,
31964
33726
  listSearchStyles,
31965
- css`
31966
- :host {
31967
- --hyperbolictree-node-color: var(--surface-4);
31968
- --hyperbolictree-node-text: var(--surface-3-text);
31969
- --hyperbolictree-edge-color: var(--surface-6);
31970
- --hyperbolictree-boundary-color: var(--border-subtle);
31971
- --hyperbolictree-font-size: 25px;
31972
-
31973
- display: block;
31974
- overflow: hidden;
31975
- outline: none;
31976
- overscroll-behavior: contain;
31977
- }
31978
-
31979
- /*
31980
- * delegatesFocus means the host itself reports as focused whenever
31981
- * the shadow DOM's focused descendant (the svg) is — without this,
31982
- * the host also picks up the browser's own default focus ring/fill
31983
- * sized to the whole box.
31984
- */
31985
- :host(:focus),
31986
- :host(:focus-visible) {
31987
- outline: none;
31988
- }
31989
-
31990
- .hyperbolictree {
31991
- display: block;
31992
- width: 100%;
31993
- height: 100%;
31994
- outline: none;
31995
- touch-action: none;
31996
- overscroll-behavior: contain;
31997
- }
31998
-
31999
- .disk-boundary {
32000
- fill: none;
32001
- stroke: var(--hyperbolictree-boundary-color);
32002
- stroke-width: 1;
32003
- }
32004
-
32005
- .edge {
32006
- fill: none;
32007
- stroke: var(--hyperbolictree-edge-color);
32008
- /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
32009
- stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
32010
- }
32011
-
32012
- .node {
32013
- cursor: pointer;
32014
- }
32015
-
32016
- .node-hit {
32017
- fill: transparent;
32018
- pointer-events: all;
32019
- }
32020
-
32021
- .node-dot {
32022
- /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
32023
- fill: color-mix(in oklab, var(--hyperbolictree-node-color), var(--accent) calc(var(--node-weight, 0.5) * 50%));
32024
- stroke: var(--surface-2);
32025
- stroke-width: 1;
32026
- pointer-events: none;
32027
- }
32028
-
32029
- .node.tickled .node-dot {
32030
- fill: var(--item-bg-tickled);
32031
- }
32032
-
32033
- .node.selected .node-dot {
32034
- fill: var(--item-bg-selected);
32035
- }
32036
-
32037
- .node.selected.tickled .node-dot {
32038
- fill: var(--item-bg-selected-tickled);
32039
- }
32040
-
32041
- .node.focused .node-dot {
32042
- stroke: var(--accent);
32043
- stroke-width: 3;
32044
- }
32045
-
32046
- .node.filtered {
32047
- opacity: 0.3;
32048
- }
32049
-
32050
- .node.loading .node-dot {
32051
- opacity: 0.6;
32052
- }
32053
-
32054
- .hyperbolictree-label {
32055
- font-size: var(--hyperbolictree-font-size);
32056
- fill: var(--hyperbolictree-node-text);
32057
- cursor: pointer;
32058
- user-select: none;
32059
- }
32060
- `
33727
+ hyperbolicTreeStyles
32061
33728
  ];
32062
33729
  }
32063
33730
  };
@@ -32080,6 +33747,24 @@ __decorate([property({
32080
33747
  type: Number,
32081
33748
  attribute: `max-radius`
32082
33749
  })], HyperbolicTreeListElement.prototype, "maxRadius", void 0);
33750
+ __decorate([property({
33751
+ type: String,
33752
+ attribute: `node-shape`,
33753
+ reflect: true
33754
+ })], HyperbolicTreeListElement.prototype, "nodeShape", void 0);
33755
+ __decorate([property({
33756
+ type: Number,
33757
+ attribute: `box-max-chars`
33758
+ })], HyperbolicTreeListElement.prototype, "boxMaxChars", void 0);
33759
+ __decorate([property({
33760
+ type: Number,
33761
+ attribute: `min-label-size`
33762
+ })], HyperbolicTreeListElement.prototype, "minLabelSize", void 0);
33763
+ __decorate([property({
33764
+ type: Boolean,
33765
+ attribute: `color-by-branch`,
33766
+ reflect: true
33767
+ })], HyperbolicTreeListElement.prototype, "colorByBranch", void 0);
32083
33768
  __decorate([property({
32084
33769
  type: Number,
32085
33770
  attribute: `node-radius`
@@ -32097,7 +33782,23 @@ __decorate([property({
32097
33782
  attribute: `max-weight-scale`
32098
33783
  })], HyperbolicTreeListElement.prototype, "maxWeightScale", void 0);
32099
33784
  __decorate([property({ type: Number })], HyperbolicTreeListElement.prototype, "zoom", void 0);
33785
+ __decorate([property({
33786
+ type: Boolean,
33787
+ attribute: `disable-zoom-gestures`
33788
+ })], HyperbolicTreeListElement.prototype, "disableZoomGestures", void 0);
33789
+ __decorate([property({
33790
+ type: Boolean,
33791
+ attribute: `disable-radial-labels`
33792
+ })], HyperbolicTreeListElement.prototype, "disableRadialLabels", void 0);
33793
+ __decorate([property({
33794
+ type: Boolean,
33795
+ attribute: `disable-child-preview`
33796
+ })], HyperbolicTreeListElement.prototype, "disableChildPreview", void 0);
32100
33797
  __decorate([property({ type: Number })], HyperbolicTreeListElement.prototype, "curvature", void 0);
33798
+ __decorate([property({
33799
+ type: Number,
33800
+ attribute: `sibling-spacing`
33801
+ })], HyperbolicTreeListElement.prototype, "siblingSpacing", void 0);
32101
33802
  __decorate([property({
32102
33803
  type: Number,
32103
33804
  attribute: `focus-animation-duration`
@@ -32116,9 +33817,12 @@ __decorate([property({
32116
33817
  attribute: `search-modes`,
32117
33818
  reflect: true
32118
33819
  })], HyperbolicTreeListElement.prototype, "searchModes", void 0);
32119
- __decorate([state()], HyperbolicTreeListElement.prototype, "_focusKey", void 0);
32120
- __decorate([state()], HyperbolicTreeListElement.prototype, "_animCenter", void 0);
32121
- __decorate([state()], HyperbolicTreeListElement.prototype, "_panOffset", void 0);
33820
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_focusId", void 0);
33821
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_viewOverride", void 0);
33822
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_focusRotation", void 0);
33823
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_zoomOffset", void 0);
33824
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_previewId", void 0);
33825
+ __decorate([state()], HyperbolicTreeListElement.prototype, "_hostSize", void 0);
32122
33826
  HyperbolicTreeListElement = __decorate([safeCustomElement(`ixfx-hyperbolictree-list`)], HyperbolicTreeListElement);
32123
33827
  //#endregion
32124
33828
  //#region src/icicle-tree/layout.ts
@@ -37886,6 +39590,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37886
39590
  const open = this._isOpen(node, depth);
37887
39591
  const isBranch = node.item.isLeaf !== true;
37888
39592
  const icon = node.item.icon;
39593
+ const iconColor = appearanceColor(node.item.iconColor);
39594
+ const textColor = appearanceColor(node.item.textColor);
39595
+ const badge = appearanceAbsent(node.item.badge) ? void 0 : node.item.badge;
37889
39596
  const tooltip = node.item.tooltip;
37890
39597
  const isCheckedMode = this.interactionMode === `checked`;
37891
39598
  let checkboxTpl = nothing;
@@ -37913,8 +39620,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37913
39620
  @pointerenter=${() => this._handleItemPointerEnter(node, depth)}
37914
39621
  >
37915
39622
  ${checkboxTpl}
37916
- ${icon ? html`<ixfx-icon class="item-icon" name="${icon}"></ixfx-icon>` : nothing}
37917
- <span class="label">${node.item.label}</span>
39623
+ ${icon ? html`<ixfx-icon class="item-icon" name="${icon}" style=${iconColor ? `color:${iconColor}` : nothing}></ixfx-icon>` : nothing}
39624
+ <span class="label" style=${textColor ? `color:${textColor}` : nothing}>${node.item.label}</span>
39625
+ ${badge === void 0 ? nothing : html`<span class="item-badge" part="badge">${badge}</span>`}
37918
39626
  ${tooltip ? html`<ixfx-tooltip class="item-tooltip" anchor="--tooltip-miller-${depth}-${node.item.key}" .value=${tooltip}></ixfx-tooltip>` : nothing}
37919
39627
  ${isBranch ? html`<span class="caret" aria-hidden="true">›</span>` : nothing}
37920
39628
  </li>
@@ -37968,6 +39676,17 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37968
39676
  text-overflow: ellipsis;
37969
39677
  }
37970
39678
 
39679
+ /* Finder-style badge: sits between the label and the caret, flush to
39680
+ the right edge of the row. The label is flex:1, so the gap opens up
39681
+ ahead of the badge automatically. */
39682
+ .item-badge {
39683
+ flex-shrink: 0;
39684
+ display: inline-flex;
39685
+ align-items: center;
39686
+ margin-inline-start: auto;
39687
+ padding-inline-start: var(--miller-list-badge-gap, 0.5em);
39688
+ }
39689
+
37971
39690
  .item-tooltip {
37972
39691
  position: absolute;
37973
39692
  }
@@ -38512,6 +40231,7 @@ let MulticolListElement = class MulticolListElement extends LitElement {
38512
40231
  --multicol-list-border: var(--border);
38513
40232
  --multicol-list-min-column-width: ${MIN_COLUMN_WIDTH}px;
38514
40233
  --multicol-list-max-column-width: ${MAX_COLUMN_WIDTH}px;
40234
+ --multicol-list-header-font-size: unset;
38515
40235
 
38516
40236
  display: block;
38517
40237
  overflow: hidden;
@@ -38577,7 +40297,7 @@ let MulticolListElement = class MulticolListElement extends LitElement {
38577
40297
  text-overflow: ellipsis;
38578
40298
  white-space: nowrap;
38579
40299
  padding: 0 var(--space-s);
38580
- font-size: var(--text-m);
40300
+ font-size: var(--multicol-list-header-font-size);
38581
40301
  font-weight: 600;
38582
40302
  background: transparent;
38583
40303
  border: none;
@@ -42859,8 +44579,14 @@ let RangeInput = class RangeInput extends WheelNudgeMixin(LitElement) {
42859
44579
  const clamped = Math.min(this.max, Math.max(this.min, candidate));
42860
44580
  if (clamped === this.value) return;
42861
44581
  input.value = String(clamped);
42862
- input.dispatchEvent(new Event(`input`, { bubbles: true }));
42863
- input.dispatchEvent(new Event(`change`, { bubbles: true }));
44582
+ input.dispatchEvent(new CustomEvent(`input`, {
44583
+ detail: { value: clamped },
44584
+ bubbles: true
44585
+ }));
44586
+ input.dispatchEvent(new CustomEvent(`change`, {
44587
+ detail: { value: clamped },
44588
+ bubbles: true
44589
+ }));
42864
44590
  }
42865
44591
  render() {
42866
44592
  const tooltipEl = this.tooltip ? html`
@@ -44303,6 +46029,7 @@ let ShapefillValueElement = class ShapefillValueElement extends WheelNudgeMixin(
44303
46029
  this.#dragging = false;
44304
46030
  this.#dragStartY = 0;
44305
46031
  this.#valueAtDragStart = 0;
46032
+ this.#lastPointerY = 0;
44306
46033
  this.#onPointerDown = (event) => {
44307
46034
  if (this.disabled) return;
44308
46035
  event.preventDefault();
@@ -44310,7 +46037,9 @@ let ShapefillValueElement = class ShapefillValueElement extends WheelNudgeMixin(
44310
46037
  svgEl?.focus();
44311
46038
  this.#dragging = true;
44312
46039
  this.#dragStartY = event.clientY;
46040
+ this.#lastPointerY = event.clientY;
44313
46041
  this.#valueAtDragStart = this.value;
46042
+ this.#lastSetValue = this.value;
44314
46043
  this.toggleAttribute(`dragging`, true);
44315
46044
  if (svgEl) svgEl.setPointerCapture(event.pointerId);
44316
46045
  window.addEventListener(`pointermove`, this.#onPointerMove);
@@ -44320,10 +46049,16 @@ let ShapefillValueElement = class ShapefillValueElement extends WheelNudgeMixin(
44320
46049
  this.#onPointerMove = (event) => {
44321
46050
  if (!this.#dragging) return;
44322
46051
  event.preventDefault();
46052
+ if (this.value !== this.#lastSetValue) {
46053
+ this.#valueAtDragStart = this.value;
46054
+ this.#dragStartY = this.#lastPointerY;
46055
+ this.#lastSetValue = this.value;
46056
+ }
44323
46057
  const effectiveSensitivity = this.clientHeight * 2 * (event.shiftKey ? 10 : 1);
44324
46058
  const deltaValue = (this.#dragStartY - event.clientY) / effectiveSensitivity * (this.max - this.min);
44325
46059
  const relativeValue = scale(this.#valueAtDragStart + deltaValue, this.min, this.max, 0, 1);
44326
46060
  this.#setValueFromRelative(clampValue(relativeValue, 0, 1), true);
46061
+ this.#lastPointerY = event.clientY;
44327
46062
  };
44328
46063
  this.#onPointerUp = () => {
44329
46064
  this.#dragging = false;
@@ -44366,6 +46101,10 @@ let ShapefillValueElement = class ShapefillValueElement extends WheelNudgeMixin(
44366
46101
  #dragging;
44367
46102
  #dragStartY;
44368
46103
  #valueAtDragStart;
46104
+ /** Client Y of the last processed pointer event, for external-write re-baselining. */
46105
+ #lastPointerY;
46106
+ /** Value at the drag's last write — detects external `value` writes mid-drag. */
46107
+ #lastSetValue;
44369
46108
  #changeDebounceTimer;
44370
46109
  #getRelativeValue() {
44371
46110
  return scale(this.value, this.min, this.max, 0, 1);
@@ -44383,6 +46122,7 @@ let ShapefillValueElement = class ShapefillValueElement extends WheelNudgeMixin(
44383
46122
  if (clampedValue === this.value) return;
44384
46123
  const previousValue = this.value;
44385
46124
  this.value = clampedValue;
46125
+ this.#lastSetValue = clampedValue;
44386
46126
  if (this.hasAttribute(`data-tooltip-live`)) {
44387
46127
  const percentage = Math.round(this.#getRelativeValue() * 100);
44388
46128
  this.setAttribute(`data-tooltip`, `${percentage}%`);
@@ -51538,6 +53278,6 @@ function init() {
51538
53278
  console.log(`Init`);
51539
53279
  }
51540
53280
  //#endregion
51541
- 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 };
53281
+ 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 };
51542
53282
 
51543
53283
  //# sourceMappingURL=index.js.map