@dxos/plugin-explorer 0.8.4-main.7996785055 → 0.8.4-main.8baae0fced

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 (251) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/neutral/ExplorerArticle-EAKRB55W.mjs +277 -0
  3. package/dist/lib/neutral/ExplorerArticle-EAKRB55W.mjs.map +7 -0
  4. package/dist/lib/neutral/ExplorerPlugin.mjs +10 -0
  5. package/dist/lib/neutral/capabilities/index.mjs +11 -0
  6. package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
  7. package/dist/lib/{browser/chunk-6KEHUEEZ.mjs → neutral/chunk-7SPMPHRS.mjs} +6 -5
  8. package/dist/lib/neutral/chunk-7SPMPHRS.mjs.map +7 -0
  9. package/dist/lib/{browser/chunk-LSUP47BZ.mjs → neutral/chunk-DXIWQFYO.mjs} +2 -4
  10. package/dist/lib/neutral/chunk-DXIWQFYO.mjs.map +7 -0
  11. package/dist/lib/neutral/chunk-EM2BV4PF.mjs +290 -0
  12. package/dist/lib/neutral/chunk-EM2BV4PF.mjs.map +7 -0
  13. package/dist/lib/neutral/chunk-GRJXLL4Z.mjs +25 -0
  14. package/dist/lib/neutral/chunk-GRJXLL4Z.mjs.map +7 -0
  15. package/dist/lib/neutral/chunk-V2OFO6PI.mjs +14 -0
  16. package/dist/lib/neutral/chunk-V2OFO6PI.mjs.map +7 -0
  17. package/dist/lib/{browser/chunk-56VV76WZ.mjs → neutral/components/index.mjs} +706 -356
  18. package/dist/lib/neutral/components/index.mjs.map +7 -0
  19. package/dist/lib/neutral/containers/index.mjs +9 -0
  20. package/dist/lib/neutral/containers/index.mjs.map +7 -0
  21. package/dist/lib/neutral/create-object-F6TKVAGV.mjs +39 -0
  22. package/dist/lib/neutral/create-object-F6TKVAGV.mjs.map +7 -0
  23. package/dist/lib/neutral/hooks/index.mjs +45 -0
  24. package/dist/lib/neutral/hooks/index.mjs.map +7 -0
  25. package/dist/lib/neutral/index.mjs +14 -0
  26. package/dist/lib/neutral/meta.json +1 -0
  27. package/dist/lib/{browser → neutral}/meta.mjs +1 -1
  28. package/dist/lib/neutral/plugin.mjs +12 -0
  29. package/dist/lib/neutral/plugin.mjs.map +7 -0
  30. package/dist/lib/neutral/react-surface-APBW2VQG.mjs +26 -0
  31. package/dist/lib/neutral/react-surface-APBW2VQG.mjs.map +7 -0
  32. package/dist/lib/neutral/testing/index.mjs +193 -0
  33. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  34. package/dist/lib/neutral/translations.mjs +33 -0
  35. package/dist/lib/neutral/translations.mjs.map +7 -0
  36. package/dist/lib/{browser → neutral}/types/index.mjs +1 -1
  37. package/dist/types/data/cities.d.ts +4 -4
  38. package/dist/types/data/cities.d.ts.map +1 -1
  39. package/dist/types/data/countries-110m.d.ts +19 -22
  40. package/dist/types/data/countries-110m.d.ts.map +1 -1
  41. package/dist/types/src/ExplorerPlugin.d.ts +1 -0
  42. package/dist/types/src/ExplorerPlugin.d.ts.map +1 -1
  43. package/dist/types/src/ExplorerPlugin.test.d.ts +2 -0
  44. package/dist/types/src/ExplorerPlugin.test.d.ts.map +1 -0
  45. package/dist/types/src/capabilities/create-object.d.ts +11 -0
  46. package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
  47. package/dist/types/src/capabilities/index.d.ts +8 -1
  48. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  49. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  50. package/dist/types/src/components/Chart/Chart.d.ts +1 -1
  51. package/dist/types/src/components/Chart/Chart.d.ts.map +1 -1
  52. package/dist/types/src/components/Chart/Chart.stories.d.ts +4 -1
  53. package/dist/types/src/components/Chart/Chart.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Globe/Globe.d.ts +1 -1
  55. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
  56. package/dist/types/src/components/Globe/Globe.stories.d.ts +5 -2
  57. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  58. package/dist/types/src/components/Graph/CanvasForceGraph.d.ts +13 -0
  59. package/dist/types/src/components/Graph/CanvasForceGraph.d.ts.map +1 -0
  60. package/dist/types/src/components/Graph/{D3ForceGraph.stories.d.ts → CanvasForceGraph.stories.d.ts} +3 -7
  61. package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts.map +1 -0
  62. package/dist/types/src/components/Graph/ForceGraph.d.ts +12 -5
  63. package/dist/types/src/components/Graph/ForceGraph.d.ts.map +1 -1
  64. package/dist/types/src/components/Graph/ForceGraph.stories.d.ts +3 -1
  65. package/dist/types/src/components/Graph/ForceGraph.stories.d.ts.map +1 -1
  66. package/dist/types/src/components/Graph/{adapter.d.ts → graph-adapter.d.ts} +1 -1
  67. package/dist/types/src/components/Graph/graph-adapter.d.ts.map +1 -0
  68. package/dist/types/src/components/Graph/index.d.ts +1 -1
  69. package/dist/types/src/components/Graph/index.d.ts.map +1 -1
  70. package/dist/types/src/components/Lattice/Lattice.d.ts +20 -0
  71. package/dist/types/src/components/Lattice/Lattice.d.ts.map +1 -0
  72. package/dist/types/src/components/Lattice/Lattice.stories.d.ts +8 -0
  73. package/dist/types/src/components/Lattice/Lattice.stories.d.ts.map +1 -0
  74. package/dist/types/src/components/Lattice/index.d.ts +2 -0
  75. package/dist/types/src/components/Lattice/index.d.ts.map +1 -0
  76. package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts +21 -0
  77. package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts.map +1 -0
  78. package/dist/types/src/components/Tree/Tree.d.ts +20 -23
  79. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  80. package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -12
  81. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  82. package/dist/types/src/components/Tree/index.d.ts +2 -0
  83. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  84. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts +37 -2
  85. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts.map +1 -1
  86. package/dist/types/src/components/Tree/layout/RadialTree.d.ts +35 -2
  87. package/dist/types/src/components/Tree/layout/RadialTree.d.ts.map +1 -1
  88. package/dist/types/src/components/Tree/layout/TidyTree.d.ts +24 -2
  89. package/dist/types/src/components/Tree/layout/TidyTree.d.ts.map +1 -1
  90. package/dist/types/src/components/Tree/layout/hierarchy.d.ts +17 -0
  91. package/dist/types/src/components/Tree/layout/hierarchy.d.ts.map +1 -0
  92. package/dist/types/src/components/Tree/layout/index.d.ts +5 -4
  93. package/dist/types/src/components/Tree/layout/index.d.ts.map +1 -1
  94. package/dist/types/src/components/Tree/layout/slots.d.ts +7 -0
  95. package/dist/types/src/components/Tree/layout/slots.d.ts.map +1 -0
  96. package/dist/types/src/components/Tree/layout/useContainerSize.d.ts +15 -0
  97. package/dist/types/src/components/Tree/layout/useContainerSize.d.ts.map +1 -0
  98. package/dist/types/src/components/Tree/types/tree.d.ts +6 -6
  99. package/dist/types/src/components/Tree/types/tree.d.ts.map +1 -1
  100. package/dist/types/src/components/Tree/types/types.d.ts +14 -4
  101. package/dist/types/src/components/Tree/types/types.d.ts.map +1 -1
  102. package/dist/types/src/components/index.d.ts +1 -0
  103. package/dist/types/src/components/index.d.ts.map +1 -1
  104. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts +9 -0
  105. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts.map +1 -0
  106. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts +29 -0
  107. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts.map +1 -0
  108. package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts +7 -0
  109. package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts.map +1 -0
  110. package/dist/types/src/containers/ExplorerArticle/index.d.ts +2 -0
  111. package/dist/types/src/containers/ExplorerArticle/index.d.ts.map +1 -0
  112. package/dist/types/src/containers/index.d.ts +1 -1
  113. package/dist/types/src/containers/index.d.ts.map +1 -1
  114. package/dist/types/src/hooks/useGraphModel.d.ts +2 -2
  115. package/dist/types/src/hooks/useGraphModel.d.ts.map +1 -1
  116. package/dist/types/src/index.d.ts +1 -3
  117. package/dist/types/src/index.d.ts.map +1 -1
  118. package/dist/types/src/plugin.d.ts +3 -0
  119. package/dist/types/src/plugin.d.ts.map +1 -0
  120. package/dist/types/src/{components/Tree/testing → testing}/generator.d.ts +1 -1
  121. package/dist/types/src/testing/generator.d.ts.map +1 -0
  122. package/dist/types/src/testing/index.d.ts +4 -0
  123. package/dist/types/src/testing/index.d.ts.map +1 -0
  124. package/dist/types/src/testing/relations.d.ts +47 -0
  125. package/dist/types/src/testing/relations.d.ts.map +1 -0
  126. package/dist/types/src/translations.d.ts +28 -28
  127. package/dist/types/src/translations.d.ts.map +1 -1
  128. package/dist/types/src/types/Graph.d.ts +3 -4
  129. package/dist/types/src/types/Graph.d.ts.map +1 -1
  130. package/dist/types/src/util/index.d.ts +3 -0
  131. package/dist/types/src/util/index.d.ts.map +1 -0
  132. package/dist/types/src/util/node-color.d.ts +13 -0
  133. package/dist/types/src/util/node-color.d.ts.map +1 -0
  134. package/dist/types/src/{components → util}/plot.d.ts +1 -1
  135. package/dist/types/src/util/plot.d.ts.map +1 -0
  136. package/dist/types/tsconfig.tsbuildinfo +1 -1
  137. package/package.json +102 -56
  138. package/src/ExplorerPlugin.test.ts +26 -0
  139. package/src/ExplorerPlugin.tsx +7 -35
  140. package/src/capabilities/create-object.ts +36 -0
  141. package/src/capabilities/index.ts +4 -1
  142. package/src/capabilities/react-surface.tsx +32 -0
  143. package/src/components/Chart/Chart.stories.tsx +14 -21
  144. package/src/components/Chart/Chart.tsx +1 -1
  145. package/src/components/Globe/Globe.stories.tsx +17 -20
  146. package/src/components/Globe/Globe.tsx +1 -1
  147. package/src/components/Graph/CanvasForceGraph.stories.tsx +83 -0
  148. package/src/components/Graph/CanvasForceGraph.tsx +124 -0
  149. package/src/components/Graph/ForceGraph.stories.tsx +78 -42
  150. package/src/components/Graph/ForceGraph.tsx +104 -85
  151. package/src/components/Graph/index.ts +1 -1
  152. package/src/components/Lattice/Lattice.stories.tsx +90 -0
  153. package/src/components/Lattice/Lattice.tsx +182 -0
  154. package/src/components/Lattice/index.ts +5 -0
  155. package/src/components/Tree/EdgeBundling.stories.tsx +144 -0
  156. package/src/components/Tree/Tree.stories.tsx +19 -40
  157. package/src/components/Tree/Tree.tsx +69 -100
  158. package/src/components/Tree/index.ts +2 -0
  159. package/src/components/Tree/layout/HierarchicalEdgeBundling.tsx +296 -0
  160. package/src/components/Tree/layout/RadialTree.tsx +242 -0
  161. package/src/components/Tree/layout/TidyTree.tsx +246 -0
  162. package/src/components/Tree/layout/hierarchy.ts +32 -0
  163. package/src/components/Tree/layout/index.ts +5 -5
  164. package/src/components/Tree/layout/slots.ts +19 -0
  165. package/src/components/Tree/layout/useContainerSize.ts +43 -0
  166. package/src/components/Tree/types/tree.test.ts +4 -5
  167. package/src/components/Tree/types/tree.ts +9 -9
  168. package/src/components/Tree/types/types.ts +38 -29
  169. package/src/components/index.ts +1 -0
  170. package/src/containers/ExplorerArticle/ExplorerArticle.stories.tsx +136 -0
  171. package/src/containers/ExplorerArticle/ExplorerArticle.tsx +465 -0
  172. package/src/containers/ExplorerArticle/experimental.stories.tsx +446 -0
  173. package/src/containers/ExplorerArticle/index.ts +5 -0
  174. package/src/containers/index.ts +1 -1
  175. package/src/hooks/useGraphModel.ts +10 -6
  176. package/src/index.ts +1 -4
  177. package/src/meta.ts +1 -1
  178. package/src/plugin.ts +9 -0
  179. package/src/{components/Tree/testing → testing}/generator.ts +2 -2
  180. package/src/testing/index.ts +9 -0
  181. package/src/testing/relations.ts +192 -0
  182. package/src/translations.ts +14 -14
  183. package/src/types/ExplorerAction.ts +1 -1
  184. package/src/types/Graph.ts +2 -3
  185. package/src/typings.d.ts +8 -0
  186. package/src/util/index.ts +6 -0
  187. package/src/util/node-color.ts +23 -0
  188. package/src/{components → util}/plot.ts +16 -4
  189. package/dist/lib/browser/ExplorerContainer-H5RGY6AD.mjs +0 -48
  190. package/dist/lib/browser/ExplorerContainer-H5RGY6AD.mjs.map +0 -7
  191. package/dist/lib/browser/chunk-56VV76WZ.mjs.map +0 -7
  192. package/dist/lib/browser/chunk-6KEHUEEZ.mjs.map +0 -7
  193. package/dist/lib/browser/chunk-LSUP47BZ.mjs.map +0 -7
  194. package/dist/lib/browser/index.mjs +0 -107
  195. package/dist/lib/browser/index.mjs.map +0 -7
  196. package/dist/lib/browser/meta.json +0 -1
  197. package/dist/lib/browser/react-surface-JYGFP5ZN.mjs +0 -38
  198. package/dist/lib/browser/react-surface-JYGFP5ZN.mjs.map +0 -7
  199. package/dist/lib/node-esm/ExplorerContainer-KFHE5KU3.mjs +0 -49
  200. package/dist/lib/node-esm/ExplorerContainer-KFHE5KU3.mjs.map +0 -7
  201. package/dist/lib/node-esm/chunk-35JCF4SD.mjs +0 -11292
  202. package/dist/lib/node-esm/chunk-35JCF4SD.mjs.map +0 -7
  203. package/dist/lib/node-esm/chunk-EN3JZNEY.mjs +0 -26
  204. package/dist/lib/node-esm/chunk-EN3JZNEY.mjs.map +0 -7
  205. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  206. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  207. package/dist/lib/node-esm/chunk-WSE2Z4OT.mjs +0 -72
  208. package/dist/lib/node-esm/chunk-WSE2Z4OT.mjs.map +0 -7
  209. package/dist/lib/node-esm/index.mjs +0 -108
  210. package/dist/lib/node-esm/index.mjs.map +0 -7
  211. package/dist/lib/node-esm/meta.json +0 -1
  212. package/dist/lib/node-esm/meta.mjs +0 -9
  213. package/dist/lib/node-esm/react-surface-HJEJL53N.mjs +0 -39
  214. package/dist/lib/node-esm/react-surface-HJEJL53N.mjs.map +0 -7
  215. package/dist/lib/node-esm/types/index.mjs +0 -11
  216. package/dist/types/src/capabilities/react-surface/index.d.ts +0 -3
  217. package/dist/types/src/capabilities/react-surface/index.d.ts.map +0 -1
  218. package/dist/types/src/capabilities/react-surface/react-surface.d.ts.map +0 -1
  219. package/dist/types/src/components/Graph/D3ForceGraph.d.ts +0 -19
  220. package/dist/types/src/components/Graph/D3ForceGraph.d.ts.map +0 -1
  221. package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts.map +0 -1
  222. package/dist/types/src/components/Graph/adapter.d.ts.map +0 -1
  223. package/dist/types/src/components/Graph/testing.d.ts +0 -14
  224. package/dist/types/src/components/Graph/testing.d.ts.map +0 -1
  225. package/dist/types/src/components/Tree/testing/generator.d.ts.map +0 -1
  226. package/dist/types/src/components/Tree/testing/index.d.ts +0 -2
  227. package/dist/types/src/components/Tree/testing/index.d.ts.map +0 -1
  228. package/dist/types/src/components/plot.d.ts.map +0 -1
  229. package/dist/types/src/containers/ExplorerContainer/ExplorerContainer.d.ts +0 -6
  230. package/dist/types/src/containers/ExplorerContainer/ExplorerContainer.d.ts.map +0 -1
  231. package/dist/types/src/containers/ExplorerContainer/index.d.ts +0 -3
  232. package/dist/types/src/containers/ExplorerContainer/index.d.ts.map +0 -1
  233. package/src/capabilities/react-surface/index.ts +0 -7
  234. package/src/capabilities/react-surface/react-surface.tsx +0 -31
  235. package/src/components/Graph/D3ForceGraph.stories.tsx +0 -83
  236. package/src/components/Graph/D3ForceGraph.tsx +0 -109
  237. package/src/components/Graph/testing.ts +0 -58
  238. package/src/components/Tree/layout/HierarchicalEdgeBundling.ts +0 -162
  239. package/src/components/Tree/layout/RadialTree.ts +0 -94
  240. package/src/components/Tree/layout/TidyTree.ts +0 -101
  241. package/src/components/Tree/testing/index.ts +0 -5
  242. package/src/containers/ExplorerContainer/ExplorerContainer.tsx +0 -53
  243. package/src/containers/ExplorerContainer/index.ts +0 -7
  244. /package/dist/lib/{browser/chunk-J5LGTIGS.mjs.map → neutral/ExplorerPlugin.mjs.map} +0 -0
  245. /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs +0 -0
  246. /package/dist/lib/{browser/meta.mjs.map → neutral/chunk-J5LGTIGS.mjs.map} +0 -0
  247. /package/dist/lib/{browser/types → neutral}/index.mjs.map +0 -0
  248. /package/dist/lib/{node-esm → neutral}/meta.mjs.map +0 -0
  249. /package/dist/lib/{node-esm → neutral}/types/index.mjs.map +0 -0
  250. /package/dist/types/src/capabilities/{react-surface/react-surface.d.ts → react-surface.d.ts} +0 -0
  251. /package/src/components/Graph/{adapter.ts → graph-adapter.ts} +0 -0
@@ -0,0 +1,296 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Copyright 2024 Observable, Inc.
4
+ //
5
+
6
+ import { cluster, curveBundle, hierarchy, lineRadial, select } from 'd3';
7
+ import type { HierarchyNode } from 'd3-hierarchy';
8
+ import React, { useEffect, useMemo, useRef } from 'react';
9
+
10
+ import { type Obj } from '@dxos/echo';
11
+ import { type ThemedClassName } from '@dxos/react-ui';
12
+ import { mx } from '@dxos/ui-theme';
13
+
14
+ import { getNodeFillForObject } from '../../../util/node-color';
15
+ import { type TreeNode } from '../types';
16
+ import { type TreeLayoutSlots, defaultTreeLayoutSlots } from './slots';
17
+ import { useContainerSize } from './useContainerSize';
18
+
19
+ const TRANSITION_MS = 350;
20
+
21
+ /** A directed edge between two leaves of the hierarchy, identified by node id. */
22
+ export type BundleEdge = {
23
+ source: string;
24
+ target: string;
25
+ kind?: string;
26
+ };
27
+
28
+ export type HierarchicalEdgeBundlingProps = ThemedClassName<{
29
+ /** Hierarchical data; leaves are the connectable entities. */
30
+ data: TreeNode;
31
+ /** Edges between leaves (by id). Bundled through the hierarchy. */
32
+ edges?: BundleEdge[];
33
+ /** Label accessor for leaf nodes. */
34
+ label?: (d: TreeNode) => string;
35
+ /** Padding (in screen pixels) reserved around the radial layout. */
36
+ padding?: number;
37
+ /** Bundling tension; 0 = straight, 1 = maximally bundled. */
38
+ tension?: number;
39
+ /** Leaf node circle radius (matches the cluster layout's `r`). */
40
+ r?: number;
41
+ slots?: TreeLayoutSlots;
42
+ /**
43
+ * Called when the user hovers a leaf node (with the event so callers can dispatch
44
+ * `DxAnchorActivate` for previews). Receives `null` on leave.
45
+ */
46
+ onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
47
+ }>;
48
+
49
+ /**
50
+ * Hierarchical edge bundling.
51
+ * https://observablehq.com/@d3/hierarchical-edge-bundling?intent=fork
52
+ *
53
+ * Leaves are placed on a circle, grouped by their parent in the hierarchy.
54
+ * Edges between leaves are drawn as bundled curves that route through their lowest common ancestor.
55
+ */
56
+ export const HierarchicalEdgeBundling = ({
57
+ classNames,
58
+ data,
59
+ edges = [],
60
+ label = (d) => d.label ?? d.id,
61
+ padding = 120,
62
+ tension = 0.85,
63
+ r = 4,
64
+ slots = defaultTreeLayoutSlots,
65
+ onNodeHover,
66
+ }: HierarchicalEdgeBundlingProps) => {
67
+ const svgRef = useRef<SVGSVGElement | null>(null);
68
+ const { setRef, width, height } = useContainerSize();
69
+
70
+ const root = useMemo(() => buildBundleHierarchy(data, edges), [data, edges]);
71
+
72
+ // Stable hover ref so the effect doesn't rebind handlers on every render.
73
+ const handleHoverRef = useRef<(node: TreeNode | null, event?: MouseEvent) => void>(() => {});
74
+ handleHoverRef.current = (node, event) => onNodeHover?.(node, event);
75
+
76
+ useEffect(() => {
77
+ if (!svgRef.current || !width || !height) {
78
+ return;
79
+ }
80
+
81
+ const radius = Math.max(0, Math.min(width, height) / 2 - padding);
82
+ renderBundling(svgRef.current, root, {
83
+ radius,
84
+ r,
85
+ label,
86
+ slots,
87
+ tension,
88
+ onNodeHover: (n, e) => handleHoverRef.current(n, e),
89
+ });
90
+ }, [root, width, height, padding, tension, r, label, slots]);
91
+
92
+ return (
93
+ <div ref={setRef} className={mx('dx-expander relative', classNames)}>
94
+ {width > 0 && height > 0 && (
95
+ <svg
96
+ ref={svgRef}
97
+ xmlns='http://www.w3.org/2000/svg'
98
+ width={width}
99
+ height={height}
100
+ viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
101
+ />
102
+ )}
103
+ </div>
104
+ );
105
+ };
106
+
107
+ type BundleHierarchy = HierarchyNode<TreeNode> & {
108
+ outgoing?: Array<[BundleHierarchy, BundleHierarchy, BundleEdge]>;
109
+ incoming?: Array<[BundleHierarchy, BundleHierarchy, BundleEdge]>;
110
+ pathEl?: SVGPathElement | null;
111
+ text?: SVGTextElement | null;
112
+ };
113
+
114
+ /**
115
+ * Build the bundling hierarchy from data + edges.
116
+ * Edges connect leaves (by id); the cluster layout places leaves on the circle.
117
+ */
118
+ const buildBundleHierarchy = (data: TreeNode, edges: BundleEdge[]): BundleHierarchy => {
119
+ const root = hierarchy<TreeNode>(data) as BundleHierarchy;
120
+ const byId = new Map<string, BundleHierarchy>();
121
+ for (const node of root.descendants() as BundleHierarchy[]) {
122
+ byId.set(node.data.id, node);
123
+ node.outgoing = [];
124
+ node.incoming = [];
125
+ }
126
+
127
+ for (const edge of edges) {
128
+ const source = byId.get(edge.source);
129
+ const target = byId.get(edge.target);
130
+ if (!source || !target || source === target) {
131
+ continue;
132
+ }
133
+ source.outgoing!.push([source, target, edge]);
134
+ target.incoming!.push([source, target, edge]);
135
+ }
136
+
137
+ return root;
138
+ };
139
+
140
+ type RenderOptions = {
141
+ radius: number;
142
+ r: number;
143
+ tension: number;
144
+ label: (d: TreeNode) => string;
145
+ slots: TreeLayoutSlots;
146
+ onNodeHover: (node: TreeNode | null, event?: MouseEvent) => void;
147
+ };
148
+
149
+ const renderBundling = (svgElement: SVGSVGElement, root: BundleHierarchy, options: RenderOptions) => {
150
+ const { radius, r, tension, label, slots, onNodeHover } = options;
151
+ const svg = select(svgElement);
152
+
153
+ // Degenerate root (no descendants yet): clear any previously rendered bundle so a stale
154
+ // graph doesn't linger after the hierarchy empties out, then no-op.
155
+ if (!root.children?.length) {
156
+ svg.selectAll('g.dx-bundle-root').remove();
157
+ return;
158
+ }
159
+
160
+ cluster<TreeNode>().size([2 * Math.PI, radius])(root);
161
+
162
+ const g = svg.selectAll<SVGGElement, null>('g.dx-bundle-root').data([null]).join('g').classed('dx-bundle-root', true);
163
+
164
+ const linksLayer = g
165
+ .selectAll<SVGGElement, null>('g.dx-bundle-links')
166
+ .data([null])
167
+ .join('g')
168
+ .classed('dx-bundle-links', true);
169
+
170
+ const nodesLayer = g
171
+ .selectAll<SVGGElement, null>('g.dx-bundle-nodes')
172
+ .data([null])
173
+ .join('g')
174
+ .classed('dx-bundle-nodes', true);
175
+
176
+ const line = lineRadial<any>()
177
+ .curve(curveBundle.beta(tension))
178
+ .radius((d: any) => d.y)
179
+ .angle((d: any) => d.x);
180
+
181
+ // Each edge: route through the lowest common ancestor via hierarchy.path().
182
+ const leaves = root.leaves() as BundleHierarchy[];
183
+ const flatEdges = leaves.flatMap((leaf) => leaf.outgoing ?? []);
184
+
185
+ const paths = linksLayer
186
+ .selectAll<SVGPathElement, any>('path')
187
+ .data(flatEdges, (d: any) => `${d[0].data.id}->${d[1].data.id}`)
188
+ .join(
189
+ (enter) =>
190
+ enter
191
+ .append('path')
192
+ .attr('class', slots.path ?? '')
193
+ .attr('fill', 'none')
194
+ .attr('opacity', 0),
195
+ (update) => update,
196
+ (exit) =>
197
+ exit
198
+ .each(function () {
199
+ select(this).interrupt();
200
+ })
201
+ .transition()
202
+ .duration(TRANSITION_MS)
203
+ .attr('opacity', 0)
204
+ .remove(),
205
+ );
206
+
207
+ paths
208
+ .each(function (d: any) {
209
+ d[0].pathEl = this;
210
+ })
211
+ .transition()
212
+ .duration(TRANSITION_MS)
213
+ .attr('opacity', 1)
214
+ .attr('d', ([s, t]) => line(s.path(t)));
215
+
216
+ // Render a circle + label per leaf along the perimeter (matches the cluster layout).
217
+ const labels = nodesLayer
218
+ .selectAll<SVGGElement, any>('g.dx-bundle-leaf')
219
+ .data(leaves, (d: any) => d.data.id)
220
+ .join(
221
+ (enter) => {
222
+ const ge = enter.append('g').classed('dx-bundle-leaf', true).attr('opacity', 0);
223
+ ge.append('circle').style('cursor', 'pointer');
224
+ ge.append('text').attr('dy', '0.32em').attr('paint-order', 'stroke').style('cursor', 'pointer');
225
+ return ge;
226
+ },
227
+ (update) => update,
228
+ (exit) =>
229
+ exit
230
+ .each(function () {
231
+ select(this).interrupt();
232
+ })
233
+ .transition()
234
+ .duration(TRANSITION_MS)
235
+ .attr('opacity', 0)
236
+ .remove(),
237
+ );
238
+
239
+ labels
240
+ .transition()
241
+ .duration(TRANSITION_MS)
242
+ .attr('opacity', 1)
243
+ .attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`);
244
+
245
+ const onEnter = function (this: SVGElement, event: MouseEvent, d: BundleHierarchy) {
246
+ onNodeHover(d.data, event);
247
+ hover(linksLayer, leaves, d, true);
248
+ };
249
+ const onLeave = function (this: SVGElement, event: MouseEvent, d: BundleHierarchy) {
250
+ onNodeHover(null);
251
+ hover(linksLayer, leaves, d, false);
252
+ };
253
+
254
+ labels
255
+ .select<SVGCircleElement>('circle')
256
+ .attr('class', [slots.node ?? '', 'dx-leaf'].filter(Boolean).join(' '))
257
+ .attr('r', r)
258
+ .style('fill', (d: BundleHierarchy) => getNodeFillForObject(d.data.data as Obj.Unknown | undefined))
259
+ .on('pointerenter', onEnter)
260
+ .on('pointerleave', onLeave);
261
+
262
+ labels
263
+ .select<SVGTextElement>('text')
264
+ .attr('class', slots.text ?? '')
265
+ .attr('x', (d: any) => (d.x < Math.PI ? r + 4 : -(r + 4)))
266
+ .attr('text-anchor', (d: any) => (d.x < Math.PI ? 'start' : 'end'))
267
+ .attr('transform', (d: any) => (d.x >= Math.PI ? 'rotate(180)' : null))
268
+ .each(function (d: BundleHierarchy) {
269
+ d.text = this;
270
+ })
271
+ .text((d: any) => label(d.data))
272
+ .on('pointerenter', onEnter)
273
+ .on('pointerleave', onLeave);
274
+ };
275
+
276
+ const hover = (linksLayer: any, leaves: BundleHierarchy[], focused: BundleHierarchy, on: boolean) => {
277
+ const outgoing = new Set((focused.outgoing ?? []).map(([, t]) => t));
278
+ const incoming = new Set((focused.incoming ?? []).map(([s]) => s));
279
+
280
+ (linksLayer.selectAll('path') as any)
281
+ .classed('dx-bundle-out', (d: any) => on && d[0] === focused)
282
+ .classed('dx-bundle-in', (d: any) => on && d[1] === focused)
283
+ .classed('dx-bundle-dim', (d: any) => on && d[0] !== focused && d[1] !== focused);
284
+
285
+ for (const leaf of leaves) {
286
+ if (!leaf.text) {
287
+ continue;
288
+ }
289
+ select(leaf.text)
290
+ .classed('dx-bundle-focused', on && leaf === focused)
291
+ .classed('dx-bundle-out-text', on && outgoing.has(leaf))
292
+ .classed('dx-bundle-in-text', on && incoming.has(leaf));
293
+ }
294
+ };
295
+
296
+ export default HierarchicalEdgeBundling;
@@ -0,0 +1,242 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Copyright 2024 Observable, Inc.
4
+ //
5
+
6
+ import { cluster as d3Cluster, linkRadial, select, tree as d3Tree } from 'd3';
7
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+
9
+ import { type Obj } from '@dxos/echo';
10
+ import { type ThemedClassName } from '@dxos/react-ui';
11
+ import { mx } from '@dxos/ui-theme';
12
+
13
+ import { getNodeFillForObject } from '../../../util/node-color';
14
+ import { type TreeNode } from '../types';
15
+ import { buildHierarchy, isCollapsed, isLeaf } from './hierarchy';
16
+ import { type TreeLayoutSlots, defaultTreeLayoutSlots } from './slots';
17
+ import { useContainerSize } from './useContainerSize';
18
+
19
+ const TRANSITION_MS = 350;
20
+
21
+ export type RadialTreeProps = ThemedClassName<{
22
+ data: TreeNode;
23
+ label?: (d: TreeNode) => string;
24
+ slots?: TreeLayoutSlots;
25
+ /** Node radius. */
26
+ r?: number;
27
+ /** Optional padding (in screen pixels) reserved around the radial layout. */
28
+ padding?: number;
29
+ /** Initial set of collapsed node ids. */
30
+ initialCollapsed?: Iterable<string>;
31
+ /**
32
+ * Use `d3.cluster` (all leaves equidistant from center) instead of `d3.tree`.
33
+ * Matches https://observablehq.com/@d3/radial-cluster.
34
+ */
35
+ cluster?: boolean;
36
+ /** Notified when the user clicks a node. */
37
+ onNodeClick?: (node: TreeNode) => void;
38
+ /**
39
+ * Notified on pointerenter (and `null` on pointerleave) for nodes. Used to wire previews
40
+ * (dispatch `DxAnchorActivate`). The event target on enter is the hovered circle — dispatch
41
+ * from it so the preview anchors there.
42
+ */
43
+ onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
44
+ }>;
45
+
46
+ /**
47
+ * Radial tree layout based on the D3 reference component.
48
+ * https://observablehq.com/@d3/radial-tree-component
49
+ *
50
+ * Click a node with children to toggle collapse / expand.
51
+ */
52
+ export const RadialTree = ({
53
+ classNames,
54
+ data,
55
+ label = (d) => d.label ?? d.id,
56
+ slots = defaultTreeLayoutSlots,
57
+ r = 4,
58
+ padding = 80,
59
+ initialCollapsed,
60
+ cluster = false,
61
+ onNodeClick,
62
+ onNodeHover,
63
+ }: RadialTreeProps) => {
64
+ const svgRef = useRef<SVGSVGElement | null>(null);
65
+ const { setRef, width, height } = useContainerSize();
66
+
67
+ const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(initialCollapsed ?? []));
68
+ const toggle = useCallback((id: string) => {
69
+ setCollapsed((prev) => {
70
+ const next = new Set(prev);
71
+ if (next.has(id)) {
72
+ next.delete(id);
73
+ } else {
74
+ next.add(id);
75
+ }
76
+ return next;
77
+ });
78
+ }, []);
79
+
80
+ const handleClickRef = useRef<(node: TreeNode) => void>(() => {});
81
+ handleClickRef.current = (node: TreeNode) => {
82
+ onNodeClick?.(node);
83
+ if (node.children?.length) {
84
+ toggle(node.id);
85
+ }
86
+ };
87
+
88
+ const handleHoverRef = useRef<(node: TreeNode | null, event?: MouseEvent) => void>(() => {});
89
+ handleHoverRef.current = (node: TreeNode | null, event?: MouseEvent) => onNodeHover?.(node, event);
90
+
91
+ const root = useMemo(() => buildHierarchy(data, collapsed), [data, collapsed]);
92
+
93
+ useEffect(() => {
94
+ if (!svgRef.current || !width || !height) {
95
+ return;
96
+ }
97
+
98
+ const radius = Math.max(0, Math.min(width, height) / 2 - padding);
99
+ renderRadialTree(svgRef.current, root, {
100
+ radius,
101
+ r,
102
+ label,
103
+ slots,
104
+ collapsed,
105
+ cluster,
106
+ onNodeClick: (n) => handleClickRef.current(n),
107
+ onNodeHover: (n, e) => handleHoverRef.current(n, e),
108
+ });
109
+ }, [root, width, height, r, padding, label, slots, collapsed, cluster]);
110
+
111
+ return (
112
+ <div ref={setRef} className={mx('dx-expander relative', classNames)}>
113
+ {width > 0 && height > 0 && (
114
+ <svg
115
+ ref={svgRef}
116
+ xmlns='http://www.w3.org/2000/svg'
117
+ width={width}
118
+ height={height}
119
+ viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
120
+ />
121
+ )}
122
+ </div>
123
+ );
124
+ };
125
+
126
+ type RenderOptions = {
127
+ radius: number;
128
+ r: number;
129
+ label: (d: TreeNode) => string;
130
+ slots: TreeLayoutSlots;
131
+ collapsed: Set<string>;
132
+ cluster: boolean;
133
+ onNodeClick: (node: TreeNode) => void;
134
+ onNodeHover: (node: TreeNode | null, event?: MouseEvent) => void;
135
+ };
136
+
137
+ const renderRadialTree = (svgElement: SVGSVGElement, root: any, options: RenderOptions) => {
138
+ const { radius, r, label, slots, collapsed, cluster, onNodeClick, onNodeHover } = options;
139
+ const svg = select(svgElement);
140
+
141
+ const layout = cluster ? d3Cluster<TreeNode>() : d3Tree<TreeNode>();
142
+ layout
143
+ .size([2 * Math.PI, radius])
144
+ .separation((a: any, b: any) => (a.parent === b.parent ? 1 : 2) / Math.max(1, a.depth))(root);
145
+
146
+ const g = svg.selectAll<SVGGElement, null>('g.dx-radial-root').data([null]).join('g').classed('dx-radial-root', true);
147
+
148
+ const linksLayer = g
149
+ .selectAll<SVGGElement, null>('g.dx-radial-links')
150
+ .data([null])
151
+ .join('g')
152
+ .classed('dx-radial-links', true);
153
+
154
+ const nodesLayer = g
155
+ .selectAll<SVGGElement, null>('g.dx-radial-nodes')
156
+ .data([null])
157
+ .join('g')
158
+ .classed('dx-radial-nodes', true);
159
+
160
+ const linkPath = linkRadial<any, any>()
161
+ .angle((d: any) => d.x)
162
+ .radius((d: any) => d.y);
163
+
164
+ linksLayer
165
+ .selectAll<SVGPathElement, any>('path')
166
+ .data(root.links(), (d: any) => `${d.source.data.id}->${d.target.data.id}`)
167
+ .join(
168
+ (enter) =>
169
+ enter
170
+ .append('path')
171
+ .attr('class', slots.path ?? '')
172
+ .attr('fill', 'none')
173
+ .attr('opacity', 0),
174
+ (update) => update,
175
+ (exit) => exit.transition().duration(TRANSITION_MS).attr('opacity', 0).remove(),
176
+ )
177
+ .transition()
178
+ .duration(TRANSITION_MS)
179
+ .attr('opacity', 1)
180
+ .attr('d', linkPath);
181
+
182
+ const node = nodesLayer
183
+ .selectAll<SVGGElement, any>('g.dx-radial-node')
184
+ .data(root.descendants(), (d: any) => d.data.id);
185
+
186
+ const nodeEnter = node
187
+ .enter()
188
+ .append('g')
189
+ .classed('dx-radial-node', true)
190
+ .attr('opacity', 0)
191
+ .attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`)
192
+ .style('cursor', (d: any) => (d.data.children?.length ? 'pointer' : 'default'))
193
+ .on('click', (_, d: any) => onNodeClick(d.data));
194
+
195
+ nodeEnter
196
+ .append('circle')
197
+ .attr('r', r)
198
+ .on('pointerenter', (event: MouseEvent, d: any) => onNodeHover(d.data, event))
199
+ .on('pointerleave', (event: MouseEvent) => onNodeHover(null, event));
200
+
201
+ nodeEnter
202
+ .append('text')
203
+ .attr('dy', '0.32em')
204
+ .attr('paint-order', 'stroke')
205
+ .text((d: any) => label(d.data));
206
+
207
+ const nodeMerge = nodeEnter.merge(node as any);
208
+
209
+ nodeMerge
210
+ .transition()
211
+ .duration(TRANSITION_MS)
212
+ .attr('opacity', 1)
213
+ .attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`);
214
+
215
+ nodeMerge
216
+ .select<SVGCircleElement>('circle')
217
+ .attr('class', (d: any) => {
218
+ const collapsedHere = isCollapsed(d.data, collapsed);
219
+ const leaf = isLeaf(d.data);
220
+ return [slots.node ?? '', collapsedHere ? 'dx-collapsed' : leaf ? 'dx-leaf' : 'dx-branch']
221
+ .filter(Boolean)
222
+ .join(' ');
223
+ })
224
+ .attr('r', r)
225
+ // Color leaves by typename so cluster matches the force / bundle / lattice variants.
226
+ // Branch nodes (groups, root) keep the default slot fill.
227
+ .style('fill', (d: any) => (isLeaf(d.data) ? getNodeFillForObject(d.data.data as Obj.Unknown | undefined) : null));
228
+
229
+ nodeMerge
230
+ .select<SVGTextElement>('text')
231
+ .attr('class', slots.text ?? '')
232
+ .attr('transform', (d: any) => (d.x >= Math.PI ? 'rotate(180)' : null))
233
+ // eslint-disable-next-line no-mixed-operators
234
+ .attr('x', (d: any) => (d.x < Math.PI === !d.children ? r + 4 : -(r + 4)))
235
+ // eslint-disable-next-line no-mixed-operators
236
+ .attr('text-anchor', (d: any) => (d.x < Math.PI === !d.children ? 'start' : 'end'))
237
+ .text((d: any) => label(d.data));
238
+
239
+ node.exit().transition().duration(TRANSITION_MS).attr('opacity', 0).remove();
240
+ };
241
+
242
+ export default RadialTree;