@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,246 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Copyright 2024 Observable, Inc.
4
+ //
5
+
6
+ import { curveBumpX, link as d3Link, select, tree as d3Tree } from 'd3';
7
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+
9
+ import { type ThemedClassName } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
11
+
12
+ import { type TreeNode } from '../types';
13
+ import { buildHierarchy, isCollapsed, isLeaf } from './hierarchy';
14
+ import { type TreeLayoutSlots, defaultTreeLayoutSlots } from './slots';
15
+ import { useContainerSize } from './useContainerSize';
16
+
17
+ const TRANSITION_MS = 350;
18
+
19
+ export type TidyTreeProps = ThemedClassName<{
20
+ data: TreeNode;
21
+ label?: (d: TreeNode) => string;
22
+ slots?: TreeLayoutSlots;
23
+ /** Node radius. */
24
+ r?: number;
25
+ /** Margin in screen pixels reserved around the layout. */
26
+ margin?: number;
27
+ /** Initial set of collapsed node ids. */
28
+ initialCollapsed?: Iterable<string>;
29
+ /** Notified when the user clicks a node. */
30
+ onNodeClick?: (node: TreeNode) => void;
31
+ }>;
32
+
33
+ /**
34
+ * Tidy (horizontal) tree layout based on the D3 reference component.
35
+ * https://observablehq.com/@d3/tree-component
36
+ *
37
+ * Click a node with children to toggle collapse / expand.
38
+ */
39
+ export const TidyTree = ({
40
+ classNames,
41
+ data,
42
+ label = (d) => d.label ?? d.id,
43
+ slots = defaultTreeLayoutSlots,
44
+ r = 4,
45
+ margin = 24,
46
+ initialCollapsed,
47
+ onNodeClick,
48
+ }: TidyTreeProps) => {
49
+ const svgRef = useRef<SVGSVGElement | null>(null);
50
+ const { setRef, width, height } = useContainerSize();
51
+
52
+ const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(initialCollapsed ?? []));
53
+ const toggle = useCallback((id: string) => {
54
+ setCollapsed((prev) => {
55
+ const next = new Set(prev);
56
+ if (next.has(id)) {
57
+ next.delete(id);
58
+ } else {
59
+ next.add(id);
60
+ }
61
+ return next;
62
+ });
63
+ }, []);
64
+
65
+ // Stable click handler ref so the d3 render fn doesn't need to be re-bound.
66
+ const handleClickRef = useRef<(node: TreeNode) => void>(() => {});
67
+ handleClickRef.current = (node: TreeNode) => {
68
+ onNodeClick?.(node);
69
+ if (node.children?.length) {
70
+ toggle(node.id);
71
+ }
72
+ };
73
+
74
+ const root = useMemo(() => buildHierarchy(data, collapsed), [data, collapsed]);
75
+
76
+ useEffect(() => {
77
+ if (!svgRef.current || !width || !height) {
78
+ return;
79
+ }
80
+
81
+ renderTidyTree(svgRef.current, root, {
82
+ width,
83
+ height,
84
+ r,
85
+ margin,
86
+ label,
87
+ slots,
88
+ collapsed,
89
+ onNodeClick: (n) => handleClickRef.current(n),
90
+ });
91
+ }, [root, width, height, r, margin, label, slots, collapsed]);
92
+
93
+ return (
94
+ <div ref={setRef} className={mx('dx-expander relative', classNames)}>
95
+ {width > 0 && height > 0 && (
96
+ <svg
97
+ ref={svgRef}
98
+ xmlns='http://www.w3.org/2000/svg'
99
+ width={width}
100
+ height={height}
101
+ viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
102
+ />
103
+ )}
104
+ </div>
105
+ );
106
+ };
107
+
108
+ type RenderOptions = {
109
+ width: number;
110
+ height: number;
111
+ r: number;
112
+ margin: number;
113
+ label: (d: TreeNode) => string;
114
+ slots: TreeLayoutSlots;
115
+ collapsed: Set<string>;
116
+ onNodeClick: (node: TreeNode) => void;
117
+ };
118
+
119
+ const renderTidyTree = (svgElement: SVGSVGElement, root: any, options: RenderOptions) => {
120
+ const { width, height, r, margin, label, slots, collapsed, onNodeClick } = options;
121
+ const svg = select(svgElement);
122
+
123
+ // Compute layout: nodeSize gives a stable, content-driven scale.
124
+ // dx = vertical spacing between siblings; dy = horizontal spacing between depths.
125
+ const dx = 18;
126
+ const dy = Math.max(60, (width - margin * 2) / Math.max(1, root.height + 1));
127
+ d3Tree<TreeNode>().nodeSize([dx, dy])(root);
128
+
129
+ // Center the tree vertically; lay it out horizontally from left.
130
+ let x0 = Infinity;
131
+ let x1 = -x0;
132
+ root.each((d: any) => {
133
+ if (d.x > x1) {
134
+ x1 = d.x;
135
+ }
136
+ if (d.x < x0) {
137
+ x0 = d.x;
138
+ }
139
+ });
140
+
141
+ const treeWidth = width - margin * 2;
142
+ const treeHeight = x1 - x0;
143
+ const scaleY = treeHeight > 0 ? Math.min(1, (height - margin * 2) / treeHeight) : 1;
144
+ const offsetX = -treeWidth / 2;
145
+ const offsetY = -(x0 + x1) / 2;
146
+
147
+ // Root group ensures consistent transforms on re-renders.
148
+ const g = svg.selectAll<SVGGElement, null>('g.dx-tidy-root').data([null]).join('g').classed('dx-tidy-root', true);
149
+
150
+ // Links layer.
151
+ const linksLayer = g
152
+ .selectAll<SVGGElement, null>('g.dx-tidy-links')
153
+ .data([null])
154
+ .join('g')
155
+ .classed('dx-tidy-links', true);
156
+
157
+ // Nodes layer (rendered above links).
158
+ const nodesLayer = g
159
+ .selectAll<SVGGElement, null>('g.dx-tidy-nodes')
160
+ .data([null])
161
+ .join('g')
162
+ .classed('dx-tidy-nodes', true);
163
+
164
+ // Links.
165
+ const linkPath = d3Link<any, any>(curveBumpX)
166
+ .x((d: any) => offsetX + d.y)
167
+ .y((d: any) => (d.x + offsetY) * scaleY);
168
+
169
+ linksLayer
170
+ .selectAll<SVGPathElement, any>('path')
171
+ .data(root.links(), (d: any) => `${d.source.data.id}->${d.target.data.id}`)
172
+ .join(
173
+ (enter) =>
174
+ enter
175
+ .append('path')
176
+ .attr('class', slots.path ?? '')
177
+ .attr('fill', 'none')
178
+ .attr('opacity', 0),
179
+ (update) => update,
180
+ (exit) => exit.transition().duration(TRANSITION_MS).attr('opacity', 0).remove(),
181
+ )
182
+ .transition()
183
+ .duration(TRANSITION_MS)
184
+ .attr('opacity', 1)
185
+ .attr('d', linkPath);
186
+
187
+ // Nodes.
188
+ const node = nodesLayer.selectAll<SVGGElement, any>('g.dx-tidy-node').data(root.descendants(), (d: any) => d.data.id);
189
+
190
+ const nodeEnter = node
191
+ .enter()
192
+ .append('g')
193
+ .classed('dx-tidy-node', true)
194
+ .attr('transform', (d: any) => `translate(${offsetX + d.y},${(d.x + offsetY) * scaleY})`)
195
+ .attr('opacity', 0)
196
+ .style('cursor', (d: any) => (d.data.children?.length ? 'pointer' : 'default'))
197
+ .on('click', (_, d: any) => onNodeClick(d.data));
198
+
199
+ nodeEnter.append('circle').attr('r', r);
200
+ nodeEnter
201
+ .append('text')
202
+ .attr('dy', '0.32em')
203
+ .attr('x', (d: any) => (d.children ? -(r + 4) : r + 4))
204
+ .attr('text-anchor', (d: any) => (d.children ? 'end' : 'start'))
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) => `translate(${offsetX + d.y},${(d.x + offsetY) * scaleY})`);
214
+
215
+ // Circle: filled when collapsed (has hidden children), outlined when expanded or leaf.
216
+ nodeMerge
217
+ .select<SVGCircleElement>('circle')
218
+ .attr('class', (d: any) => {
219
+ const collapsedHere = isCollapsed(d.data, collapsed);
220
+ const leaf = isLeaf(d.data);
221
+ return [slots.node ?? '', collapsedHere ? 'dx-collapsed' : leaf ? 'dx-leaf' : 'dx-branch']
222
+ .filter(Boolean)
223
+ .join(' ');
224
+ })
225
+ .attr('r', r);
226
+
227
+ nodeMerge
228
+ .select<SVGTextElement>('text')
229
+ .attr('class', slots.text ?? '')
230
+ .attr('x', (d: any) => (d.children ? -(r + 4) : r + 4))
231
+ .attr('text-anchor', (d: any) => (d.children ? 'end' : 'start'))
232
+ .text((d: any) => label(d.data));
233
+
234
+ node
235
+ .exit()
236
+ .each(function () {
237
+ // Cancel any in-flight transitions so .remove() actually fires.
238
+ select(this).interrupt();
239
+ })
240
+ .transition()
241
+ .duration(TRANSITION_MS)
242
+ .attr('opacity', 0)
243
+ .remove();
244
+ };
245
+
246
+ export default TidyTree;
@@ -0,0 +1,32 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type HierarchyNode, hierarchy as d3Hierarchy } from 'd3';
6
+
7
+ import { type TreeNode } from '../types';
8
+
9
+ /**
10
+ * Build a d3 hierarchy from a TreeNode, pruning children of nodes whose ids are in `collapsed`.
11
+ * Nodes that have children but are collapsed retain their identity in the hierarchy and can be
12
+ * distinguished by `node._children` (the original list).
13
+ */
14
+ export const buildHierarchy = (data: TreeNode, collapsed: Set<string> = new Set()): HierarchyNode<TreeNode> => {
15
+ return d3Hierarchy<TreeNode>(data, (d) => {
16
+ if (!d.children?.length) {
17
+ return undefined;
18
+ }
19
+ return collapsed.has(d.id) ? undefined : d.children;
20
+ });
21
+ };
22
+
23
+ /**
24
+ * True when the node has children that have been hidden via collapse.
25
+ */
26
+ export const isCollapsed = (data: TreeNode, collapsed: Set<string>): boolean =>
27
+ Boolean(data.children?.length) && collapsed.has(data.id);
28
+
29
+ /**
30
+ * True when the node has no children at all (a real leaf, not a collapsed branch).
31
+ */
32
+ export const isLeaf = (data: TreeNode): boolean => !data.children?.length;
@@ -2,8 +2,8 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import HierarchicalEdgeBundling from './HierarchicalEdgeBundling';
6
- import RadialTree from './RadialTree';
7
- import TidyTree from './TidyTree';
8
-
9
- export { HierarchicalEdgeBundling, RadialTree, TidyTree };
5
+ export * from './HierarchicalEdgeBundling';
6
+ export * from './RadialTree';
7
+ export * from './TidyTree';
8
+ export * from './hierarchy';
9
+ export * from './slots';
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export type TreeLayoutSlots = {
6
+ node?: string;
7
+ path?: string;
8
+ text?: string;
9
+ };
10
+
11
+ export const defaultTreeLayoutSlots: TreeLayoutSlots = {
12
+ // Cursor + transition so the hover swap reads clearly; SVG circles support the `:hover` pseudo-class
13
+ // via Tailwind variants exactly like HTML elements.
14
+ node: 'fill-blue-600 hover:fill-orange-500 cursor-pointer transition-colors',
15
+ // 0.5px is fine on a white background, but on a dark Storybook background the lines disappear.
16
+ // Use stroke-1 with opacity 50% so they read in both themes; dx-bundle-dim/out/in further tune on hover.
17
+ path: 'fill-none stroke-blue-500/50 stroke-[1px] dark:stroke-blue-400/60',
18
+ text: 'fill-neutral-700 dark:fill-neutral-300 text-xs hover:fill-orange-500 cursor-pointer transition-colors',
19
+ };
@@ -0,0 +1,43 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { useEffect, useState } from 'react';
6
+
7
+ /**
8
+ * Track an element's content-box size via ResizeObserver.
9
+ * Returns the most recently observed `{ width, height }` plus a callback ref to attach to the element.
10
+ *
11
+ * Why not `react-resize-detector` directly: its `targetRef` API doesn't pick up a ref whose
12
+ * `.current` is set later than the hook runs, and the returned-`ref` API forces the consumer to
13
+ * forward a callback ref through their own component — which is awkward for class refs.
14
+ * This hook returns a setter so the consumer assigns it directly to `<div ref={setRef}>`.
15
+ */
16
+ export const useContainerSize = (): {
17
+ setRef: (el: HTMLDivElement | null) => void;
18
+ width: number;
19
+ height: number;
20
+ } => {
21
+ const [el, setEl] = useState<HTMLDivElement | null>(null);
22
+ const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
23
+
24
+ useEffect(() => {
25
+ if (!el) {
26
+ return;
27
+ }
28
+ const rect = el.getBoundingClientRect();
29
+ setSize({ width: rect.width, height: rect.height });
30
+ const observer = new ResizeObserver((entries) => {
31
+ const entry = entries[0];
32
+ if (!entry) {
33
+ return;
34
+ }
35
+ const { width, height } = entry.contentRect;
36
+ setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
37
+ });
38
+ observer.observe(el);
39
+ return () => observer.disconnect();
40
+ }, [el]);
41
+
42
+ return { setRef: setEl, width: size.width, height: size.height };
43
+ };
@@ -5,14 +5,13 @@
5
5
  import { describe, test } from 'vitest';
6
6
 
7
7
  import { Obj, Ref } from '@dxos/echo';
8
- import { faker } from '@dxos/random';
8
+ import { random } from '@dxos/random';
9
9
  import { Task } from '@dxos/types';
10
10
 
11
- import { createTree } from '../testing';
12
-
11
+ import { createTree } from '../../../testing';
13
12
  import { type Tree } from './tree';
14
13
 
15
- faker.seed(0);
14
+ random.seed(0);
16
15
 
17
16
  const print = (tree: Tree) => {
18
17
  let count = 0;
@@ -129,7 +128,7 @@ describe('tree', () => {
129
128
 
130
129
  const tree = createTree();
131
130
  const node = tree.addNode(tree.root);
132
- Obj.change(tree.tree, () => {
131
+ Obj.update(tree.tree, () => {
133
132
  node.ref = Ref.make(task);
134
133
  });
135
134
  });
@@ -185,7 +185,7 @@ export class Tree {
185
185
  clear(): void {
186
186
  const root = this._tree.nodes[this._tree.root];
187
187
  root.children.length = 0;
188
- Obj.change(this._tree, (obj) => {
188
+ Obj.update(this._tree, (obj) => {
189
189
  obj.nodes = {
190
190
  [root.id]: root,
191
191
  };
@@ -202,7 +202,7 @@ export class Tree {
202
202
  }
203
203
 
204
204
  const nodeToAdd = node;
205
- Obj.change(this._tree, (obj) => {
205
+ Obj.update(this._tree, (obj) => {
206
206
  obj.nodes[nodeToAdd.id] = nodeToAdd;
207
207
  parent.children.splice(index ?? parent.children.length, 0, nodeToAdd.id);
208
208
  });
@@ -218,12 +218,12 @@ export class Tree {
218
218
  return undefined;
219
219
  }
220
220
 
221
- Obj.change(this._tree, (obj) => {
221
+ Obj.update(this._tree, (obj) => {
222
222
  delete obj.nodes[node.id];
223
223
  });
224
224
  const idx = parent.children.findIndex((child) => child === id);
225
225
  if (idx !== -1) {
226
- Obj.change(this._tree, () => {
226
+ Obj.update(this._tree, () => {
227
227
  parent.children.splice(idx, 1);
228
228
  });
229
229
  }
@@ -242,7 +242,7 @@ export class Tree {
242
242
  }
243
243
 
244
244
  const child = node.children[from];
245
- Obj.change(this._tree, () => {
245
+ Obj.update(this._tree, () => {
246
246
  node.children.splice(from, 1);
247
247
  node.children.splice(to, 0, child);
248
248
  });
@@ -264,7 +264,7 @@ export class Tree {
264
264
  }
265
265
 
266
266
  const previous = this.getNode(parent.children[idx - 1]);
267
- Obj.change(this._tree, () => {
267
+ Obj.update(this._tree, () => {
268
268
  parent.children.splice(idx, 1);
269
269
  previous.children.push(node.id);
270
270
  });
@@ -287,19 +287,19 @@ export class Tree {
287
287
  // Remove node from parent and get following siblings.
288
288
  const nodeIdx = parent.children.findIndex((id) => id === node.id);
289
289
  let rest: Key.ObjectId[] = [];
290
- Obj.change(this._tree, () => {
290
+ Obj.update(this._tree, () => {
291
291
  const removed = parent.children.splice(nodeIdx, parent.children.length - nodeIdx);
292
292
  rest = removed.slice(1); // Skip the node itself.
293
293
  });
294
294
 
295
295
  // Add to ancestor.
296
296
  const parentIdx = this.getChildNodes(ancestor).findIndex((n) => n.id === parent.id);
297
- Obj.change(this._tree, () => {
297
+ Obj.update(this._tree, () => {
298
298
  ancestor.children.splice(parentIdx + 1, 0, node.id);
299
299
  });
300
300
 
301
301
  // Transplant following siblings to current node.
302
- Obj.change(this._tree, () => {
302
+ Obj.update(this._tree, () => {
303
303
  node.children.push(...rest);
304
304
  });
305
305
  }
@@ -2,40 +2,49 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type GraphModel } from '@dxos/graph';
5
+ import { type Key } from '@dxos/echo';
6
6
 
7
- export type TreeNode = {
7
+ import { type TreeType } from './tree';
8
+
9
+ /**
10
+ * In-memory tree shape used by the d3 layouts.
11
+ * `data` carries through to layout callbacks (e.g. hover/inspect) — typically an ECHO object on leaves.
12
+ */
13
+ export type TreeNode<TData = unknown> = {
8
14
  id: string;
9
15
  label?: string;
10
- children?: TreeNode[];
16
+ data?: TData;
17
+ children?: TreeNode<TData>[];
11
18
  };
12
19
 
13
- export const mapGraphToTreeData = (model: GraphModel.GraphModel, maxDepth = 8): TreeNode | undefined => {
14
- // TODO(burdon): Convert to common/graph.
15
- // const mapNode = (node: N, depth = 0): TreeNode => {
16
- // const treeNode: TreeNode = {
17
- // id: model.idAccessor(node),
18
- // label: model.idAccessor(node).slice(0, 8),
19
- // };
20
-
21
- // const links = model.graph.links.filter((link) => link.source === treeNode.id);
22
- // if (depth < maxDepth) {
23
- // treeNode.children = links.map((link) =>
24
- // mapNode(model.graph.nodes.find((node) => model.idAccessor(node) === link.target)!, depth + 1),
25
- // );
26
- // }
20
+ /**
21
+ * Convert an ECHO `TreeType` (id-keyed node map) into a nested `TreeNode` hierarchy.
22
+ * Returns `undefined` if the root id is missing the tree is then incomplete and shouldn't render.
23
+ */
24
+ export const treeTypeToTreeNode = (
25
+ tree: TreeType,
26
+ rootId: Key.ObjectId = tree.root,
27
+ visited: Set<string> = new Set(),
28
+ ): TreeNode | undefined => {
29
+ const node = tree.nodes[rootId];
30
+ if (!node) {
31
+ return undefined;
32
+ }
33
+ if (visited.has(rootId)) {
34
+ return { id: rootId, label: labelOf(node), data: node.data };
35
+ }
36
+ visited.add(rootId);
27
37
 
28
- // return treeNode;
29
- // };
30
-
31
- let data: TreeNode | undefined;
32
- // TODO(burdon): Selection model.
33
- // if (model.selected) {
34
- // const node = model.graph.nodes.find((node) => model.idAccessor(node) === model.selected);
35
- // if (node) {
36
- // data = mapNode(node);
37
- // }
38
- // }
38
+ return {
39
+ id: rootId,
40
+ label: labelOf(node),
41
+ data: node.data,
42
+ children: node.children
43
+ .map((childId) => treeTypeToTreeNode(tree, childId, visited))
44
+ .filter((c): c is TreeNode => Boolean(c)),
45
+ };
46
+ };
39
47
 
40
- return data;
48
+ const labelOf = (node: { data: Record<string, any> }): string | undefined => {
49
+ return typeof node.data?.text === 'string' ? node.data.text : undefined;
41
50
  };
@@ -5,4 +5,5 @@
5
5
  export * from './Chart';
6
6
  export * from './Globe';
7
7
  export * from './Graph';
8
+ export * from './Lattice';
8
9
  export * from './Tree';
@@ -0,0 +1,136 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Effect from 'effect/Effect';
7
+ import React from 'react';
8
+
9
+ import { withPluginManager } from '@dxos/app-framework/testing';
10
+ import { Filter, Query, Type, View } from '@dxos/echo';
11
+ import { ClientPlugin, initializeIdentity } from '@dxos/plugin-client/testing';
12
+ import { PreviewPlugin } from '@dxos/plugin-preview/testing';
13
+ import { StorybookPlugin, corePlugins } from '@dxos/plugin-testing';
14
+ import { random } from '@dxos/random';
15
+ import { useQuery, useSpaces } from '@dxos/react-client/echo';
16
+ import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
17
+ import { ViewModel } from '@dxos/schema';
18
+ import { type ValueGenerator } from '@dxos/schema/testing';
19
+ import { HasRelationship, Organization, Person, Pipeline } from '@dxos/types';
20
+
21
+ import { generate } from '../../testing';
22
+ import { Graph } from '../../types';
23
+ import { ExplorerArticle, type ExplorerArticleVariant } from './ExplorerArticle';
24
+
25
+ const generator = random as any as ValueGenerator;
26
+
27
+ random.seed(7);
28
+
29
+ type StoryArgs = { variant: ExplorerArticleVariant };
30
+
31
+ const DefaultStory = ({ variant }: StoryArgs) => {
32
+ const [space] = useSpaces();
33
+ const [graph] = useQuery(space?.db, Filter.type(Graph.Graph));
34
+ if (!space || !graph) {
35
+ return <Loading data={{ space: !!space, graph: !!graph }} />;
36
+ }
37
+
38
+ return <ExplorerArticle role='article' subject={graph as any} attendableId={graph.id} variant={variant} />;
39
+ };
40
+
41
+ const meta: Meta<StoryArgs> = {
42
+ title: 'plugins/plugin-explorer/containers/ExplorerArticle',
43
+ render: DefaultStory,
44
+ decorators: [
45
+ withTheme(),
46
+ withLayout({ layout: 'fullscreen' }),
47
+ withPluginManager({
48
+ plugins: [
49
+ ...corePlugins(),
50
+ StorybookPlugin({}),
51
+ ClientPlugin({
52
+ types: [
53
+ Graph.Graph,
54
+ View.View,
55
+ HasRelationship.HasRelationship,
56
+ Organization.Organization,
57
+ Pipeline.Pipeline,
58
+ Person.Person,
59
+ ],
60
+ onClientInitialized: ({ client }) =>
61
+ Effect.gen(function* () {
62
+ const { personalSpace } = yield* initializeIdentity(client);
63
+ yield* Effect.promise(() => generate(personalSpace, generator));
64
+ const { view } = yield* Effect.promise(() =>
65
+ ViewModel.makeFromDatabase({
66
+ db: personalSpace.db,
67
+ typename: Type.getTypename(Graph.Graph),
68
+ }),
69
+ );
70
+
71
+ const graph = personalSpace.db.add(
72
+ Graph.make({
73
+ name: 'Root',
74
+ view,
75
+ query: {
76
+ ast: Query.select(Filter.everything()).ast,
77
+ },
78
+ }),
79
+ );
80
+
81
+ yield* Effect.promise(() => personalSpace.db.flush({ indexes: true }));
82
+ return graph;
83
+ }),
84
+ }),
85
+ PreviewPlugin(),
86
+ ],
87
+ }),
88
+ ],
89
+ parameters: {
90
+ layout: 'fullscreen',
91
+ },
92
+ };
93
+
94
+ export default meta;
95
+
96
+ type Story = StoryObj<StoryArgs>;
97
+
98
+ /**
99
+ * Default force-directed view (the production layout).
100
+ */
101
+ export const Force: Story = {
102
+ args: {
103
+ variant: 'force',
104
+ },
105
+ };
106
+
107
+ /**
108
+ * Radial cluster: every object on the perimeter, grouped by its schema, all under a single database root.
109
+ * Inspired by https://observablehq.com/@d3/radial-cluster.
110
+ */
111
+ export const Cluster: Story = {
112
+ args: {
113
+ variant: 'cluster',
114
+ },
115
+ };
116
+
117
+ /**
118
+ * Hierarchical edge bundling: same hierarchy as `cluster`, with bundled curves
119
+ * routed through the lowest common ancestor for every relation / ref in the space.
120
+ * Inspired by https://observablehq.com/@d3/hierarchical-edge-bundling.
121
+ */
122
+ export const Bundle: Story = {
123
+ args: {
124
+ variant: 'bundle',
125
+ },
126
+ };
127
+
128
+ /**
129
+ * Lattice: every object as a cell in a square-as-possible CSS grid, sorted by typename so
130
+ * objects of the same type cluster together. Each cell is colored by its typename.
131
+ */
132
+ export const Lattice: Story = {
133
+ args: {
134
+ variant: 'lattice',
135
+ },
136
+ };