@ixfx/components 0.11.4 → 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 (104) hide show
  1. package/bundle/index.d.ts +442 -120
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +2230 -538
  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/{data-grid-CfiaBqTA.d.ts → data-grid-CPsJBnL4.d.ts} +48 -6
  15. package/dist/data-grid-CPsJBnL4.d.ts.map +1 -0
  16. package/dist/data-grid.d.ts +3 -3
  17. package/dist/data-grid.js +66 -2
  18. package/dist/data-grid.js.map +1 -1
  19. package/dist/{detail-list-DxQZkK-C.d.ts → detail-list-CmdCxddo.d.ts} +22 -5
  20. package/dist/detail-list-CmdCxddo.d.ts.map +1 -0
  21. package/dist/detail-list.d.ts +3 -3
  22. package/dist/detail-list.js +67 -4
  23. package/dist/detail-list.js.map +1 -1
  24. package/dist/{grid-list-k89JpiNy.js → grid-list-3fGhdaFA.js} +92 -7
  25. package/dist/grid-list-3fGhdaFA.js.map +1 -0
  26. package/dist/{grid-list-ChgYpUUq.d.ts → grid-list-D6AfWfwn.d.ts} +27 -4
  27. package/dist/grid-list-D6AfWfwn.d.ts.map +1 -0
  28. package/dist/grid-list.d.ts +2 -2
  29. package/dist/grid-list.js +1 -1
  30. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts → grouped-item-lister-DETte0Dh.d.ts} +2 -2
  31. package/dist/{grouped-item-lister-Bhyo-dkH.d.ts.map → grouped-item-lister-DETte0Dh.d.ts.map} +1 -1
  32. package/dist/grouped-item-lister.d.ts +1 -1
  33. package/dist/{icon-DR_3rSyZ.d.ts → icon-CDGprSdE.d.ts} +2 -2
  34. package/dist/icon-CDGprSdE.d.ts.map +1 -0
  35. package/dist/icons.d.ts +2 -2
  36. package/dist/incr-search.d.ts +1 -1
  37. package/dist/{index-CHbuPyLL.d.ts → index-CMsmZ6Or.d.ts} +4 -4
  38. package/dist/{index-CHbuPyLL.d.ts.map → index-CMsmZ6Or.d.ts.map} +1 -1
  39. package/dist/{index-garxxqgV.d.ts → index-CYndvTWH.d.ts} +2 -2
  40. package/dist/{index-garxxqgV.d.ts.map → index-CYndvTWH.d.ts.map} +1 -1
  41. package/dist/{index-CDllxjVG.d.ts → index-CnyoNA6m.d.ts} +4 -4
  42. package/dist/{index-CDllxjVG.d.ts.map → index-CnyoNA6m.d.ts.map} +1 -1
  43. package/dist/{index-BUB5SICW.d.ts → index-CoqtMSd9.d.ts} +2 -2
  44. package/dist/{index-BUB5SICW.d.ts.map → index-CoqtMSd9.d.ts.map} +1 -1
  45. package/dist/{index-CJ5fshD3.d.ts → index-UvnoZUAo.d.ts} +2 -2
  46. package/dist/{index-CJ5fshD3.d.ts.map → index-UvnoZUAo.d.ts.map} +1 -1
  47. package/dist/{index-BOUaDIW-.d.ts → index-tq6f8007.d.ts} +2 -2
  48. package/dist/{index-BOUaDIW-.d.ts.map → index-tq6f8007.d.ts.map} +1 -1
  49. package/dist/index.d.ts +337 -140
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2036 -644
  52. package/dist/index.js.map +1 -1
  53. package/dist/item-appearance-BjcMyauw.js +31 -0
  54. package/dist/item-appearance-BjcMyauw.js.map +1 -0
  55. package/dist/{labelled-input-base-Dp_9KP0G.d.ts → labelled-input-base-DiTme1JO.d.ts} +2 -2
  56. package/dist/{labelled-input-base-Dp_9KP0G.d.ts.map → labelled-input-base-DiTme1JO.d.ts.map} +1 -1
  57. package/dist/labelled-radial-input.d.ts +2 -2
  58. package/dist/labelled-radial-input.d.ts.map +1 -1
  59. package/dist/labelled-range-input.d.ts +1 -1
  60. package/dist/labelled-slider-input.d.ts +1 -1
  61. package/dist/{list-native-drag-controller-B9reY4uM.d.ts → list-native-drag-controller-CllsHLxw.d.ts} +2 -2
  62. package/dist/{list-native-drag-controller-B9reY4uM.d.ts.map → list-native-drag-controller-CllsHLxw.d.ts.map} +1 -1
  63. package/dist/{menu-item-DlxAb0Xr.d.ts → menu-item-DT7bKAIw.d.ts} +2 -2
  64. package/dist/{menu-item-DlxAb0Xr.d.ts.map → menu-item-DT7bKAIw.d.ts.map} +1 -1
  65. package/dist/menu.d.ts +2 -2
  66. package/dist/miller.d.ts +3 -3
  67. package/dist/miller.d.ts.map +1 -1
  68. package/dist/miller.js +18 -2
  69. package/dist/miller.js.map +1 -1
  70. package/dist/panel.d.ts +1 -1
  71. package/dist/{split-layout-C5SYbDrq.d.ts → split-layout-JK3xWVX2.d.ts} +2 -2
  72. package/dist/{split-layout-C5SYbDrq.d.ts.map → split-layout-JK3xWVX2.d.ts.map} +1 -1
  73. package/dist/split-layout.d.ts +1 -1
  74. package/dist/{tab-list-v8mKX7Fu.d.ts → tab-list-pqfKaVAE.d.ts} +2 -2
  75. package/dist/{tab-list-v8mKX7Fu.d.ts.map → tab-list-pqfKaVAE.d.ts.map} +1 -1
  76. package/dist/tabs.d.ts +1 -1
  77. package/dist/{titlebar-B5zSTZnF.d.ts → titlebar-Tqu-VvMU.d.ts} +2 -2
  78. package/dist/{titlebar-B5zSTZnF.d.ts.map → titlebar-Tqu-VvMU.d.ts.map} +1 -1
  79. package/dist/titlebar.d.ts +1 -1
  80. package/dist/{tree-component-CMGeqyjZ.d.ts → tree-component-DukLqesg.d.ts} +2 -2
  81. package/dist/{tree-component-CMGeqyjZ.d.ts.map → tree-component-DukLqesg.d.ts.map} +1 -1
  82. package/dist/tree.d.ts +4 -4
  83. package/dist/{types-3Jfcaxvo.d.ts → types-D-kYF16b.d.ts} +21 -1
  84. package/dist/types-D-kYF16b.d.ts.map +1 -0
  85. package/dist/{vertical-list-R25WqAVO.d.ts → vertical-list-DTp-UIUE.d.ts} +26 -3
  86. package/dist/vertical-list-DTp-UIUE.d.ts.map +1 -0
  87. package/dist/vertical-list.d.ts +2 -2
  88. package/dist/vertical-list.js +53 -4
  89. package/dist/vertical-list.js.map +1 -1
  90. package/docs-user/data-grid.md +51 -1
  91. package/docs-user/detail-list.md +46 -0
  92. package/docs-user/grid-list.md +49 -2
  93. package/docs-user/hyperbolic-tree.md +98 -18
  94. package/docs-user/index.json +2 -2
  95. package/docs-user/miller.md +52 -7
  96. package/docs-user/vertical-list.md +63 -0
  97. package/package.json +1 -1
  98. package/dist/data-grid-CfiaBqTA.d.ts.map +0 -1
  99. package/dist/detail-list-DxQZkK-C.d.ts.map +0 -1
  100. package/dist/grid-list-ChgYpUUq.d.ts.map +0 -1
  101. package/dist/grid-list-k89JpiNy.js.map +0 -1
  102. package/dist/icon-DR_3rSyZ.d.ts.map +0 -1
  103. package/dist/types-3Jfcaxvo.d.ts.map +0 -1
  104. package/dist/vertical-list-R25WqAVO.d.ts.map +0 -1
package/bundle/index.js CHANGED
@@ -12013,6 +12013,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12013
12013
  this.path = ``;
12014
12014
  this.separator = `/`;
12015
12015
  this.pathRoot = ``;
12016
+ this.hintPrevious = false;
12016
12017
  this._openIdx = null;
12017
12018
  this._editing = false;
12018
12019
  this._cachedSiblings = /* @__PURE__ */ new Map();
@@ -12024,6 +12025,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12024
12025
  this._openCumPath = null;
12025
12026
  this._dragCumPath = null;
12026
12027
  this._dropCumPath = null;
12028
+ this._hintSegments = null;
12027
12029
  this.#uid = Math.random().toString(36).slice(2, 8);
12028
12030
  this.#clickSuppressUntil = 0;
12029
12031
  this.#onPopoverToggle = (e) => {
@@ -12159,6 +12161,19 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12159
12161
  #clicksSuppressed() {
12160
12162
  return Date.now() < this.#clickSuppressUntil || this._dragCumPath !== null;
12161
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
+ }
12162
12177
  #resolveSegmentIcon(fullPathSegments, index) {
12163
12178
  const direct = this.segmentIconLookup?.(fullPathSegments, index);
12164
12179
  if (typeof direct === `string` && direct.length > 0) return direct;
@@ -12304,12 +12319,63 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12304
12319
  composed: true
12305
12320
  }));
12306
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
+ }
12307
12371
  #onLabelClick(index, event) {
12308
12372
  if (this.#clicksSuppressed()) return;
12309
12373
  event.preventDefault();
12310
12374
  event.stopPropagation();
12311
12375
  this.#closeDropdown();
12312
- 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);
12313
12379
  const hadLeading = this.path.startsWith(this.separator);
12314
12380
  const newPath = truncatedSegments.join(this.separator);
12315
12381
  this.path = hadLeading ? this.separator + newPath : newPath;
@@ -12327,6 +12393,31 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12327
12393
  composed: true
12328
12394
  }));
12329
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
+ }
12330
12421
  #selectSibling(index, segment) {
12331
12422
  const cumPath = this._openCumPath;
12332
12423
  this.#closeDropdown();
@@ -12402,7 +12493,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12402
12493
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12403
12494
  <label title=${label}
12404
12495
  part="label"
12405
- ?draggable=${draggable}
12496
+ draggable=${draggable ? `true` : `false`}
12406
12497
  @click=${(e) => this.#onLabelClick(index, e)}
12407
12498
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12408
12499
  @dragend=${() => this.#onLabelDragEnd()}>
@@ -12419,6 +12510,26 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12419
12510
  </div>
12420
12511
  ` : nothing}
12421
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>
12422
12533
  `;
12423
12534
  }
12424
12535
  #renderRootSegment(label, index) {
@@ -12440,7 +12551,7 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12440
12551
  @drop=${(e) => this.#onSegDrop(fullPathSegments, e)}>
12441
12552
  <label title=${label}
12442
12553
  part="label"
12443
- ?draggable=${draggable}
12554
+ draggable=${draggable ? `true` : `false`}
12444
12555
  @click=${(e) => this.#onLabelClick(index, e)}
12445
12556
  @dragstart=${(e) => this.#onLabelDragStart(fullPathSegments, cumPath, e)}
12446
12557
  @dragend=${() => this.#onLabelDragEnd()}>${icon ? html`<ixfx-icon class="crumb-icon segment-icon" name=${icon}></ixfx-icon>` : nothing}${label}</label>
@@ -12546,6 +12657,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12546
12657
  #onHostKeyDown;
12547
12658
  render() {
12548
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) : [];
12549
12662
  if (this._editing) {
12550
12663
  const editValue = this.path;
12551
12664
  return html`
@@ -12568,10 +12681,15 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12568
12681
  ${this.separator}
12569
12682
  </div>` : nothing;
12570
12683
  return html`
12571
- <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)}>
12572
12689
  ${rootSegments.length > 0 ? rootSegments.map((label, i) => this.#renderRootSegment(label, i)) : nothing}
12573
12690
  ${leadingCaret}
12574
12691
  ${segments.map((label, i) => this.#renderSegment(label, i, i === segments.length - 1))}
12692
+ ${virtualSegments.map((label, i) => this.#renderVirtualSegment(label, segments.length + i))}
12575
12693
  <div class="trailing" @click=${this.#onTrailingClick}></div>
12576
12694
  </div>
12577
12695
  ${this._openIdx !== null ? html`
@@ -12598,6 +12716,8 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12598
12716
  --padding-m: var(--space-s);
12599
12717
  --hover-bg: var(--surface-5);
12600
12718
  --hover-fg: var(--surface-5-text);
12719
+ --crumb-hint-opacity: 0.35;
12720
+ --crumb-hint-hover-opacity: 0.6;
12601
12721
 
12602
12722
  display: flex;
12603
12723
  width: 100%;
@@ -12709,6 +12829,14 @@ let CrumbPathElement = class CrumbPathElement extends LitElement {
12709
12829
  background: var(--crumb-drop-into-bg, color-mix(in srgb, var(--accent) 14%, transparent));
12710
12830
  }
12711
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
+
12712
12840
  .crumb-icon {
12713
12841
  width: 1em;
12714
12842
  height: 1em;
@@ -12793,6 +12921,10 @@ __decorate([property({ type: Object })], CrumbPathElement.prototype, "siblingLoo
12793
12921
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentIconLookup", void 0);
12794
12922
  __decorate([property({ type: Object })], CrumbPathElement.prototype, "segmentDragLookup", void 0);
12795
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);
12796
12928
  __decorate([state()], CrumbPathElement.prototype, "_openIdx", void 0);
12797
12929
  __decorate([state()], CrumbPathElement.prototype, "_editing", void 0);
12798
12930
  __decorate([state()], CrumbPathElement.prototype, "_cachedSiblings", void 0);
@@ -12804,6 +12936,7 @@ __decorate([state()], CrumbPathElement.prototype, "_editStartIndex", void 0);
12804
12936
  __decorate([state()], CrumbPathElement.prototype, "_openCumPath", void 0);
12805
12937
  __decorate([state()], CrumbPathElement.prototype, "_dragCumPath", void 0);
12806
12938
  __decorate([state()], CrumbPathElement.prototype, "_dropCumPath", void 0);
12939
+ __decorate([state()], CrumbPathElement.prototype, "_hintSegments", void 0);
12807
12940
  CrumbPathElement = __decorate([safeCustomElement(`ixfx-crumb-path`)], CrumbPathElement);
12808
12941
  //#endregion
12809
12942
  //#region src/data-display/data-display.ts
@@ -13127,6 +13260,34 @@ let DataDisplayComponent = class DataDisplayComponent extends LitElement {
13127
13260
  __decorate([property({ type: Boolean })], DataDisplayComponent.prototype, "automap", void 0);
13128
13261
  DataDisplayComponent = __decorate([safeCustomElement(`ixfx-data-display`)], DataDisplayComponent);
13129
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
13130
13291
  //#region src/util/drag-ghost.ts
13131
13292
  /**
13132
13293
  * Theme-aware floating element helper.
@@ -14251,6 +14412,12 @@ __decorate([query(`.text`)], PanTextElement.prototype, "_textEl", void 0);
14251
14412
  PanTextElement = __decorate([safeCustomElement(`ixfx-pan-text`)], PanTextElement);
14252
14413
  //#endregion
14253
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
+ };
14254
14421
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
14255
14422
  function sameKeys$3(a, b) {
14256
14423
  if (a === void 0 || a.length !== b.length) return false;
@@ -14275,6 +14442,9 @@ let DataGridElement = class DataGridElement extends LitElement {
14275
14442
  this.allowGrouping = false;
14276
14443
  this.crossGroupSelection = false;
14277
14444
  this.columnMenu = `auto`;
14445
+ this.textColorColumn = ``;
14446
+ this.iconColumn = ``;
14447
+ this.badgeColumn = ``;
14278
14448
  this.itemsDraggable = false;
14279
14449
  this._columnFormatters = /* @__PURE__ */ new Map();
14280
14450
  this._resizingColKey = void 0;
@@ -14694,6 +14864,25 @@ let DataGridElement = class DataGridElement extends LitElement {
14694
14864
  }
14695
14865
  .cell.editable-text { cursor: text; }
14696
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
+ }
14697
14886
  .checkbox-col { width: 28px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
14698
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; }
14699
14888
  .header-cell[drag-source] { opacity: 0.4; }
@@ -15465,6 +15654,27 @@ let DataGridElement = class DataGridElement extends LitElement {
15465
15654
  if (this.globalFormatter) return this.globalFormatter(value, col.key, row);
15466
15655
  return value;
15467
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
+ }
15468
15678
  _handleCellSecondClick(rowKey, col) {
15469
15679
  if (this.editability !== `user`) return;
15470
15680
  const now = Date.now();
@@ -15922,7 +16132,9 @@ let DataGridElement = class DataGridElement extends LitElement {
15922
16132
  const textEditable = this._isEditable(row, col) && this._isTextEditor(col);
15923
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));` : ``;
15924
16134
  const canPan = this.cellOverflow === `pan-text` && !col.renderer && (selected || tickled);
15925
- return html`<div class="cell ${textEditable ? `editable-text` : ``} ${this._animatingColKeys.has(col.key) ? `width-animating` : ``}" data-col=${col.key} style="width:${col.width ?? 150}px; justify-content:${col.align === `right` ? `flex-end` : col.align === `center` ? `center` : `flex-start`}; ${indent}"
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}"
15926
16138
  @click=${(e) => {
15927
16139
  this._lastClickedCell = {
15928
16140
  rowKey,
@@ -15944,7 +16156,7 @@ let DataGridElement = class DataGridElement extends LitElement {
15944
16156
  composed: true
15945
16157
  }));
15946
16158
  }}
15947
- >${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>`;
15948
16160
  })}
15949
16161
  </div>
15950
16162
  `;
@@ -16004,6 +16216,18 @@ __decorate([property({
16004
16216
  attribute: `column-menu`
16005
16217
  })], DataGridElement.prototype, "columnMenu", void 0);
16006
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);
16007
16231
  __decorate([property({ attribute: false })], DataGridElement.prototype, "filterPredicate", void 0);
16008
16232
  __decorate([property({ attribute: false })], DataGridElement.prototype, "rowSearchText", void 0);
16009
16233
  __decorate([property({
@@ -16304,6 +16528,14 @@ DataTableElement = __decorate([safeCustomElement(`ixfx-data-table`)], DataTableE
16304
16528
  const ITEM_SELECTOR$3 = `:scope > li`;
16305
16529
  const SEARCH_HIGHLIGHT_KEY = `detail-list-search`;
16306
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)`;
16307
16539
  function pointInRect(x, y, rect) {
16308
16540
  return rect.width > 0 && rect.height > 0 && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
16309
16541
  }
@@ -16337,6 +16569,9 @@ let DetailListElement = class DetailListElement extends LitElement {
16337
16569
  this.items = [];
16338
16570
  this.displayProperty = ``;
16339
16571
  this.iconProperty = ``;
16572
+ this.iconColorProperty = ``;
16573
+ this.textColorProperty = ``;
16574
+ this.badgeProperty = ``;
16340
16575
  this.keyProperty = ``;
16341
16576
  this.itemWrap = `stop`;
16342
16577
  this.itemsDraggable = false;
@@ -16538,7 +16773,8 @@ let DetailListElement = class DetailListElement extends LitElement {
16538
16773
  };
16539
16774
  this._onWheel = (e) => {
16540
16775
  e.preventDefault();
16541
- 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;
16542
16778
  };
16543
16779
  }
16544
16780
  #itemData;
@@ -16743,7 +16979,7 @@ let DetailListElement = class DetailListElement extends LitElement {
16743
16979
  });
16744
16980
  return;
16745
16981
  }
16746
- 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);
16747
16983
  this.#syncFromProvider();
16748
16984
  if (changed.has(`sort`) || changed.has(`sortBy`) || changed.has(`sortComparator`)) this.#applySort();
16749
16985
  if (changed.has(`filterPredicate`)) {
@@ -16948,24 +17184,36 @@ let DetailListElement = class DetailListElement extends LitElement {
16948
17184
  const li = document.createElement(`li`);
16949
17185
  this.#itemData.set(li, item);
16950
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);
16951
17190
  if (typeof content === `string`) {
16952
- this._setLabel(li, content);
17191
+ const span = this._setLabel(li, content);
17192
+ if (textColor !== void 0) span.style.color = textColor;
16953
17193
  if (this.iconProperty) {
16954
17194
  const icon = this.#propertyValue(item, this.iconProperty);
16955
17195
  if (typeof icon === `string` && icon.length > 0) li.setAttribute(`data-icon`, icon);
16956
17196
  }
16957
- } 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
+ }
16958
17201
  if (this.keyProperty) {
16959
17202
  const key = this.#propertyValue(item, this.keyProperty);
16960
17203
  if (key !== void 0 && key !== null) li.setAttribute(`data-key`, String(key));
16961
17204
  }
16962
17205
  this.#ensureIcon(li);
17206
+ this.#ensureBadge(li, this.#propertyValue(item, this.badgeProperty));
16963
17207
  return li;
16964
17208
  }
16965
17209
  #propertyValue(item, prop) {
16966
17210
  if (typeof item !== `object` || item === null || !(prop in item)) return void 0;
16967
17211
  return item[prop];
16968
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
+ }
16969
17217
  #syncFromProvider() {
16970
17218
  if (this.provider === void 0) return;
16971
17219
  const items = this.#data.items;
@@ -17223,12 +17471,14 @@ let DetailListElement = class DetailListElement extends LitElement {
17223
17471
  if (this.interactionMode !== `checked`) return true;
17224
17472
  return !item.hasAttribute(`not-checked`);
17225
17473
  }
17474
+ /** Appends the caption span; returns it so callers can style it per item. */
17226
17475
  _setLabel(li, label) {
17227
17476
  const span = document.createElement(`span`);
17228
17477
  span.style.cssText = LABEL_SPAN_CSS;
17229
17478
  span.textContent = label;
17230
17479
  li.appendChild(span);
17231
17480
  li.setAttribute(`data-search-label`, label);
17481
+ return span;
17232
17482
  }
17233
17483
  /**
17234
17484
  * Inject a leading `<ixfx-icon>` into an item carrying `data-icon`.
@@ -17242,8 +17492,28 @@ let DetailListElement = class DetailListElement extends LitElement {
17242
17492
  icon.setAttribute(`name`, name);
17243
17493
  icon.style.marginInlineEnd = `var(--detail-list-icon-gap, 6px)`;
17244
17494
  icon.style.flexShrink = `0`;
17495
+ const color = li.getAttribute(`data-icon-color`);
17496
+ if (color) icon.style.color = color;
17245
17497
  li.prepend(icon);
17246
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
+ }
17247
17517
  /** Scan light-DOM children for unprocessed `data-icon` items. */
17248
17518
  #processChildren() {
17249
17519
  for (const el of this.querySelectorAll(ITEM_SELECTOR$3)) this.#ensureIcon(el);
@@ -17517,6 +17787,10 @@ let DetailListElement = class DetailListElement extends LitElement {
17517
17787
  opacity: 0.6;
17518
17788
  }
17519
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
+
17520
17794
  /* checked mode: the injected checkbox communicates selection — suppress bg */
17521
17795
  :host([interaction-mode="checked"]) ::slotted([data-selected]) {
17522
17796
  background: transparent;
@@ -17591,6 +17865,18 @@ __decorate([property({
17591
17865
  type: String,
17592
17866
  attribute: `icon-property`
17593
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);
17594
17880
  __decorate([property({
17595
17881
  type: String,
17596
17882
  attribute: `key-property`
@@ -23322,6 +23608,10 @@ var ElementGridListSource = class {
23322
23608
  if (eff === void 0) return void 0;
23323
23609
  return html`<slot name=${this.slotNameFor(this.#items[eff])}></slot>`;
23324
23610
  }
23611
+ iconAt(_index) {}
23612
+ iconColorAt(_index) {}
23613
+ textColorAt(_index) {}
23614
+ badgeAt(_index) {}
23325
23615
  setFilters(opts) {
23326
23616
  if (`predicate` in opts) this.#predicate = opts.predicate;
23327
23617
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23438,6 +23728,22 @@ var DataGridListSource = class {
23438
23728
  const eff = this.#effectiveIndices[index];
23439
23729
  return eff === void 0 ? void 0 : this.#adapter.render(this.#items[eff], eff);
23440
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
+ }
23441
23747
  setFilters(opts) {
23442
23748
  if (`predicate` in opts) this.#predicate = opts.predicate;
23443
23749
  if (`searchKeys` in opts) this.#searchKeys = opts.searchKeys;
@@ -23978,6 +24284,50 @@ let GridListElement = class GridListElement extends LitElement {
23978
24284
  color: inherit;
23979
24285
  }
23980
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
+
23981
24331
  /* ---- Box modes (hidden / under / overlaid): the cell is one box with a
23982
24332
  single selection outline ---- */
23983
24333
  :host(:not([caption-style="separate"])) .cell {
@@ -24986,6 +25336,8 @@ let GridListElement = class GridListElement extends LitElement {
24986
25336
  const x = win.horizontal ? index * win.colStride : columnOf(index, win.cols) * win.colStride;
24987
25337
  const y = win.horizontal ? Math.max(0, (this._viewportHeight - cellHeight) / 2) : rowOf(index, win.cols) * win.rowStride;
24988
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;
24989
25341
  return html`
24990
25342
  <div
24991
25343
  class="cell${selected ? ` selected` : ``}${tickled ? ` tickled` : ``}${marquee ? ` marquee` : ``}${this.#nativeDragging && key === this.#dragOriginKey ? ` dragging-origin` : ``}"
@@ -25003,8 +25355,11 @@ let GridListElement = class GridListElement extends LitElement {
25003
25355
  @dblclick=${() => this.#onCellActivate(key)}
25004
25356
  @pointerenter=${() => this.#handleCellPointerEnter(key)}
25005
25357
  >
25006
- <div class="content" part="content">${this.#source.renderCellContent(index)}</div>
25007
- ${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)) : ``}
25008
25363
  </div>
25009
25364
  `;
25010
25365
  }
@@ -25012,10 +25367,25 @@ let GridListElement = class GridListElement extends LitElement {
25012
25367
  * A tickled cell swaps its plain label for an `ixfx-pan-text` in loop mode
25013
25368
  * so a truncated label scrolls instead of staying an ellipsis. Unticked
25014
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.
25015
25374
  */
25016
- #renderCaption(caption, captionCss, tickled) {
25017
- if (tickled) return html`<div class="caption" part="caption" style=${captionCss} title=${caption}><ixfx-pan-text mode="loop">${caption}</ixfx-pan-text></div>`;
25018
- return html`<div class="caption" part="caption" style=${captionCss} title=${caption}>${caption}</div>`;
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};`;
25019
25389
  }
25020
25390
  /** Stop the active caption pan, if any, and forget it. */
25021
25391
  #stopActiveCaptionPan() {
@@ -28218,6 +28588,16 @@ const ITEM_SELECTOR = `li:not([data-virtual-spacer]), [data-list-item]:not([data
28218
28588
  * vertical rows are shorter relative to the viewport, so 3.
28219
28589
  */
28220
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)`;
28221
28601
  /** Shallow key-array equality, treating `undefined` (never dispatched) as different. */
28222
28602
  function sameKeys(a, b) {
28223
28603
  if (a === void 0 || a.length !== b.length) return false;
@@ -28627,7 +29007,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28627
29007
  }
28628
29008
  this.#syncSearchCommands();
28629
29009
  }
28630
- 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) {
28631
29011
  if (this.#suspendAdapterRebuild) this.#suspendAdapterRebuild = false;
28632
29012
  else if (this.#virtualActive()) this.#rebuildAdapterVirtual();
28633
29013
  else this.#rebuildFromAdapter();
@@ -28897,7 +29277,7 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28897
29277
  const fresh = this.formatter ? this.formatter(data, index) : data;
28898
29278
  if (el instanceof HTMLLIElement) {
28899
29279
  for (const child of [...el.childNodes]) child.remove();
28900
- this.#fillAdapterItem(el, fresh, data);
29280
+ this.#fillAdapterItem(el, fresh, data, index);
28901
29281
  if (this.interactionMode === `checked`) this._syncCheckboxes();
28902
29282
  this._syncItemStates();
28903
29283
  } else if (fresh instanceof Element && this.#isRowElement(fresh)) {
@@ -28952,17 +29332,47 @@ let VerticalListElement = class VerticalListElement extends LitElement {
28952
29332
  return content;
28953
29333
  }
28954
29334
  const li = document.createElement(`li`);
28955
- this.#fillAdapterItem(li, content, item);
29335
+ this.#fillAdapterItem(li, content, item, index);
28956
29336
  return li;
28957
29337
  }
28958
- #fillAdapterItem(li, content, item) {
29338
+ #fillAdapterItem(li, content, item, index) {
28959
29339
  if (typeof content === `string`) li.textContent = content;
28960
29340
  else if (content instanceof Node) li.appendChild(content);
28961
29341
  else if (this.formatter && content !== void 0 && content !== null) render(content, li);
29342
+ this.#decorateAdapterItem(li, item, index);
28962
29343
  this.#stampAdapterKey(li, item);
28963
29344
  this.#itemData.set(li, item);
28964
29345
  }
28965
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
+ /**
28966
29376
  * Whether a formatter-produced element is a whole row (used directly)
28967
29377
  * rather than row content (rendered into a `<li>`): an `<li>`, already
28968
29378
  * marked `[data-list-item]`, or a custom element.
@@ -29484,6 +29894,10 @@ let VerticalListElement = class VerticalListElement extends LitElement {
29484
29894
  outline: 1px solid transparent;
29485
29895
  }
29486
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
+
29487
29901
  /* Custom shadow-DOM components handle their own internal layout/padding.
29488
29902
  Wrapped in :where() so the selector stays at zero specificity and a
29489
29903
  consumer's own :host rule (e.g. display: flex) wins without
@@ -29630,6 +30044,10 @@ __decorate([property({
29630
30044
  attribute: `key-property`
29631
30045
  })], VerticalListElement.prototype, "keyProperty", void 0);
29632
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);
29633
30051
  __decorate([property({
29634
30052
  type: Boolean,
29635
30053
  attribute: `virtualize`,
@@ -30905,6 +31323,90 @@ __decorate([state()], IxfxHexEditorElement.prototype, "_containerH", void 0);
30905
31323
  __decorate([state()], IxfxHexEditorElement.prototype, "_shadeMap", void 0);
30906
31324
  __decorate([state()], IxfxHexEditorElement.prototype, "_focusSide", void 0);
30907
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
+ }
30908
31410
  //#endregion
30909
31411
  //#region src/hyperbolic-tree/poincare-math.ts
30910
31412
  const EPSILON = 1e-9;
@@ -31009,8 +31511,625 @@ function geodesicInterpolate(a, b, t) {
31009
31511
  if (localNorm < EPSILON) return a;
31010
31512
  return mobiusFromOrigin(complexScale(complexScale(bLocal, 1 / localNorm), euclideanRadiusForHyperbolicDistance(hyperbolicDistanceForEuclideanRadius(localNorm) * t)), a);
31011
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
+ `;
31012
32058
  //#endregion
31013
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
+ }
31014
32133
  /** Depth-first search for the node with `key`. Returns `undefined` when absent. */
31015
32134
  function findHyperbolicNode(root, key) {
31016
32135
  if (!root) return void 0;
@@ -31085,21 +32204,58 @@ function stableUnitJitter(key) {
31085
32204
  * radially).
31086
32205
  */
31087
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
+ }
31088
32230
  /**
31089
32231
  * Canonical (true-root-anchored) hyperbolic position of every currently-loaded
31090
- * 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 —
31091
32233
  * refocusing is purely a Möbius re-projection of these fixed coordinates, so
31092
32234
  * the tree never needs to be re-laid-out when the focus changes.
31093
32235
  *
31094
32236
  * Each node holds an angular wedge — inherited from its parent, expressed in
31095
32237
  * its *own* local frame (root starts with the full `[-π, π]`) — across which
31096
- * its children are placed at hyperbolic distance `curvature` (default `1`).
31097
- * That wedge is partitioned among the children, weighted by each child's own
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
31098
32242
  * subtree size rather than split evenly, and each child's own share is
31099
32243
  * carried into its local frame via {@link boundaryAngleInLocalFrame} before
31100
- * recursing. This is a strict partition (slices never overlap and always sum
31101
- * to the parent's own wedge), which is what guarantees sibling subtrees never
31102
- * spatially cross regardless of how deep or bushy they are.
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.
31103
32259
  *
31104
32260
  * `curvature` scales how far apart (in hyperbolic distance) each generation
31105
32261
  * sits. Raising it pushes each generation further out, so the Poincaré
@@ -31108,7 +32264,7 @@ const CHAIN_JITTER_RADIANS = .22;
31108
32264
  * in" on the focus. Lowering it spreads generations out more evenly,
31109
32265
  * flattening the view toward a Euclidean-feeling layout.
31110
32266
  */
31111
- function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
32267
+ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1, siblingSpacing = DEFAULT_SIBLING_SPACING) {
31112
32268
  const map = /* @__PURE__ */ new Map();
31113
32269
  if (!root) return map;
31114
32270
  const origin = {
@@ -31117,70 +32273,122 @@ function computeCanonicalPositions(root, maxDepth = 24, curvature = 1) {
31117
32273
  };
31118
32274
  map.set(root.item.key, {
31119
32275
  node: root,
32276
+ id: root.item.key,
31120
32277
  depth: 0,
31121
32278
  parentKey: void 0,
32279
+ parentId: void 0,
31122
32280
  position: origin,
32281
+ toParent: identityIsometry,
31123
32282
  angularShare: 2 * Math.PI
31124
32283
  });
31125
- const place = (node, position, wedgeStart, wedgeEnd, depth) => {
32284
+ const place = (node, id, frame, wedgeStart, wedgeEnd, depth) => {
31126
32285
  if (depth >= maxDepth) return;
31127
32286
  const children = node.children ?? [];
31128
32287
  const n = children.length;
31129
32288
  if (n === 0) return;
31130
- const r0 = euclideanRadiusForHyperbolicDistance(curvature);
31131
32289
  const angularWidth = wedgeEnd - wedgeStart;
31132
- const weights = children.map((child) => subtreeSize(child, maxDepth - depth - 1));
32290
+ const weights = children.map((child) => Math.sqrt(subtreeSize(child, maxDepth - depth - 1)));
31133
32291
  const totalWeight = weights.reduce((a, b) => a + b, 0);
32292
+ const ids = hyperbolicChildIds(id, children);
31134
32293
  let cursor = wedgeStart;
31135
32294
  children.forEach((child, index) => {
31136
32295
  const share = n === 1 ? angularWidth : angularWidth * weights[index] / totalWeight;
31137
- const childStart = cursor;
31138
- const childEnd = cursor + share;
31139
- cursor = childEnd;
31140
- const theta = (childStart + childEnd) / 2 + (n === 1 ? stableUnitJitter(child.item.key) * Math.min(CHAIN_JITTER_RADIANS, share * .4) : 0);
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));
31141
32300
  const local = {
31142
- x: r0 * Math.cos(theta),
31143
- y: r0 * Math.sin(theta)
32301
+ x: r * Math.cos(theta),
32302
+ y: r * Math.sin(theta)
31144
32303
  };
31145
- const canonicalPosition = mobiusFromOrigin(local, position);
31146
- map.set(child.item.key, {
32304
+ const toParent = translationFromOrigin(local);
32305
+ const childFrame = composeIsometry(frame, toParent);
32306
+ map.set(ids[index], {
31147
32307
  node: child,
32308
+ id: ids[index],
31148
32309
  depth: depth + 1,
31149
32310
  parentKey: node.item.key,
31150
- position: canonicalPosition,
32311
+ parentId: id,
32312
+ position: applyIsometry(childFrame, origin),
32313
+ toParent,
31151
32314
  angularShare: share
31152
32315
  });
31153
- const localStart = boundaryAngleInLocalFrame(childStart, local);
31154
- 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;
31155
32319
  if (localWidth <= 0) localWidth += 2 * Math.PI;
31156
- place(child, canonicalPosition, localStart, localStart + localWidth, depth + 1);
32320
+ place(child, ids[index], childFrame, localStart, localStart + localWidth, depth + 1);
31157
32321
  });
31158
32322
  };
31159
- place(root, origin, -Math.PI, Math.PI, 0);
32323
+ place(root, root.item.key, identityIsometry, -Math.PI, Math.PI, 0);
31160
32324
  return map;
31161
32325
  }
31162
- /** Nodes within `maxRadius` tree-graph steps of `focusedKey`, mapped to their step distance. */
31163
- function ringDistances(canonical, focusedKey, maxRadius) {
31164
- const ring = /* @__PURE__ */ new Map();
31165
- if (!canonical.has(focusedKey)) return ring;
31166
- ring.set(focusedKey, 0);
31167
- let frontier = [focusedKey];
31168
- for (let distance = 1; distance <= maxRadius && frontier.length > 0; distance++) {
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++) {
31169
32342
  const next = [];
31170
- for (const key of frontier) {
31171
- const entry = canonical.get(key);
31172
- if (!entry) continue;
31173
- const neighbours = [];
31174
- if (entry.parentKey !== void 0) neighbours.push(entry.parentKey);
31175
- for (const child of entry.node.children ?? []) neighbours.push(child.item.key);
31176
- for (const neighbour of neighbours) if (!ring.has(neighbour) && canonical.has(neighbour)) {
31177
- ring.set(neighbour, distance);
31178
- next.push(neighbour);
31179
- }
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)));
31180
32356
  }
31181
32357
  frontier = next;
31182
32358
  }
31183
- 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;
31184
32392
  }
31185
32393
  /**
31186
32394
  * Compute the visible nodes/edges of a hyperbolic tree view: every
@@ -31188,58 +32396,69 @@ function ringDistances(canonical, focusedKey, maxRadius) {
31188
32396
  * node, projected onto the Poincaré disk with the focus at its center.
31189
32397
  *
31190
32398
  * @param root True tree root. `undefined` yields an empty layout.
31191
- * @param options `focusedKey` (default the true root; unknown keys fall back
31192
- * to the true root), `maxRadius` (default unlimited), `maxDepth` (safety
31193
- * cap on canonical-position recursion depth, default 24), `centerOverride`
31194
- * (project around this canonical-space point instead of the focus node's
31195
- * own position — used to animate smoothly between foci).
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).
31196
32406
  */
31197
32407
  function computeHyperbolicLayout(root, options = {}) {
31198
32408
  if (!root) return {
31199
32409
  nodes: [],
31200
32410
  edges: []
31201
32411
  };
31202
- const { maxRadius = Number.POSITIVE_INFINITY, maxDepth = 24, curvature = 1 } = options;
31203
- const canonical = computeCanonicalPositions(root, maxDepth, curvature);
31204
- const focusedKey = options.focusedKey !== void 0 && canonical.has(options.focusedKey) ? options.focusedKey : root.item.key;
31205
- const focusEntry = canonical.get(focusedKey);
31206
- if (!focusEntry) return {
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 {
31207
32416
  nodes: [],
31208
32417
  edges: []
31209
32418
  };
31210
- const projectionCenter = options.centerOverride ?? focusEntry.position;
31211
- const ring = ringDistances(canonical, focusedKey, maxRadius);
31212
- const rawWeights = [...ring.keys()].map((key) => canonical.get(key).node.item.weight).filter((w) => typeof w === `number`);
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`);
31213
32425
  const minWeight = rawWeights.length > 0 ? Math.min(...rawWeights) : void 0;
31214
32426
  const maxWeight = rawWeights.length > 0 ? Math.max(...rawWeights) : void 0;
31215
32427
  const normalizeWeight = (weight) => {
31216
32428
  if (weight === void 0 || minWeight === void 0 || maxWeight === void 0 || maxWeight === minWeight) return .5;
31217
32429
  return (weight - minWeight) / (maxWeight - minWeight);
31218
32430
  };
31219
- const nodes = [...ring.entries()].map(([key, distance]) => {
31220
- const entry = canonical.get(key);
32431
+ const nodes = [...frames.entries()].map(([id, { ring, frame }]) => {
32432
+ const entry = canonical.get(id);
31221
32433
  const weight = entry.node.item.weight;
31222
32434
  return {
31223
32435
  node: entry.node,
31224
- key,
32436
+ key: entry.node.item.key,
32437
+ id,
31225
32438
  depth: entry.depth,
31226
- ring: distance,
32439
+ ring,
31227
32440
  parentKey: entry.parentKey,
31228
- position: mobiusToOrigin(entry.position, projectionCenter),
32441
+ parentId: entry.parentId,
32442
+ position: applyIsometry(composeIsometry(view, frame), {
32443
+ x: 0,
32444
+ y: 0
32445
+ }),
31229
32446
  angularShare: entry.angularShare,
31230
32447
  weight,
31231
32448
  weightNormalized: normalizeWeight(weight)
31232
32449
  };
31233
32450
  }).sort((a, b) => a.ring - b.ring);
31234
- const byKey = new Map(nodes.map((n) => [n.key, n]));
32451
+ const byId = new Map(nodes.map((n) => [n.id, n]));
31235
32452
  const edges = [];
31236
32453
  for (const n of nodes) {
31237
- if (n.parentKey === void 0) continue;
31238
- const parent = byKey.get(n.parentKey);
32454
+ if (n.parentId === void 0) continue;
32455
+ const parent = byId.get(n.parentId);
31239
32456
  if (!parent) continue;
31240
32457
  edges.push({
31241
32458
  fromKey: parent.key,
31242
32459
  toKey: n.key,
32460
+ fromId: parent.id,
32461
+ toId: n.id,
31243
32462
  from: parent.position,
31244
32463
  to: n.position,
31245
32464
  weightNormalized: (parent.weightNormalized + n.weightNormalized) / 2
@@ -31251,17 +32470,476 @@ function computeHyperbolicLayout(root, options = {}) {
31251
32470
  };
31252
32471
  }
31253
32472
  //#endregion
31254
- //#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
+ }
31255
32485
  /**
31256
- * The Poincaré disk's math operates in a `-1..1` unit radius, but rendering
31257
- * an SVG `viewBox` at that literal scale (then letting CSS stretch it to a
31258
- * few hundred device pixels) is an extreme, unusual upscale factor that has
31259
- * been observed to trigger a Chromium GPU-rasterization glitch — a `fill:
31260
- * none` shape painting as solid-filled, matching its computed style exactly
31261
- * but not what's on screen. Rendering in a "normal"-magnitude SVG coordinate
31262
- * system (viewBox radius {@link RENDER_SCALE} instead of `1`) avoids it.
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)
31263
32492
  */
31264
- const RENDER_SCALE = 500;
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
+ }
32932
+ /**
32933
+ * Paint order: far-from-focus first so nearer (larger) nodes sit on top;
32934
+ * then labelled over unlabelled; the hovered node above everything.
32935
+ */
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
31265
32943
  /**
31266
32944
  * Sanitizes a node key into a token safe to use inside a `part` attribute /
31267
32945
  * `::part()` selector — arbitrary key characters (spaces, quotes, etc.)
@@ -31270,13 +32948,121 @@ const RENDER_SCALE = 500;
31270
32948
  function partToken(key) {
31271
32949
  return key.replace(/[^\w-]/g, `-`);
31272
32950
  }
31273
- /** Angular distance (radians, 0–π) of a layout node from screen-"up" (−π/2). */
31274
- function angleDistanceFromUp(n) {
31275
- const angle = Math.atan2(n.position.y, n.position.x);
31276
- let diff = Math.abs(angle - -Math.PI / 2);
31277
- if (diff > Math.PI) diff = 2 * Math.PI - diff;
31278
- return diff;
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
+ `;
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
+ />`;
31279
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
31280
33066
  let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitElement {
31281
33067
  constructor(..._args) {
31282
33068
  super(..._args);
@@ -31284,37 +33070,63 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31284
33070
  this.selectionFilter = `all`;
31285
33071
  this.interactionMode = `standard`;
31286
33072
  this.maxRadius = 4;
33073
+ this.nodeShape = `dot`;
33074
+ this.boxMaxChars = 24;
33075
+ this.minLabelSize = 9;
33076
+ this.colorByBranch = false;
31287
33077
  this.nodeRadius = .05;
31288
33078
  this.minNodeRadius = .015;
31289
33079
  this.minWeightScale = .6;
31290
33080
  this.maxWeightScale = 1.6;
31291
33081
  this.zoom = 1;
33082
+ this.disableZoomGestures = false;
33083
+ this.disableRadialLabels = false;
33084
+ this.disableChildPreview = false;
31292
33085
  this.curvature = 1;
33086
+ this.siblingSpacing = DEFAULT_SIBLING_SPACING;
31293
33087
  this.focusAnimationDuration = 400;
31294
33088
  this._selectedNodes = /* @__PURE__ */ new Set();
31295
33089
  this.disableKeyboardNavigation = false;
31296
33090
  this.registry = void 0;
31297
- this._focusKey = void 0;
31298
- this._animCenter = void 0;
31299
- this._panOffset = {
33091
+ this._focusId = void 0;
33092
+ this._viewOverride = void 0;
33093
+ this._focusRotation = 0;
33094
+ this._zoomOffset = {
31300
33095
  x: 0,
31301
33096
  y: 0
31302
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();
31303
33114
  this.search = new ListSearchController(this, { adapter: createTreeListSearchAdapter(this, {
31304
33115
  labelSelector: `.hyperbolictree-label`,
31305
33116
  highlightKey: `incr-search-highlight`
31306
33117
  }) });
31307
33118
  this.tickled = new TickledController(this, {
31308
33119
  getItems: () => this.#layout().nodes.map((n) => ({
31309
- id: n.key,
33120
+ id: n.id,
31310
33121
  enabled: true
31311
33122
  })),
31312
33123
  onStateChange: () => {
31313
33124
  this.setAttribute(`data-input-mode`, this.tickled.inputMode);
31314
33125
  },
31315
33126
  onTickle: (id) => {
33127
+ this.#preview.update(id);
31316
33128
  if (id === void 0) return;
31317
- const n = this.#layout().nodes.find((n) => n.key === id);
33129
+ const n = this.#layout().nodes.find((n) => n.id === id);
31318
33130
  if (!n) return;
31319
33131
  this.dispatchEvent(new CustomEvent(`tickle`, {
31320
33132
  detail: {
@@ -31326,8 +33138,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31326
33138
  }));
31327
33139
  }
31328
33140
  });
31329
- this.#pendingKeys = /* @__PURE__ */ new Set();
31330
- 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
+ });
31331
33165
  }
31332
33166
  static {
31333
33167
  this.shadowRootOptions = {
@@ -31340,37 +33174,55 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31340
33174
  }
31341
33175
  /** The node currently centered on the disk. Pure view-state — `root` is never mutated by focus changes. */
31342
33176
  get focusedNode() {
31343
- if (this._focusKey === void 0) return this.#effectiveRoot;
31344
- 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;
31345
33179
  }
33180
+ #preview;
33181
+ #lazy;
33182
+ /** Labels shown in the last render — what the child preview judges legibility by. */
33183
+ #lastLabelIds;
31346
33184
  #modelUnsubscribe;
31347
- #pendingKeys;
31348
- #aborters;
31349
33185
  #ownedRegistry;
31350
33186
  #unregisterSearchCommands;
31351
33187
  #animFrame;
31352
- #panStart;
33188
+ #resizeObserver;
33189
+ #gestures;
31353
33190
  get #effectiveRoot() {
31354
33191
  return this.model?.root ?? this.root;
31355
33192
  }
31356
33193
  #layout() {
31357
33194
  return computeHyperbolicLayout(this.#effectiveRoot, {
31358
- focusedKey: this._focusKey,
33195
+ focusedKey: this._focusId,
31359
33196
  maxRadius: this.maxRadius,
31360
33197
  curvature: this.curvature,
31361
- 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()
31362
33200
  });
31363
33201
  }
31364
- #effectiveCenter() {
31365
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31366
- const focusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31367
- const base = this._animCenter ?? (focusKey === void 0 ? void 0 : canonical.get(focusKey)?.position);
31368
- if (base === void 0) return void 0;
31369
- if (this._panOffset.x === 0 && this._panOffset.y === 0) return base;
31370
- return clampToDisk({
31371
- x: base.x + this._panOffset.x,
31372
- y: base.y + this._panOffset.y
31373
- }, .95);
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;
31374
33226
  }
31375
33227
  connectedCallback() {
31376
33228
  super.connectedCallback();
@@ -31390,14 +33242,25 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31390
33242
  });
31391
33243
  this.registry = registry;
31392
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
+ }
31393
33255
  }
31394
33256
  disconnectedCallback() {
31395
33257
  super.disconnectedCallback();
33258
+ this.#resizeObserver?.disconnect();
33259
+ this.#resizeObserver = void 0;
31396
33260
  this.#modelUnsubscribe?.();
31397
33261
  this.#modelUnsubscribe = void 0;
31398
- for (const aborter of this.#aborters.values()) aborter.abort();
31399
- this.#aborters.clear();
31400
- this.#pendingKeys.clear();
33262
+ this.#preview.dispose();
33263
+ this.#lazy.abortAll();
31401
33264
  if (this.#animFrame !== void 0) {
31402
33265
  cancelAnimationFrame(this.#animFrame);
31403
33266
  this.#animFrame = void 0;
@@ -31438,58 +33301,30 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31438
33301
  }
31439
33302
  if (changedProps.has(`interactionMode`)) this.clearSelection();
31440
33303
  if (changedProps.has(`searchModes`)) this.#syncSearchCommands();
31441
- if (this._focusKey !== void 0 && !findHyperbolicNode(this.#effectiveRoot, this._focusKey)) {
31442
- this._focusKey = void 0;
31443
- this._animCenter = void 0;
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;
31444
33308
  }
31445
- this.#ensureVisibleChildren();
33309
+ this.#lazy.ensure(this.#layout().nodes);
31446
33310
  }
31447
33311
  #resetViewState() {
31448
33312
  this.#clearSelectionInternal();
31449
33313
  this.tickled.clearTickled();
31450
- this._focusKey = void 0;
31451
- this._animCenter = void 0;
31452
- this._panOffset = {
33314
+ this._focusId = void 0;
33315
+ this._viewOverride = void 0;
33316
+ this._focusRotation = 0;
33317
+ this._zoomOffset = {
31453
33318
  x: 0,
31454
33319
  y: 0
31455
33320
  };
33321
+ this._previewId = void 0;
33322
+ this.#gestures.cancel();
31456
33323
  if (this.#animFrame !== void 0) {
31457
33324
  cancelAnimationFrame(this.#animFrame);
31458
33325
  this.#animFrame = void 0;
31459
33326
  }
31460
33327
  }
31461
- #ensureVisibleChildren() {
31462
- if (!this.model && !this.loadChildren) return;
31463
- for (const n of this.#layout().nodes) {
31464
- const { node } = n;
31465
- if (node.children !== void 0 || this.#pendingKeys.has(node.item.key)) continue;
31466
- this.#loadChildren(node, n.depth);
31467
- }
31468
- }
31469
- async #loadChildren(node, depth) {
31470
- const key = node.item.key;
31471
- this.#pendingKeys.add(key);
31472
- const controller = new AbortController();
31473
- this.#aborters.set(key, controller);
31474
- this.requestUpdate();
31475
- try {
31476
- let children;
31477
- if (this.model) children = await this.model.getChildren(node, depth, controller.signal);
31478
- else children = [...await collectDataResult(this.loadChildren({
31479
- node,
31480
- depth
31481
- }, controller.signal), controller.signal)];
31482
- if (controller.signal.aborted) return;
31483
- node.children = children;
31484
- } catch {
31485
- if (controller.signal.aborted) return;
31486
- node.children = [];
31487
- } finally {
31488
- this.#pendingKeys.delete(key);
31489
- this.#aborters.delete(key);
31490
- if (!controller.signal.aborted) this.requestUpdate();
31491
- }
31492
- }
31493
33328
  #canSelect(node) {
31494
33329
  if (this.selectionFilter === `none`) return false;
31495
33330
  if (this.selectionFilter === `leaf`) return node.item.isLeaf === true;
@@ -31565,83 +33400,83 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31565
33400
  * the previous one, for interop with generic `TreeComponent` listeners.
31566
33401
  */
31567
33402
  focusOn(node) {
31568
- const targetKey = node.item.key;
31569
- const currentFocusKey = this._focusKey ?? this.#effectiveRoot?.item.key;
31570
- if (currentFocusKey === targetKey) return;
31571
- const previousNode = currentFocusKey === void 0 ? void 0 : findHyperbolicNode(this.#effectiveRoot, currentFocusKey);
31572
- const canonical = computeCanonicalPositions(this.#effectiveRoot, 24, this.curvature);
31573
- const fromPosition = (currentFocusKey === void 0 ? void 0 : canonical.get(currentFocusKey)?.position) ?? {
31574
- x: 0,
31575
- y: 0
31576
- };
31577
- const toPosition = canonical.get(targetKey)?.position ?? {
31578
- x: 0,
31579
- y: 0
31580
- };
31581
- this._focusKey = targetKey;
31582
- this._panOffset = {
31583
- x: 0,
31584
- y: 0
31585
- };
31586
- this.#animateTo(fromPosition, toPosition);
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);
31587
33414
  this.dispatchEvent(new CustomEvent(`focus`, {
31588
33415
  detail: {
31589
- node,
33416
+ node: targetNode,
31590
33417
  previous: previousNode
31591
33418
  },
31592
33419
  bubbles: true,
31593
33420
  composed: true
31594
33421
  }));
31595
- if (previousNode) {
31596
- if (findHyperbolicNode(previousNode, targetKey)) {
31597
- const depth = (findHyperbolicPath(this.#effectiveRoot, targetKey)?.length ?? 1) - 1;
31598
- this.dispatchEvent(new CustomEvent(`expand`, {
31599
- detail: {
31600
- node,
31601
- depth
31602
- },
31603
- bubbles: true,
31604
- composed: true
31605
- }));
31606
- } else if (findHyperbolicNode(node, previousNode.item.key)) {
31607
- const depth = (findHyperbolicPath(this.#effectiveRoot, previousNode.item.key)?.length ?? 1) - 1;
31608
- this.dispatchEvent(new CustomEvent(`collapse`, {
31609
- detail: {
31610
- node: previousNode,
31611
- depth
31612
- },
31613
- bubbles: true,
31614
- composed: true
31615
- }));
31616
- }
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
+ }));
31617
33439
  }
31618
33440
  this.requestUpdate();
31619
33441
  }
31620
- #prefersReducedMotion() {
31621
- return typeof matchMedia === `function` && matchMedia(`(prefers-reduced-motion: reduce)`).matches;
31622
- }
31623
- #animateTo(from, to) {
33442
+ #cancelAnimation() {
31624
33443
  if (this.#animFrame !== void 0) {
31625
33444
  cancelAnimationFrame(this.#animFrame);
31626
33445
  this.#animFrame = void 0;
31627
33446
  }
31628
- if (this.focusAnimationDuration <= 0 || this.#prefersReducedMotion() || typeof requestAnimationFrame !== `function`) {
31629
- 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
+ };
31630
33460
  return;
31631
33461
  }
31632
33462
  const start = performance.now();
31633
33463
  const duration = this.focusAnimationDuration;
31634
33464
  const step = (now) => {
31635
33465
  const t = Math.min(1, (now - start) / duration);
31636
- this._animCenter = t >= 1 ? void 0 : geodesicInterpolate(from, to, t);
31637
- 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);
31638
33472
  this.#animFrame = t < 1 ? requestAnimationFrame(step) : void 0;
31639
33473
  };
31640
33474
  this.#animFrame = requestAnimationFrame(step);
31641
33475
  }
31642
33476
  #focusAndMaybeSelect(node) {
31643
33477
  this.focusOn(node);
31644
- this.tickled.setTickled(node.item.key);
33478
+ const id = this.#idOf(node);
33479
+ if (id !== void 0) this.tickled.setTickled(id);
31645
33480
  if (this.interactionMode === `implicit`) this.select(node);
31646
33481
  }
31647
33482
  /** Focus + select the node at `keys` (path from the root's children). Returns `true` if found. */
@@ -31656,7 +33491,8 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31656
33491
  }
31657
33492
  this.focusOn(current);
31658
33493
  this.select(current);
31659
- this.tickled.setTickled(current.item.key);
33494
+ const id = this.#idOf(current);
33495
+ if (id !== void 0) this.tickled.setTickled(id);
31660
33496
  this.requestUpdate();
31661
33497
  return true;
31662
33498
  }
@@ -31664,7 +33500,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31664
33500
  navigateToNode(predicate) {
31665
33501
  const root = this.#effectiveRoot;
31666
33502
  if (!root?.children) return false;
31667
- const keyPath = this.#findKeyPath(root.children, predicate);
33503
+ const keyPath = findHyperbolicKeyPath(root.children, predicate);
31668
33504
  if (!keyPath) return false;
31669
33505
  return this.navigateTo(keyPath);
31670
33506
  }
@@ -31675,22 +33511,13 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31675
33511
  */
31676
33512
  revealKey(key) {
31677
33513
  const node = findHyperbolicNode(this.#effectiveRoot, key);
31678
- if (!node) return false;
31679
- if (!this.#layout().nodes.some((n) => n.key === key)) this.focusOn(node);
31680
- this.tickled.setTickled(key);
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);
31681
33518
  this.requestUpdate();
31682
33519
  return true;
31683
33520
  }
31684
- #findKeyPath(nodes, predicate, prefix = []) {
31685
- for (const node of nodes) {
31686
- const path = [...prefix, node.item.key];
31687
- if (predicate(node)) return path;
31688
- if (node.children?.length) {
31689
- const hit = this.#findKeyPath(node.children, predicate, path);
31690
- if (hit) return hit;
31691
- }
31692
- }
31693
- }
31694
33521
  /**
31695
33522
  * All nodes currently laid out on the disk (within `maxRadius` of the
31696
33523
  * focus), in render (ring-major) order. Unlike `ixfx-icicletree-list`, the
@@ -31727,7 +33554,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31727
33554
  this.requestUpdate();
31728
33555
  }
31729
33556
  } else if (this.interactionMode !== `manual`) this.select(n.node);
31730
- this.tickled.setTickled(n.key);
33557
+ this.tickled.setTickled(n.id);
31731
33558
  this.focusOn(n.node);
31732
33559
  }
31733
33560
  #handleItemDblClick(n) {
@@ -31749,239 +33576,147 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31749
33576
  }));
31750
33577
  }
31751
33578
  #handleKeyDown(event) {
31752
- if (this.disableKeyboardNavigation) return;
31753
33579
  const root = this.#effectiveRoot;
31754
- const { nodes } = this.#layout();
31755
- if (!root || nodes.length === 0) return;
31756
- const focusNode = findHyperbolicNode(root, this._focusKey ?? root.item.key);
31757
- if (!focusNode) return;
31758
- switch (event.key) {
31759
- case `ArrowUp`: {
31760
- event.preventDefault();
31761
- const children = nodes.filter((n) => n.parentKey === focusNode.item.key);
31762
- if (children.length === 0) return;
31763
- const best = children.reduce((a, b) => angleDistanceFromUp(a) < angleDistanceFromUp(b) ? a : b);
31764
- this.#focusAndMaybeSelect(best.node);
31765
- return;
31766
- }
31767
- case `ArrowDown`: {
31768
- event.preventDefault();
31769
- const path = findHyperbolicPath(root, focusNode.item.key);
31770
- const parent = path && path.length >= 2 ? path[path.length - 2] : void 0;
31771
- if (parent) this.#focusAndMaybeSelect(parent);
31772
- return;
31773
- }
31774
- case `ArrowLeft`:
31775
- case `ArrowRight`: {
31776
- event.preventDefault();
31777
- const path = findHyperbolicPath(root, focusNode.item.key);
31778
- const siblings = (path && path.length >= 2 ? path[path.length - 2] : void 0)?.children ?? (focusNode === root ? [focusNode] : []);
31779
- const index = siblings.findIndex((n) => n.item.key === focusNode.item.key);
31780
- if (index === -1 || siblings.length <= 1) return;
31781
- const next = siblings[(index + (event.key === `ArrowRight` ? 1 : -1) + siblings.length) % siblings.length];
31782
- this.#focusAndMaybeSelect(next);
31783
- return;
31784
- }
31785
- case `Enter`: {
31786
- event.preventDefault();
31787
- if (this.interactionMode !== `manual`) this.select(focusNode);
31788
- const depth = (findHyperbolicPath(root, focusNode.item.key)?.length ?? 1) - 1;
31789
- this.dispatchEvent(new CustomEvent(`activate`, {
31790
- detail: {
31791
- node: focusNode,
31792
- depth
31793
- },
31794
- bubbles: true,
31795
- composed: true
31796
- }));
31797
- return;
31798
- }
31799
- case `Escape`: if (this._focusKey !== void 0 && this._focusKey !== root.item.key) {
31800
- event.preventDefault();
31801
- this.focusOn(root);
31802
- }
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
+ }));
31803
33599
  }
31804
33600
  }
31805
- #isBackgroundTarget(event) {
31806
- const target = event.target;
31807
- return target === event.currentTarget || target?.classList.contains(`disk-boundary`) === true;
31808
- }
31809
- #handleBackgroundPointerDown(event) {
31810
- if (!this.#isBackgroundTarget(event)) return;
31811
- event.currentTarget.setPointerCapture(event.pointerId);
31812
- this.#panStart = {
31813
- pointerId: event.pointerId,
31814
- startClient: {
31815
- x: event.clientX,
31816
- y: event.clientY
31817
- },
31818
- baseOffset: this._panOffset
31819
- };
31820
- }
31821
- #handleBackgroundPointerMove(event) {
31822
- if (!this.#panStart || event.pointerId !== this.#panStart.pointerId) return;
31823
- const rect = event.currentTarget.getBoundingClientRect();
31824
- const scale = 2.1 / (Math.min(rect.width, rect.height) || 1);
31825
- const dx = (event.clientX - this.#panStart.startClient.x) * scale;
31826
- const dy = (event.clientY - this.#panStart.startClient.y) * scale;
31827
- this._panOffset = clampToDisk({
31828
- x: this.#panStart.baseOffset.x - dx,
31829
- y: this.#panStart.baseOffset.y - dy
31830
- }, .9);
31831
- this.requestUpdate();
33601
+ #zoomState() {
33602
+ return clampZoomState({
33603
+ zoom: this.zoom,
33604
+ offset: this._zoomOffset
33605
+ });
31832
33606
  }
31833
- #handleBackgroundPointerUp(event) {
31834
- if (this.#panStart?.pointerId === event.pointerId) this.#panStart = void 0;
31835
- }
31836
- #defaultEdgeRenderer(edge) {
31837
- const steps = 8;
31838
- let d = ``;
31839
- for (let i = 0; i <= steps; i++) {
31840
- const p = geodesicInterpolate(edge.from, edge.to, i / steps);
31841
- d += `${i === 0 ? `M` : `L`}${(p.x * RENDER_SCALE).toFixed(2)},${(p.y * RENDER_SCALE).toFixed(2)} `;
31842
- }
31843
- return svg`<path
31844
- class="edge"
31845
- d=${d}
31846
- part="edge edge-to-${partToken(edge.toKey)} edge-from-${partToken(edge.fromKey)}"
31847
- data-from=${edge.fromKey}
31848
- data-to=${edge.toKey}
31849
- style="--edge-weight: ${edge.weightNormalized}"
31850
- />`;
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 });
31851
33615
  }
31852
- #renderEdge(edge, byKey) {
31853
- if (this.edgeRenderer) {
31854
- const fromNode = byKey.get(edge.fromKey)?.node;
31855
- const toNode = byKey.get(edge.toKey)?.node;
31856
- if (fromNode && toNode) return svg`<g transform="scale(${RENDER_SCALE})">${this.edgeRenderer({
31857
- edge,
31858
- fromNode,
31859
- toNode
31860
- })}</g>`;
31861
- }
31862
- return this.#defaultEdgeRenderer(edge);
31863
- }
31864
- /**
31865
- * A node's rendered dot radius: shrinks with distance from focus (existing
31866
- * behaviour), but also caps itself to the angular room {@link
31867
- * HyperbolicLayoutNode.angularShare} actually gives it at its radius —
31868
- * without this, a cluster of siblings squeezed into a narrow wedge (a
31869
- * bushy ancestor elsewhere in the tree, or many siblings at this level)
31870
- * all render at the same distance-only size and physically overlap into
31871
- * an unreadable blob, however small {@link minNodeRadius} suppresses their
31872
- * labels. `angularShare * distance` approximates the Euclidean arc length
31873
- * available to the node; capping the diameter to a fraction of that keeps
31874
- * a visible gap between crowded neighbours.
31875
- */
31876
- #nodeRenderRadius(n) {
31877
- const distance = Math.hypot(n.position.x, n.position.y);
31878
- const scale = Math.max(.2, 1 - distance);
31879
- const weightScale = this.minWeightScale + (this.maxWeightScale - this.minWeightScale) * n.weightNormalized;
31880
- const radius = this.nodeRadius * scale * weightScale;
31881
- if (distance < 1e-6) return radius;
31882
- const arcLength = n.angularShare * distance;
31883
- return Math.min(radius, Math.max(arcLength * .35, .003));
31884
- }
31885
- /**
31886
- * Which nodes get a rendered label, greedily thinned to avoid overlap: a
31887
- * per-node radius threshold alone ({@link minNodeRadius}) still lets many
31888
- * same-sized nodes crowded into a small angular wedge all show labels that
31889
- * visually collide. Nodes are considered in priority order (closer ring
31890
- * first, then larger on-screen radius) and a label is kept only if its
31891
- * position isn't too close to an already-kept one.
31892
- */
31893
- #computeLabelKeys(nodes) {
31894
- const minSpacing = .07;
31895
- const prioritised = [...nodes.filter((n) => this.#nodeRenderRadius(n) >= this.minNodeRadius)].sort((a, b) => {
31896
- if (a.ring !== b.ring) return a.ring - b.ring;
31897
- return Math.hypot(a.position.x, a.position.y) - Math.hypot(b.position.x, b.position.y);
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)
31898
33654
  });
31899
- const kept = [];
31900
- const keys = /* @__PURE__ */ new Set();
31901
- for (const n of prioritised) if (!kept.some((k) => Math.hypot(k.position.x - n.position.x, k.position.y - n.position.y) < minSpacing)) {
31902
- kept.push(n);
31903
- keys.add(n.key);
31904
- }
31905
- return keys;
31906
33655
  }
31907
- #renderNode(n, siblingGroups, labelKeys) {
31908
- const { node, key, position } = n;
31909
- const selected = this._selectedNodes.has(node);
31910
- const tickled = this.tickled.isTickled(key);
31911
- const filtered = !this.#matchesFilter(node, n.depth);
31912
- const pending = this.#pendingKeys.has(key);
31913
- const isFocused = n.ring === 0;
31914
- const radius = this.#nodeRenderRadius(n);
31915
- const showLabel = labelKeys.has(key);
31916
- const siblings = siblingGroups.get(n.parentKey) ?? [n];
31917
- const index = siblings.findIndex((s) => s.key === key);
31918
- const scaledRadius = radius * RENDER_SCALE;
31919
- const weightStyle = `--node-weight: ${n.weightNormalized};${n.weight === void 0 ? `` : ` --node-weight-raw: ${n.weight};`}`;
31920
- return svg`
31921
- <g
31922
- class="node ${selected ? `selected` : ``} ${tickled ? `tickled` : ``} ${filtered ? `filtered` : ``} ${pending ? `loading` : ``} ${isFocused ? `focused` : ``}"
31923
- data-key=${key}
31924
- role="treeitem"
31925
- tabindex="-1"
31926
- aria-level=${n.depth + 1}
31927
- aria-selected=${selected}
31928
- aria-setsize=${siblings.length}
31929
- aria-posinset=${index + 1}
31930
- aria-label=${node.item.label}
31931
- part="node node-${partToken(key)}"
31932
- style=${weightStyle}
31933
- transform="translate(${position.x * RENDER_SCALE} ${position.y * RENDER_SCALE})"
31934
- @pointerdown=${(e) => e.stopPropagation()}
31935
- @click=${(e) => {
31936
- e.stopPropagation();
31937
- this.#handleItemClick(n, e);
31938
- }}
31939
- @dblclick=${(e) => {
31940
- e.stopPropagation();
31941
- this.#handleItemDblClick(n);
31942
- }}
31943
- @pointerenter=${() => this.tickled.handlePointerEnter(key)}
31944
- >
31945
- <circle class="node-hit" r=${Math.max(scaledRadius, .035 * RENDER_SCALE)} part="hit" />
31946
- <circle class="node-dot" r=${scaledRadius} part="dot" />
31947
- ${showLabel ? svg`<text class="hyperbolictree-label" part="label" x=${scaledRadius + .01 * RENDER_SCALE} y="0" dominant-baseline="middle">${node.item.label}</text>` : nothing}
31948
- </g>
31949
- `;
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
+ });
31950
33679
  }
31951
33680
  render() {
31952
33681
  const { nodes, edges } = this.#layout();
31953
- const byKey = new Map(nodes.map((n) => [n.key, n]));
31954
- const siblingGroups = /* @__PURE__ */ new Map();
31955
- for (const n of nodes) {
31956
- const group = siblingGroups.get(n.parentKey) ?? [];
31957
- group.push(n);
31958
- siblingGroups.set(n.parentKey, group);
31959
- }
31960
- const labelKeys = this.#computeLabelKeys(nodes);
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}) `;
31961
33688
  return html`
31962
33689
  <svg
31963
33690
  class="hyperbolictree"
31964
- viewBox="${-1.05 * RENDER_SCALE} ${-1.05 * RENDER_SCALE} ${2.1 * RENDER_SCALE} ${2.1 * RENDER_SCALE}"
33691
+ viewBox="${-525} ${-525} ${1050} ${1050}"
31965
33692
  preserveAspectRatio="xMidYMid meet"
31966
33693
  role="tree"
31967
33694
  tabindex="0"
31968
33695
  data-input-mode=${this.tickled.inputMode}
33696
+ data-node-shape=${this.nodeShape}
31969
33697
  @keydown=${(e) => this.#handleKeyDown(e)}
31970
- @pointerdown=${(e) => this.#handleBackgroundPointerDown(e)}
31971
- @pointermove=${(e) => this.#handleBackgroundPointerMove(e)}
31972
- @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)}
31973
33703
  @pointerleave=${() => this.tickled.clearTickled()}
31974
33704
  >
31975
- <g class="zoom" transform="scale(${this.zoom})">
31976
- <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} />
31977
33707
  <g class="edges" part="edges">
31978
- ${repeat(edges, (e) => `${e.fromKey}->${e.toKey}`, (e) => this.#renderEdge(e, byKey))}
33708
+ ${renderEdges(edges, byId, branches, this.edgeRenderer)}
31979
33709
  </g>
31980
33710
  <g class="nodes" part="nodes">
31981
- ${repeat(nodes, (n) => n.key, (n) => this.#renderNode(n, siblingGroups, labelKeys))}
33711
+ ${this.#renderNodes(nodes, ctx)}
31982
33712
  </g>
31983
33713
  </g>
31984
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
+ })}
31985
33720
  ${this.search.renderField()}
31986
33721
  `;
31987
33722
  }
@@ -31989,102 +33724,7 @@ let HyperbolicTreeListElement = class HyperbolicTreeListElement extends LitEleme
31989
33724
  this.styles = [
31990
33725
  themeFallbacks,
31991
33726
  listSearchStyles,
31992
- css`
31993
- :host {
31994
- --hyperbolictree-node-color: var(--surface-4);
31995
- --hyperbolictree-node-text: var(--surface-3-text);
31996
- --hyperbolictree-edge-color: var(--surface-6);
31997
- --hyperbolictree-boundary-color: var(--border-subtle);
31998
- --hyperbolictree-font-size: 25px;
31999
-
32000
- display: block;
32001
- overflow: hidden;
32002
- outline: none;
32003
- overscroll-behavior: contain;
32004
- }
32005
-
32006
- /*
32007
- * delegatesFocus means the host itself reports as focused whenever
32008
- * the shadow DOM's focused descendant (the svg) is — without this,
32009
- * the host also picks up the browser's own default focus ring/fill
32010
- * sized to the whole box.
32011
- */
32012
- :host(:focus),
32013
- :host(:focus-visible) {
32014
- outline: none;
32015
- }
32016
-
32017
- .hyperbolictree {
32018
- display: block;
32019
- width: 100%;
32020
- height: 100%;
32021
- outline: none;
32022
- touch-action: none;
32023
- overscroll-behavior: contain;
32024
- }
32025
-
32026
- .disk-boundary {
32027
- fill: none;
32028
- stroke: var(--hyperbolictree-boundary-color);
32029
- stroke-width: 1;
32030
- }
32031
-
32032
- .edge {
32033
- fill: none;
32034
- stroke: var(--hyperbolictree-edge-color);
32035
- /* --edge-weight (0..1, average of the endpoint nodes' --node-weight) thickens heavier edges. */
32036
- stroke-width: calc(1px + var(--edge-weight, 0.5) * 3px);
32037
- }
32038
-
32039
- .node {
32040
- cursor: pointer;
32041
- }
32042
-
32043
- .node-hit {
32044
- fill: transparent;
32045
- pointer-events: all;
32046
- }
32047
-
32048
- .node-dot {
32049
- /* --node-weight (0..1, normalized across the visible node set) tints toward --accent for heavier nodes. */
32050
- fill: color-mix(in oklab, var(--hyperbolictree-node-color), var(--accent) calc(var(--node-weight, 0.5) * 50%));
32051
- stroke: var(--surface-2);
32052
- stroke-width: 1;
32053
- pointer-events: none;
32054
- }
32055
-
32056
- .node.tickled .node-dot {
32057
- fill: var(--item-bg-tickled);
32058
- }
32059
-
32060
- .node.selected .node-dot {
32061
- fill: var(--item-bg-selected);
32062
- }
32063
-
32064
- .node.selected.tickled .node-dot {
32065
- fill: var(--item-bg-selected-tickled);
32066
- }
32067
-
32068
- .node.focused .node-dot {
32069
- stroke: var(--accent);
32070
- stroke-width: 3;
32071
- }
32072
-
32073
- .node.filtered {
32074
- opacity: 0.3;
32075
- }
32076
-
32077
- .node.loading .node-dot {
32078
- opacity: 0.6;
32079
- }
32080
-
32081
- .hyperbolictree-label {
32082
- font-size: var(--hyperbolictree-font-size);
32083
- fill: var(--hyperbolictree-node-text);
32084
- cursor: pointer;
32085
- user-select: none;
32086
- }
32087
- `
33727
+ hyperbolicTreeStyles
32088
33728
  ];
32089
33729
  }
32090
33730
  };
@@ -32107,6 +33747,24 @@ __decorate([property({
32107
33747
  type: Number,
32108
33748
  attribute: `max-radius`
32109
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);
32110
33768
  __decorate([property({
32111
33769
  type: Number,
32112
33770
  attribute: `node-radius`
@@ -32124,7 +33782,23 @@ __decorate([property({
32124
33782
  attribute: `max-weight-scale`
32125
33783
  })], HyperbolicTreeListElement.prototype, "maxWeightScale", void 0);
32126
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);
32127
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);
32128
33802
  __decorate([property({
32129
33803
  type: Number,
32130
33804
  attribute: `focus-animation-duration`
@@ -32143,9 +33817,12 @@ __decorate([property({
32143
33817
  attribute: `search-modes`,
32144
33818
  reflect: true
32145
33819
  })], HyperbolicTreeListElement.prototype, "searchModes", void 0);
32146
- __decorate([state()], HyperbolicTreeListElement.prototype, "_focusKey", void 0);
32147
- __decorate([state()], HyperbolicTreeListElement.prototype, "_animCenter", void 0);
32148
- __decorate([state()], HyperbolicTreeListElement.prototype, "_panOffset", void 0);
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);
32149
33826
  HyperbolicTreeListElement = __decorate([safeCustomElement(`ixfx-hyperbolictree-list`)], HyperbolicTreeListElement);
32150
33827
  //#endregion
32151
33828
  //#region src/icicle-tree/layout.ts
@@ -37913,6 +39590,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37913
39590
  const open = this._isOpen(node, depth);
37914
39591
  const isBranch = node.item.isLeaf !== true;
37915
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;
37916
39596
  const tooltip = node.item.tooltip;
37917
39597
  const isCheckedMode = this.interactionMode === `checked`;
37918
39598
  let checkboxTpl = nothing;
@@ -37940,8 +39620,9 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37940
39620
  @pointerenter=${() => this._handleItemPointerEnter(node, depth)}
37941
39621
  >
37942
39622
  ${checkboxTpl}
37943
- ${icon ? html`<ixfx-icon class="item-icon" name="${icon}"></ixfx-icon>` : nothing}
37944
- <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>`}
37945
39626
  ${tooltip ? html`<ixfx-tooltip class="item-tooltip" anchor="--tooltip-miller-${depth}-${node.item.key}" .value=${tooltip}></ixfx-tooltip>` : nothing}
37946
39627
  ${isBranch ? html`<span class="caret" aria-hidden="true">›</span>` : nothing}
37947
39628
  </li>
@@ -37995,6 +39676,17 @@ let MillerListElement = class MillerListElement extends MillerBaseElement {
37995
39676
  text-overflow: ellipsis;
37996
39677
  }
37997
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
+
37998
39690
  .item-tooltip {
37999
39691
  position: absolute;
38000
39692
  }
@@ -51586,6 +53278,6 @@ function init() {
51586
53278
  console.log(`Init`);
51587
53279
  }
51588
53280
  //#endregion
51589
- export { AcTextElement, AcTokenElement, BottomDrawerElement, ButtonElement, Checkbox, ColourPicker, ColourPickerPopup, CrumbNavigationElement, CrumbPathController, CrumbPathElement, DataController, DataDisplayComponent, DataGridElement, DataTableElement, DetailListElement, DockHostElement, DockPanelElement, EditableLabel, EditableNumber, FormElement, FormGroupElement, FormPartElement, GridListElement, GroupedItemListerElement, HyperbolicTreeListElement, ICON_CARET_RIGHT, ICON_CHECK, ICON_CHEVRON_DOWN, ICON_CLOSE, IcicleTreeListElement, IncrSearchTreeController as IncrSearchMillerController, IncrSearchTreeController, IxfxHexEditorElement, IxfxIconElement, IxfxTimelineElement, KeyedElementIndex, LIST_ITEM_MOTION_MS, LIST_SEARCH_COMMAND_IDS, LIST_SEARCH_MODES, LabelledRadialInput, LabelledRangeInput, LabelledSliderInput, LedElement, ListDragSelectController, ListNativeDragController, ListSearchController, ListSelectionController, menu_exports as Menu, MillerBaseElement, MillerListElement, MillerTreeController, MulticolListElement, NarrowedTextElement, NotificationManager, NotificationPillElement, PanTextElement, PanelElement, PanelGroupElement, PlotDataSeries, PlotHistogram, PlotMultiAxis, PlotSingleAxis, PlotXyAxis, PolarPad, RadialInputElement, RadioGroup, RangeElement, RangeInput, RangeMultiElement, RatingElement, SelectHorizElement, ShapefillValueElement, SliderInputElement, SlidingNumberElement, SnackbarElement, SnapContainer, SplitButton, SplitLayoutElement, SplitPanelElement, SwipeElement, TabController, TabListElement, TabListItemElement, TabPanelElement, TabPanelsElement, TextShimmerElement, TickledController, TimelineController, TimelineTrackTabElement, TitlebarElement, ToolbarElement, ToolbarGroupElement, TooltipElement, TransitoryLabelElement, TreeBaseElement, TreeController, TreeDataModel, TreeDropController, TreeKeyboardNavController, TreeListElement, TreemapListElement, TwoSplitLayoutElement, VerticalListElement, XyPad, addPanelToStack, animateListItemIn, animateListItemOut, applyAgeModification, applyHighlights, applySnapshot, applyTreeDrop, attachTimelineInteractions, bottomDrawerCommandId, buildHighlightTargets, buildModel, captionForEntry, clampToDisk, clearHighlights, collectContainerNames, collectDataResult, collectPanelIds, collectStackPaths, computeCanonicalPositions, computeColour, computeHyperbolicLayout, computeIcicleLayout, computeLeafGeoms, computeTreemapLayout, createAcCompletionProvider, createDataListSearchAdapter, createDefaultListDragPreview, createElementIncrSearch, createElementListSearchAdapter, createEmptyLayout, createEmptyStack, createFuzzySearch, createIncrSearch, createPillRenderer, createSplit, createStack, createTreeListAdapter, createTreeListSearchAdapter, createVerticalListAdapter, distribute, dropTargetFor, euclideanRadiusForHyperbolicDistance, extractStack, findHyperbolicNode, findHyperbolicPath, findIcicleNode, findIciclePath, findNode, findPanelNode, findParentOf, findStackOf, findStackPathByName, findTreemapNode, findTreemapPath, geodesicInterpolate, getActiveDragPanelId, getIcon, getIconEntry, getIconFont, getLeafNeighbours, hasIcon, hyperbolicDistanceForEuclideanRadius, iconBus, init, insertBeside, isDragging, isMultiMode, isSplit, isStack, keyForElement, listItemFadeKeyframes, listSearchStyles, mobiusAdd, mobiusFromOrigin, mobiusToOrigin, monitorIndeterminate, moveLeafNodeToEdge, moveLeafStep, norm, normalise, normaliseValue, normalize, panelGapIndex, parseColourScaleAttr, parseLayout, poincareDistance, pointerOverAnyHost, prefersReducedMotion, preserveRestoreWeights, pruneDraggedDescendants, readBaseColour, registerFontIcon, registerIcon, registerListSearchCommands, removePanelFromStack, resolveCssVars, resolveGrid, resolveLeafPath, resolveTracks, serializeLayout, setActive, setIconFont, setPanelRestoreWeight, startListItemOutAnimation, svgToDataUri, swapLeaves, tickledItemStyles, tickledStyles, timelineStyles, toSnapshot, toolbarGapIndex, walk, wrapRoot };
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 };
51590
53282
 
51591
53283
  //# sourceMappingURL=index.js.map