@dxos/plugin-explorer 0.8.4-main.abd8ff62ef → 0.8.4-main.bc2380dfbc
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.
- package/LICENSE +102 -5
- package/dist/lib/neutral/ExplorerArticle-EW2MBCRK.mjs +141 -0
- package/dist/lib/neutral/ExplorerArticle-EW2MBCRK.mjs.map +7 -0
- package/dist/lib/neutral/ExplorerPlugin.mjs +10 -0
- package/dist/lib/neutral/capabilities/index.mjs +11 -0
- package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
- package/dist/lib/{browser/types/index.mjs → neutral/chunk-7SPMPHRS.mjs} +7 -7
- package/dist/lib/neutral/chunk-7SPMPHRS.mjs.map +7 -0
- package/dist/lib/neutral/chunk-GRJXLL4Z.mjs +25 -0
- package/dist/lib/neutral/chunk-GRJXLL4Z.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/components/index.mjs +661 -297
- package/dist/lib/{browser → neutral}/components/index.mjs.map +4 -4
- package/dist/lib/neutral/containers/index.mjs +9 -0
- package/dist/lib/neutral/containers/index.mjs.map +7 -0
- package/dist/lib/neutral/create-object-F6TKVAGV.mjs +39 -0
- package/dist/lib/neutral/create-object-F6TKVAGV.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/hooks/index.mjs +11 -6
- package/dist/lib/neutral/hooks/index.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +14 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser/index.mjs → neutral/plugin.mjs} +3 -4
- package/dist/lib/{browser/index.mjs.map → neutral/plugin.mjs.map} +3 -3
- package/dist/lib/neutral/react-surface-APBW2VQG.mjs +26 -0
- package/dist/lib/neutral/react-surface-APBW2VQG.mjs.map +7 -0
- package/dist/lib/neutral/testing.mjs +8 -0
- package/dist/lib/neutral/testing.mjs.map +7 -0
- package/dist/lib/neutral/translations.mjs +33 -0
- package/dist/lib/neutral/translations.mjs.map +7 -0
- package/dist/lib/neutral/types/index.mjs +10 -0
- package/dist/lib/neutral/types/index.mjs.map +7 -0
- package/dist/types/src/ExplorerPlugin.d.ts.map +1 -1
- package/dist/types/src/capabilities/create-object.d.ts +11 -0
- package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +6 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/components/Chart/Chart.stories.d.ts +4 -1
- package/dist/types/src/components/Chart/Chart.stories.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.stories.d.ts +5 -2
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Graph/CanvasForceGraph.d.ts +13 -0
- package/dist/types/src/components/Graph/CanvasForceGraph.d.ts.map +1 -0
- package/dist/types/src/components/Graph/{D3ForceGraph.stories.d.ts → CanvasForceGraph.stories.d.ts} +2 -2
- package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts.map +1 -0
- package/dist/types/src/components/Graph/ForceGraph.d.ts +12 -5
- package/dist/types/src/components/Graph/ForceGraph.d.ts.map +1 -1
- package/dist/types/src/components/Graph/ForceGraph.stories.d.ts +3 -1
- package/dist/types/src/components/Graph/ForceGraph.stories.d.ts.map +1 -1
- package/dist/types/src/components/Graph/{adapter.d.ts → graph-adapter.d.ts} +1 -1
- package/dist/types/src/components/Graph/graph-adapter.d.ts.map +1 -0
- package/dist/types/src/components/Graph/index.d.ts +1 -1
- package/dist/types/src/components/Graph/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts +21 -0
- package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts.map +1 -0
- package/dist/types/src/components/Tree/Tree.d.ts +20 -23
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -12
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/index.d.ts +3 -0
- package/dist/types/src/components/Tree/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts +35 -2
- package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/RadialTree.d.ts +35 -2
- package/dist/types/src/components/Tree/layout/RadialTree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/TidyTree.d.ts +24 -2
- package/dist/types/src/components/Tree/layout/TidyTree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/hierarchy.d.ts +17 -0
- package/dist/types/src/components/Tree/layout/hierarchy.d.ts.map +1 -0
- package/dist/types/src/components/Tree/layout/index.d.ts +5 -4
- package/dist/types/src/components/Tree/layout/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/slots.d.ts +7 -0
- package/dist/types/src/components/Tree/layout/slots.d.ts.map +1 -0
- package/dist/types/src/components/Tree/layout/useContainerSize.d.ts +15 -0
- package/dist/types/src/components/Tree/layout/useContainerSize.d.ts.map +1 -0
- package/dist/types/src/components/Tree/space-graph-adapter.d.ts +32 -0
- package/dist/types/src/components/Tree/space-graph-adapter.d.ts.map +1 -0
- package/dist/types/src/components/Tree/testing/index.d.ts +1 -0
- package/dist/types/src/components/Tree/testing/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/testing/relations.d.ts +47 -0
- package/dist/types/src/components/Tree/testing/relations.d.ts.map +1 -0
- package/dist/types/src/components/Tree/types/types.d.ts +14 -4
- package/dist/types/src/components/Tree/types/types.d.ts.map +1 -1
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts +8 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts +24 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/index.d.ts +2 -0
- package/dist/types/src/containers/ExplorerArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/index.d.ts +1 -1
- package/dist/types/src/containers/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useGraphModel.d.ts +2 -2
- package/dist/types/src/hooks/useGraphModel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/plugin.d.ts +3 -0
- package/dist/types/src/plugin.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +2 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/dist/types/src/types/Graph.d.ts +1 -2
- package/dist/types/src/types/Graph.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +94 -67
- package/src/ExplorerPlugin.test.ts +2 -2
- package/src/ExplorerPlugin.tsx +3 -33
- package/src/capabilities/create-object.ts +36 -0
- package/src/capabilities/index.ts +1 -0
- package/src/capabilities/react-surface.tsx +2 -2
- package/src/components/Chart/Chart.stories.tsx +14 -20
- package/src/components/Globe/Globe.stories.tsx +17 -19
- package/src/components/Graph/CanvasForceGraph.stories.tsx +83 -0
- package/src/components/Graph/CanvasForceGraph.tsx +124 -0
- package/src/components/Graph/ForceGraph.stories.tsx +69 -37
- package/src/components/Graph/ForceGraph.tsx +104 -85
- package/src/components/Graph/index.ts +1 -1
- package/src/components/Tree/EdgeBundling.stories.tsx +144 -0
- package/src/components/Tree/Tree.stories.tsx +17 -38
- package/src/components/Tree/Tree.tsx +69 -100
- package/src/components/Tree/index.ts +3 -0
- package/src/components/Tree/layout/HierarchicalEdgeBundling.tsx +277 -0
- package/src/components/Tree/layout/RadialTree.tsx +237 -0
- package/src/components/Tree/layout/TidyTree.tsx +246 -0
- package/src/components/Tree/layout/hierarchy.ts +32 -0
- package/src/components/Tree/layout/index.ts +5 -5
- package/src/components/Tree/layout/slots.ts +19 -0
- package/src/components/Tree/layout/useContainerSize.ts +43 -0
- package/src/components/Tree/space-graph-adapter.ts +96 -0
- package/src/components/Tree/testing/index.ts +1 -0
- package/src/components/Tree/testing/relations.ts +182 -0
- package/src/components/Tree/types/types.ts +38 -29
- package/src/containers/ExplorerArticle/ExplorerArticle.stories.tsx +119 -0
- package/src/containers/ExplorerArticle/ExplorerArticle.tsx +153 -0
- package/src/containers/ExplorerArticle/index.ts +5 -0
- package/src/containers/index.ts +1 -1
- package/src/hooks/useGraphModel.ts +10 -6
- package/src/index.ts +1 -6
- package/src/plugin.ts +9 -0
- package/src/testing.ts +7 -0
- package/src/types/ExplorerAction.ts +1 -1
- package/src/types/Graph.ts +1 -2
- package/dist/lib/browser/hooks/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/types/index.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-6EUBRHHX.mjs +0 -26
- package/dist/lib/node-esm/chunk-6EUBRHHX.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/components/index.mjs +0 -11255
- package/dist/lib/node-esm/components/index.mjs.map +0 -7
- package/dist/lib/node-esm/hooks/index.mjs +0 -41
- package/dist/lib/node-esm/hooks/index.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -14
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/meta.mjs +0 -9
- package/dist/lib/node-esm/types/index.mjs +0 -73
- package/dist/lib/node-esm/types/index.mjs.map +0 -7
- package/dist/types/src/components/Graph/D3ForceGraph.d.ts +0 -15
- package/dist/types/src/components/Graph/D3ForceGraph.d.ts.map +0 -1
- package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts.map +0 -1
- package/dist/types/src/components/Graph/adapter.d.ts.map +0 -1
- package/dist/types/src/components/Graph/testing.d.ts +0 -14
- package/dist/types/src/components/Graph/testing.d.ts.map +0 -1
- package/dist/types/src/containers/ExplorerContainer/ExplorerContainer.d.ts +0 -6
- package/dist/types/src/containers/ExplorerContainer/ExplorerContainer.d.ts.map +0 -1
- package/dist/types/src/containers/ExplorerContainer/index.d.ts +0 -2
- package/dist/types/src/containers/ExplorerContainer/index.d.ts.map +0 -1
- package/src/components/Graph/D3ForceGraph.stories.tsx +0 -83
- package/src/components/Graph/D3ForceGraph.tsx +0 -108
- package/src/components/Graph/testing.ts +0 -58
- package/src/components/Tree/layout/HierarchicalEdgeBundling.ts +0 -162
- package/src/components/Tree/layout/RadialTree.ts +0 -94
- package/src/components/Tree/layout/TidyTree.ts +0 -101
- package/src/containers/ExplorerContainer/ExplorerContainer.tsx +0 -53
- package/src/containers/ExplorerContainer/index.ts +0 -5
- /package/dist/lib/{browser/chunk-J5LGTIGS.mjs.map → neutral/ExplorerPlugin.mjs.map} +0 -0
- /package/dist/lib/{browser → neutral}/chunk-HPIS2WXY.mjs +0 -0
- /package/dist/lib/{browser → neutral}/chunk-HPIS2WXY.mjs.map +0 -0
- /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs +0 -0
- /package/dist/lib/{browser/meta.mjs.map → neutral/chunk-J5LGTIGS.mjs.map} +0 -0
- /package/dist/lib/{node-esm/chunk-HSLMI22Q.mjs.map → neutral/index.mjs.map} +0 -0
- /package/dist/lib/{browser → neutral}/meta.mjs +0 -0
- /package/dist/lib/{node-esm → neutral}/meta.mjs.map +0 -0
- /package/src/components/Graph/{adapter.ts → graph-adapter.ts} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
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 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 RadialTreeProps = ThemedClassName<{
|
|
20
|
+
data: TreeNode;
|
|
21
|
+
label?: (d: TreeNode) => string;
|
|
22
|
+
slots?: TreeLayoutSlots;
|
|
23
|
+
/** Node radius. */
|
|
24
|
+
r?: number;
|
|
25
|
+
/** Optional padding (in screen pixels) reserved around the radial layout. */
|
|
26
|
+
padding?: number;
|
|
27
|
+
/** Initial set of collapsed node ids. */
|
|
28
|
+
initialCollapsed?: Iterable<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Use `d3.cluster` (all leaves equidistant from center) instead of `d3.tree`.
|
|
31
|
+
* Matches https://observablehq.com/@d3/radial-cluster.
|
|
32
|
+
*/
|
|
33
|
+
cluster?: boolean;
|
|
34
|
+
/** Notified when the user clicks a node. */
|
|
35
|
+
onNodeClick?: (node: TreeNode) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Notified on pointerenter (and `null` on pointerleave) for nodes. Used to wire previews
|
|
38
|
+
* (dispatch `DxAnchorActivate`). The event target on enter is the hovered circle — dispatch
|
|
39
|
+
* from it so the preview anchors there.
|
|
40
|
+
*/
|
|
41
|
+
onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Radial tree layout based on the D3 reference component.
|
|
46
|
+
* https://observablehq.com/@d3/radial-tree-component
|
|
47
|
+
*
|
|
48
|
+
* Click a node with children to toggle collapse / expand.
|
|
49
|
+
*/
|
|
50
|
+
export const RadialTree = ({
|
|
51
|
+
classNames,
|
|
52
|
+
data,
|
|
53
|
+
label = (d) => d.label ?? d.id,
|
|
54
|
+
slots = defaultTreeLayoutSlots,
|
|
55
|
+
r = 4,
|
|
56
|
+
padding = 80,
|
|
57
|
+
initialCollapsed,
|
|
58
|
+
cluster = false,
|
|
59
|
+
onNodeClick,
|
|
60
|
+
onNodeHover,
|
|
61
|
+
}: RadialTreeProps) => {
|
|
62
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
63
|
+
const { setRef, width, height } = useContainerSize();
|
|
64
|
+
|
|
65
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(initialCollapsed ?? []));
|
|
66
|
+
const toggle = useCallback((id: string) => {
|
|
67
|
+
setCollapsed((prev) => {
|
|
68
|
+
const next = new Set(prev);
|
|
69
|
+
if (next.has(id)) {
|
|
70
|
+
next.delete(id);
|
|
71
|
+
} else {
|
|
72
|
+
next.add(id);
|
|
73
|
+
}
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleClickRef = useRef<(node: TreeNode) => void>(() => {});
|
|
79
|
+
handleClickRef.current = (node: TreeNode) => {
|
|
80
|
+
onNodeClick?.(node);
|
|
81
|
+
if (node.children?.length) {
|
|
82
|
+
toggle(node.id);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleHoverRef = useRef<(node: TreeNode | null, event?: MouseEvent) => void>(() => {});
|
|
87
|
+
handleHoverRef.current = (node: TreeNode | null, event?: MouseEvent) => onNodeHover?.(node, event);
|
|
88
|
+
|
|
89
|
+
const root = useMemo(() => buildHierarchy(data, collapsed), [data, collapsed]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!svgRef.current || !width || !height) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const radius = Math.max(0, Math.min(width, height) / 2 - padding);
|
|
97
|
+
renderRadialTree(svgRef.current, root, {
|
|
98
|
+
radius,
|
|
99
|
+
r,
|
|
100
|
+
label,
|
|
101
|
+
slots,
|
|
102
|
+
collapsed,
|
|
103
|
+
cluster,
|
|
104
|
+
onNodeClick: (n) => handleClickRef.current(n),
|
|
105
|
+
onNodeHover: (n, e) => handleHoverRef.current(n, e),
|
|
106
|
+
});
|
|
107
|
+
}, [root, width, height, r, padding, label, slots, collapsed, cluster]);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div ref={setRef} className={mx('dx-expander relative', classNames)}>
|
|
111
|
+
{width > 0 && height > 0 && (
|
|
112
|
+
<svg
|
|
113
|
+
ref={svgRef}
|
|
114
|
+
xmlns='http://www.w3.org/2000/svg'
|
|
115
|
+
width={width}
|
|
116
|
+
height={height}
|
|
117
|
+
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type RenderOptions = {
|
|
125
|
+
radius: number;
|
|
126
|
+
r: number;
|
|
127
|
+
label: (d: TreeNode) => string;
|
|
128
|
+
slots: TreeLayoutSlots;
|
|
129
|
+
collapsed: Set<string>;
|
|
130
|
+
cluster: boolean;
|
|
131
|
+
onNodeClick: (node: TreeNode) => void;
|
|
132
|
+
onNodeHover: (node: TreeNode | null, event?: MouseEvent) => void;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const renderRadialTree = (svgElement: SVGSVGElement, root: any, options: RenderOptions) => {
|
|
136
|
+
const { radius, r, label, slots, collapsed, cluster, onNodeClick, onNodeHover } = options;
|
|
137
|
+
const svg = select(svgElement);
|
|
138
|
+
|
|
139
|
+
const layout = cluster ? d3Cluster<TreeNode>() : d3Tree<TreeNode>();
|
|
140
|
+
layout
|
|
141
|
+
.size([2 * Math.PI, radius])
|
|
142
|
+
.separation((a: any, b: any) => (a.parent === b.parent ? 1 : 2) / Math.max(1, a.depth))(root);
|
|
143
|
+
|
|
144
|
+
const g = svg.selectAll<SVGGElement, null>('g.dx-radial-root').data([null]).join('g').classed('dx-radial-root', true);
|
|
145
|
+
|
|
146
|
+
const linksLayer = g
|
|
147
|
+
.selectAll<SVGGElement, null>('g.dx-radial-links')
|
|
148
|
+
.data([null])
|
|
149
|
+
.join('g')
|
|
150
|
+
.classed('dx-radial-links', true);
|
|
151
|
+
|
|
152
|
+
const nodesLayer = g
|
|
153
|
+
.selectAll<SVGGElement, null>('g.dx-radial-nodes')
|
|
154
|
+
.data([null])
|
|
155
|
+
.join('g')
|
|
156
|
+
.classed('dx-radial-nodes', true);
|
|
157
|
+
|
|
158
|
+
const linkPath = linkRadial<any, any>()
|
|
159
|
+
.angle((d: any) => d.x)
|
|
160
|
+
.radius((d: any) => d.y);
|
|
161
|
+
|
|
162
|
+
linksLayer
|
|
163
|
+
.selectAll<SVGPathElement, any>('path')
|
|
164
|
+
.data(root.links(), (d: any) => `${d.source.data.id}->${d.target.data.id}`)
|
|
165
|
+
.join(
|
|
166
|
+
(enter) =>
|
|
167
|
+
enter
|
|
168
|
+
.append('path')
|
|
169
|
+
.attr('class', slots.path ?? '')
|
|
170
|
+
.attr('fill', 'none')
|
|
171
|
+
.attr('opacity', 0),
|
|
172
|
+
(update) => update,
|
|
173
|
+
(exit) => exit.transition().duration(TRANSITION_MS).attr('opacity', 0).remove(),
|
|
174
|
+
)
|
|
175
|
+
.transition()
|
|
176
|
+
.duration(TRANSITION_MS)
|
|
177
|
+
.attr('opacity', 1)
|
|
178
|
+
.attr('d', linkPath);
|
|
179
|
+
|
|
180
|
+
const node = nodesLayer
|
|
181
|
+
.selectAll<SVGGElement, any>('g.dx-radial-node')
|
|
182
|
+
.data(root.descendants(), (d: any) => d.data.id);
|
|
183
|
+
|
|
184
|
+
const nodeEnter = node
|
|
185
|
+
.enter()
|
|
186
|
+
.append('g')
|
|
187
|
+
.classed('dx-radial-node', true)
|
|
188
|
+
.attr('opacity', 0)
|
|
189
|
+
.attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`)
|
|
190
|
+
.style('cursor', (d: any) => (d.data.children?.length ? 'pointer' : 'default'))
|
|
191
|
+
.on('click', (_, d: any) => onNodeClick(d.data));
|
|
192
|
+
|
|
193
|
+
nodeEnter
|
|
194
|
+
.append('circle')
|
|
195
|
+
.attr('r', r)
|
|
196
|
+
.on('pointerenter', (event: MouseEvent, d: any) => onNodeHover(d.data, event))
|
|
197
|
+
.on('pointerleave', (event: MouseEvent) => onNodeHover(null, event));
|
|
198
|
+
|
|
199
|
+
nodeEnter
|
|
200
|
+
.append('text')
|
|
201
|
+
.attr('dy', '0.32em')
|
|
202
|
+
.attr('paint-order', 'stroke')
|
|
203
|
+
.text((d: any) => label(d.data));
|
|
204
|
+
|
|
205
|
+
const nodeMerge = nodeEnter.merge(node as any);
|
|
206
|
+
|
|
207
|
+
nodeMerge
|
|
208
|
+
.transition()
|
|
209
|
+
.duration(TRANSITION_MS)
|
|
210
|
+
.attr('opacity', 1)
|
|
211
|
+
.attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`);
|
|
212
|
+
|
|
213
|
+
nodeMerge
|
|
214
|
+
.select<SVGCircleElement>('circle')
|
|
215
|
+
.attr('class', (d: any) => {
|
|
216
|
+
const collapsedHere = isCollapsed(d.data, collapsed);
|
|
217
|
+
const leaf = isLeaf(d.data);
|
|
218
|
+
return [slots.node ?? '', collapsedHere ? 'dx-collapsed' : leaf ? 'dx-leaf' : 'dx-branch']
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join(' ');
|
|
221
|
+
})
|
|
222
|
+
.attr('r', r);
|
|
223
|
+
|
|
224
|
+
nodeMerge
|
|
225
|
+
.select<SVGTextElement>('text')
|
|
226
|
+
.attr('class', slots.text ?? '')
|
|
227
|
+
.attr('transform', (d: any) => (d.x >= Math.PI ? 'rotate(180)' : null))
|
|
228
|
+
// eslint-disable-next-line no-mixed-operators
|
|
229
|
+
.attr('x', (d: any) => (d.x < Math.PI === !d.children ? r + 4 : -(r + 4)))
|
|
230
|
+
// eslint-disable-next-line no-mixed-operators
|
|
231
|
+
.attr('text-anchor', (d: any) => (d.x < Math.PI === !d.children ? 'start' : 'end'))
|
|
232
|
+
.text((d: any) => label(d.data));
|
|
233
|
+
|
|
234
|
+
node.exit().transition().duration(TRANSITION_MS).attr('opacity', 0).remove();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default RadialTree;
|
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
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
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Obj } from '@dxos/echo';
|
|
6
|
+
import { type SpaceGraphEdge, type SpaceGraphModel, type SpaceGraphNode } from '@dxos/schema';
|
|
7
|
+
|
|
8
|
+
import { type BundleEdge } from './layout';
|
|
9
|
+
import { type TreeNode } from './types';
|
|
10
|
+
|
|
11
|
+
const ROOT_ID = 'db:root';
|
|
12
|
+
const SCHEMA_PREFIX = 'schema:';
|
|
13
|
+
|
|
14
|
+
const truncate = (id: string) => `${id.slice(0, 4)}…${id.slice(-4)}`;
|
|
15
|
+
|
|
16
|
+
const labelOf = (node: SpaceGraphNode): string => node.data?.label ?? truncate(node.id);
|
|
17
|
+
|
|
18
|
+
export type SpaceGraphHierarchy = {
|
|
19
|
+
/** Hierarchy where leaves carry the underlying ECHO object on `node.data`. */
|
|
20
|
+
tree: TreeNode<Obj.Unknown>;
|
|
21
|
+
/** Edges between leaf objects derived from relations and refs in the graph. */
|
|
22
|
+
edges: BundleEdge[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type SpaceGraphHierarchyOptions = {
|
|
26
|
+
rootLabel?: string;
|
|
27
|
+
rootId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert a {@link SpaceGraphModel} graph into a hierarchy suitable for the cluster / bundling layouts:
|
|
32
|
+
*
|
|
33
|
+
* Root (db)
|
|
34
|
+
* ├── Schema A
|
|
35
|
+
* │ ├── Object a1
|
|
36
|
+
* │ └── Object a2
|
|
37
|
+
* └── Schema B
|
|
38
|
+
* └── Object b1
|
|
39
|
+
*
|
|
40
|
+
* Leaves are ECHO objects. Their schema typename is the intermediate group.
|
|
41
|
+
* `rootLabel` is shown on the root node (typically the database name or "Space").
|
|
42
|
+
*
|
|
43
|
+
* `edges` exposes object-to-object edges (relations + refs) so the bundling
|
|
44
|
+
* layout can route bundled curves between leaves through their common ancestor.
|
|
45
|
+
*/
|
|
46
|
+
export const spaceGraphToHierarchy = (
|
|
47
|
+
model: SpaceGraphModel,
|
|
48
|
+
{ rootLabel = 'Database', rootId = ROOT_ID }: SpaceGraphHierarchyOptions = {},
|
|
49
|
+
): SpaceGraphHierarchy => {
|
|
50
|
+
const graph = model.graph;
|
|
51
|
+
const objectNodes = graph.nodes.filter((node) => node.type === 'object');
|
|
52
|
+
|
|
53
|
+
// Group object nodes by typename. Fall back to '(untyped)' so they stay visible.
|
|
54
|
+
const byTypename = new Map<string, SpaceGraphNode[]>();
|
|
55
|
+
for (const node of objectNodes) {
|
|
56
|
+
const obj = node.data?.object as Obj.Unknown | undefined;
|
|
57
|
+
const typename = (obj && Obj.getTypename(obj)) ?? '(untyped)';
|
|
58
|
+
const bucket = byTypename.get(typename) ?? [];
|
|
59
|
+
bucket.push(node);
|
|
60
|
+
byTypename.set(typename, bucket);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tree: TreeNode<Obj.Unknown> = {
|
|
64
|
+
id: rootId,
|
|
65
|
+
label: rootLabel,
|
|
66
|
+
children: Array.from(byTypename.entries()).map(([typename, nodes]) => ({
|
|
67
|
+
id: `${SCHEMA_PREFIX}${typename}`,
|
|
68
|
+
label: shortTypename(typename),
|
|
69
|
+
children: nodes.map((node) => ({
|
|
70
|
+
id: node.id,
|
|
71
|
+
label: labelOf(node),
|
|
72
|
+
// Preserve the ECHO object on the leaf so layouts can fire hover/inspect callbacks with it.
|
|
73
|
+
data: node.data?.object,
|
|
74
|
+
})),
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Filter edges to those that connect two distinct leaf objects in the hierarchy.
|
|
79
|
+
const objectIds = new Set(objectNodes.map((n) => n.id));
|
|
80
|
+
const edges: BundleEdge[] = graph.edges
|
|
81
|
+
.filter((edge: SpaceGraphEdge) => objectIds.has(edge.source) && objectIds.has(edge.target))
|
|
82
|
+
.filter((edge: SpaceGraphEdge) => edge.source !== edge.target)
|
|
83
|
+
.map((edge: SpaceGraphEdge) => ({
|
|
84
|
+
source: edge.source,
|
|
85
|
+
target: edge.target,
|
|
86
|
+
kind: edge.type,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
return { tree, edges };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Drop the package prefix (`org.dxos.type.foo` → `Foo`) for display. */
|
|
93
|
+
const shortTypename = (typename: string): string => {
|
|
94
|
+
const last = typename.split('.').pop() ?? typename;
|
|
95
|
+
return last.charAt(0).toUpperCase() + last.slice(1);
|
|
96
|
+
};
|