@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
|
@@ -3,76 +3,55 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
-
import React, {
|
|
6
|
+
import React, { useMemo } from 'react';
|
|
7
7
|
|
|
8
|
-
import { invariant } from '@dxos/invariant';
|
|
9
8
|
import { random } from '@dxos/random';
|
|
10
|
-
import {
|
|
11
|
-
import { type ClientRepeatedComponentProps, ClientRepeater } from '@dxos/react-client/testing';
|
|
12
|
-
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
13
|
-
import { withRegistry } from '@dxos/storybook-utils';
|
|
9
|
+
import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
14
10
|
|
|
11
|
+
import { createTree } from './testing';
|
|
15
12
|
import { Tree, type TreeComponentProps } from './Tree';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
// TODO(burdon): Storybook for Graph/Tree/Plot (generics); incl. GraphModel.
|
|
19
|
-
// TODO(burdon): Type for all Explorer components (Space, Object, Query, etc.) incl.
|
|
13
|
+
import { treeTypeToTreeNode } from './types';
|
|
20
14
|
|
|
21
15
|
random.seed(1);
|
|
22
16
|
|
|
23
|
-
type
|
|
24
|
-
|
|
25
|
-
const Component = ({ type }: ComponentProps) => {
|
|
26
|
-
const client = useClient();
|
|
27
|
-
const space = client.spaces.get()[0];
|
|
28
|
-
invariant(space, 'Tree story requires at least one space');
|
|
29
|
-
const [object, setObject] = useState<TreeType>();
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
setTimeout(() => {
|
|
32
|
-
const tree = space.db.add(TreeModel.create());
|
|
33
|
-
setObject(tree);
|
|
34
|
-
});
|
|
35
|
-
}, []);
|
|
17
|
+
type StoryArgs = Pick<TreeComponentProps, 'variant'>;
|
|
36
18
|
|
|
37
|
-
|
|
38
|
-
|
|
19
|
+
const DefaultStory = ({ variant }: StoryArgs) => {
|
|
20
|
+
const data = useMemo(() => treeTypeToTreeNode(createTree([3, [2, 4], [1, 3]]).tree), []);
|
|
21
|
+
if (!data) {
|
|
22
|
+
return <Loading />;
|
|
39
23
|
}
|
|
40
24
|
|
|
41
|
-
return <Tree
|
|
25
|
+
return <Tree data={data} variant={variant} />;
|
|
42
26
|
};
|
|
43
27
|
|
|
44
|
-
const
|
|
45
|
-
return <ClientRepeater component={Component} types={[TreeType]} createSpace />;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const meta = {
|
|
28
|
+
const meta: Meta<StoryArgs> = {
|
|
49
29
|
title: 'plugins/plugin-explorer/components/Tree',
|
|
50
|
-
component: Tree as any,
|
|
51
30
|
render: DefaultStory,
|
|
52
|
-
decorators: [
|
|
31
|
+
decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
|
|
53
32
|
parameters: {
|
|
54
33
|
layout: 'fullscreen',
|
|
55
34
|
},
|
|
56
|
-
}
|
|
35
|
+
};
|
|
57
36
|
|
|
58
37
|
export default meta;
|
|
59
38
|
|
|
60
|
-
type Story = StoryObj<
|
|
39
|
+
type Story = StoryObj<StoryArgs>;
|
|
61
40
|
|
|
62
41
|
export const Tidy: Story = {
|
|
63
42
|
args: {
|
|
64
|
-
|
|
43
|
+
variant: 'tidy',
|
|
65
44
|
},
|
|
66
45
|
};
|
|
67
46
|
|
|
68
47
|
export const Radial: Story = {
|
|
69
48
|
args: {
|
|
70
|
-
|
|
49
|
+
variant: 'radial',
|
|
71
50
|
},
|
|
72
51
|
};
|
|
73
52
|
|
|
74
53
|
export const Edge: Story = {
|
|
75
54
|
args: {
|
|
76
|
-
|
|
55
|
+
variant: 'edge',
|
|
77
56
|
},
|
|
78
57
|
};
|
|
@@ -2,111 +2,80 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import React, { useMemo } from 'react';
|
|
7
6
|
|
|
8
|
-
import { type
|
|
9
|
-
import { useAsyncState } from '@dxos/react-ui';
|
|
10
|
-
import { SVG, type SVGContext } from '@dxos/react-ui-graph';
|
|
11
|
-
import { SpaceGraphModel } from '@dxos/schema';
|
|
7
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
12
8
|
|
|
13
|
-
import { HierarchicalEdgeBundling, RadialTree, TidyTree } from './layout';
|
|
14
|
-
import { type TreeNode
|
|
15
|
-
|
|
16
|
-
// TODO(burdon): Create dge bundling graph using d3.hierarchy.
|
|
17
|
-
// https://observablehq.com/@d3/hierarchical-edge-bundling?intent=fork
|
|
18
|
-
|
|
19
|
-
type Renderer = (svg: SVGSVGElement, data: any, options: any) => void;
|
|
9
|
+
import { type BundleEdge, HierarchicalEdgeBundling, RadialTree, TidyTree, type TreeLayoutSlots } from './layout';
|
|
10
|
+
import { type TreeNode } from './types';
|
|
20
11
|
|
|
21
12
|
export type LayoutVariant = 'tidy' | 'radial' | 'edge';
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
text?: string;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type TreeOptions = {
|
|
31
|
-
label: (d: any) => string;
|
|
32
|
-
|
|
33
|
-
slots?: TreeLayoutSlots;
|
|
34
|
-
radius?: number;
|
|
35
|
-
|
|
36
|
-
width: number;
|
|
37
|
-
height: number;
|
|
38
|
-
margin?: number;
|
|
39
|
-
|
|
40
|
-
padding?: number;
|
|
41
|
-
// Radius of nodes.
|
|
42
|
-
r?: number;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export const defaultTreeLayoutSlots: TreeLayoutSlots = {
|
|
46
|
-
node: 'fill-blue-600',
|
|
47
|
-
path: 'fill-none stroke-blue-400 stroke-[0.5px]',
|
|
48
|
-
text: 'stroke-[0.5px] stroke-neutral-700 text-xs', // TODO(burdon): Create box instead of halo.
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const renderers = new Map<LayoutVariant, Renderer>([
|
|
52
|
-
['tidy', TidyTree],
|
|
53
|
-
['radial', RadialTree],
|
|
54
|
-
['edge', HierarchicalEdgeBundling],
|
|
55
|
-
]);
|
|
56
|
-
|
|
57
|
-
export type TreeComponentProps<N = unknown> = {
|
|
58
|
-
space: Space;
|
|
59
|
-
selected?: string;
|
|
14
|
+
export type TreeComponentProps = ThemedClassName<{
|
|
15
|
+
data: TreeNode;
|
|
16
|
+
/** Optional edges for the `edge` variant (hierarchical edge bundling). */
|
|
17
|
+
edges?: BundleEdge[];
|
|
60
18
|
variant?: LayoutVariant;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
19
|
+
label?: (node: TreeNode) => string;
|
|
20
|
+
slots?: TreeLayoutSlots;
|
|
21
|
+
initialCollapsed?: Iterable<string>;
|
|
22
|
+
onNodeClick?: (node: TreeNode) => void;
|
|
23
|
+
onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tree visualization wrapping the three layout variants.
|
|
28
|
+
* - `tidy` — horizontal tidy tree (collapsible)
|
|
29
|
+
* - `radial` — radial tree (collapsible)
|
|
30
|
+
* - `edge` — hierarchical edge bundling (`edges` connect leaves)
|
|
31
|
+
*/
|
|
32
|
+
export const Tree = ({
|
|
33
|
+
classNames,
|
|
34
|
+
data,
|
|
35
|
+
edges,
|
|
36
|
+
variant = 'tidy',
|
|
37
|
+
label,
|
|
38
|
+
slots,
|
|
39
|
+
initialCollapsed,
|
|
40
|
+
onNodeClick,
|
|
41
|
+
onNodeHover,
|
|
42
|
+
}: TreeComponentProps) => {
|
|
43
|
+
return useMemo(() => {
|
|
44
|
+
switch (variant) {
|
|
45
|
+
case 'tidy':
|
|
46
|
+
return (
|
|
47
|
+
<TidyTree
|
|
48
|
+
classNames={classNames}
|
|
49
|
+
data={data}
|
|
50
|
+
label={label}
|
|
51
|
+
slots={slots}
|
|
52
|
+
initialCollapsed={initialCollapsed}
|
|
53
|
+
onNodeClick={onNodeClick}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
case 'radial':
|
|
57
|
+
return (
|
|
58
|
+
<RadialTree
|
|
59
|
+
classNames={classNames}
|
|
60
|
+
data={data}
|
|
61
|
+
label={label}
|
|
62
|
+
slots={slots}
|
|
63
|
+
initialCollapsed={initialCollapsed}
|
|
64
|
+
onNodeClick={onNodeClick}
|
|
65
|
+
onNodeHover={onNodeHover}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
case 'edge':
|
|
69
|
+
return (
|
|
70
|
+
<HierarchicalEdgeBundling
|
|
71
|
+
classNames={classNames}
|
|
72
|
+
data={data}
|
|
73
|
+
edges={edges ?? []}
|
|
74
|
+
label={label}
|
|
75
|
+
slots={slots}
|
|
76
|
+
onNodeHover={onNodeHover}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
104
79
|
}
|
|
105
|
-
}, [
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<div className='grow' onClick={() => onNodeClick?.()}>
|
|
109
|
-
<SVG.Root ref={context} />
|
|
110
|
-
</div>
|
|
111
|
-
);
|
|
80
|
+
}, [variant, classNames, data, edges, label, slots, initialCollapsed, onNodeClick, onNodeHover]);
|
|
112
81
|
};
|
|
@@ -0,0 +1,277 @@
|
|
|
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 ThemedClassName } from '@dxos/react-ui';
|
|
11
|
+
import { mx } from '@dxos/ui-theme';
|
|
12
|
+
|
|
13
|
+
import { type TreeNode } from '../types';
|
|
14
|
+
import { type TreeLayoutSlots, defaultTreeLayoutSlots } from './slots';
|
|
15
|
+
import { useContainerSize } from './useContainerSize';
|
|
16
|
+
|
|
17
|
+
const TRANSITION_MS = 350;
|
|
18
|
+
|
|
19
|
+
/** A directed edge between two leaves of the hierarchy, identified by node id. */
|
|
20
|
+
export type BundleEdge = {
|
|
21
|
+
source: string;
|
|
22
|
+
target: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type HierarchicalEdgeBundlingProps = ThemedClassName<{
|
|
27
|
+
/** Hierarchical data; leaves are the connectable entities. */
|
|
28
|
+
data: TreeNode;
|
|
29
|
+
/** Edges between leaves (by id). Bundled through the hierarchy. */
|
|
30
|
+
edges?: BundleEdge[];
|
|
31
|
+
/** Label accessor for leaf nodes. */
|
|
32
|
+
label?: (d: TreeNode) => string;
|
|
33
|
+
/** Padding (in screen pixels) reserved around the radial layout. */
|
|
34
|
+
padding?: number;
|
|
35
|
+
/** Bundling tension; 0 = straight, 1 = maximally bundled. */
|
|
36
|
+
tension?: number;
|
|
37
|
+
slots?: TreeLayoutSlots;
|
|
38
|
+
/**
|
|
39
|
+
* Called when the user hovers a leaf node (with the event so callers can dispatch
|
|
40
|
+
* `DxAnchorActivate` for previews). Receives `null` on leave.
|
|
41
|
+
*/
|
|
42
|
+
onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hierarchical edge bundling.
|
|
47
|
+
* https://observablehq.com/@d3/hierarchical-edge-bundling?intent=fork
|
|
48
|
+
*
|
|
49
|
+
* Leaves are placed on a circle, grouped by their parent in the hierarchy.
|
|
50
|
+
* Edges between leaves are drawn as bundled curves that route through their lowest common ancestor.
|
|
51
|
+
*/
|
|
52
|
+
export const HierarchicalEdgeBundling = ({
|
|
53
|
+
classNames,
|
|
54
|
+
data,
|
|
55
|
+
edges = [],
|
|
56
|
+
label = (d) => d.label ?? d.id,
|
|
57
|
+
padding = 120,
|
|
58
|
+
tension = 0.85,
|
|
59
|
+
slots = defaultTreeLayoutSlots,
|
|
60
|
+
onNodeHover,
|
|
61
|
+
}: HierarchicalEdgeBundlingProps) => {
|
|
62
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
63
|
+
const { setRef, width, height } = useContainerSize();
|
|
64
|
+
|
|
65
|
+
const root = useMemo(() => buildBundleHierarchy(data, edges), [data, edges]);
|
|
66
|
+
|
|
67
|
+
// Stable hover ref so the effect doesn't rebind handlers on every render.
|
|
68
|
+
const handleHoverRef = useRef<(node: TreeNode | null, event?: MouseEvent) => void>(() => {});
|
|
69
|
+
handleHoverRef.current = (node, event) => onNodeHover?.(node, event);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!svgRef.current || !width || !height) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const radius = Math.max(0, Math.min(width, height) / 2 - padding);
|
|
77
|
+
renderBundling(svgRef.current, root, {
|
|
78
|
+
radius,
|
|
79
|
+
label,
|
|
80
|
+
slots,
|
|
81
|
+
tension,
|
|
82
|
+
onNodeHover: (n, e) => handleHoverRef.current(n, e),
|
|
83
|
+
});
|
|
84
|
+
}, [root, width, height, padding, tension, label, slots]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div ref={setRef} className={mx('dx-expander relative', classNames)}>
|
|
88
|
+
{width > 0 && height > 0 && (
|
|
89
|
+
<svg
|
|
90
|
+
ref={svgRef}
|
|
91
|
+
xmlns='http://www.w3.org/2000/svg'
|
|
92
|
+
width={width}
|
|
93
|
+
height={height}
|
|
94
|
+
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
type BundleHierarchy = HierarchyNode<TreeNode> & {
|
|
102
|
+
outgoing?: Array<[BundleHierarchy, BundleHierarchy, BundleEdge]>;
|
|
103
|
+
incoming?: Array<[BundleHierarchy, BundleHierarchy, BundleEdge]>;
|
|
104
|
+
pathEl?: SVGPathElement | null;
|
|
105
|
+
text?: SVGTextElement | null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the bundling hierarchy from data + edges.
|
|
110
|
+
* Edges connect leaves (by id); the cluster layout places leaves on the circle.
|
|
111
|
+
*/
|
|
112
|
+
const buildBundleHierarchy = (data: TreeNode, edges: BundleEdge[]): BundleHierarchy => {
|
|
113
|
+
const root = hierarchy<TreeNode>(data) as BundleHierarchy;
|
|
114
|
+
const byId = new Map<string, BundleHierarchy>();
|
|
115
|
+
for (const node of root.descendants() as BundleHierarchy[]) {
|
|
116
|
+
byId.set(node.data.id, node);
|
|
117
|
+
node.outgoing = [];
|
|
118
|
+
node.incoming = [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const edge of edges) {
|
|
122
|
+
const source = byId.get(edge.source);
|
|
123
|
+
const target = byId.get(edge.target);
|
|
124
|
+
if (!source || !target || source === target) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
source.outgoing!.push([source, target, edge]);
|
|
128
|
+
target.incoming!.push([source, target, edge]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return root;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type RenderOptions = {
|
|
135
|
+
radius: number;
|
|
136
|
+
tension: number;
|
|
137
|
+
label: (d: TreeNode) => string;
|
|
138
|
+
slots: TreeLayoutSlots;
|
|
139
|
+
onNodeHover: (node: TreeNode | null, event?: MouseEvent) => void;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const renderBundling = (svgElement: SVGSVGElement, root: BundleHierarchy, options: RenderOptions) => {
|
|
143
|
+
const { radius, tension, label, slots, onNodeHover } = options;
|
|
144
|
+
const svg = select(svgElement);
|
|
145
|
+
|
|
146
|
+
// Degenerate root (no descendants yet): clear any previously rendered bundle so a stale
|
|
147
|
+
// graph doesn't linger after the hierarchy empties out, then no-op.
|
|
148
|
+
if (!root.children?.length) {
|
|
149
|
+
svg.selectAll('g.dx-bundle-root').remove();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
cluster<TreeNode>().size([2 * Math.PI, radius])(root);
|
|
154
|
+
|
|
155
|
+
const g = svg.selectAll<SVGGElement, null>('g.dx-bundle-root').data([null]).join('g').classed('dx-bundle-root', true);
|
|
156
|
+
|
|
157
|
+
const linksLayer = g
|
|
158
|
+
.selectAll<SVGGElement, null>('g.dx-bundle-links')
|
|
159
|
+
.data([null])
|
|
160
|
+
.join('g')
|
|
161
|
+
.classed('dx-bundle-links', true);
|
|
162
|
+
|
|
163
|
+
const nodesLayer = g
|
|
164
|
+
.selectAll<SVGGElement, null>('g.dx-bundle-nodes')
|
|
165
|
+
.data([null])
|
|
166
|
+
.join('g')
|
|
167
|
+
.classed('dx-bundle-nodes', true);
|
|
168
|
+
|
|
169
|
+
const line = lineRadial<any>()
|
|
170
|
+
.curve(curveBundle.beta(tension))
|
|
171
|
+
.radius((d: any) => d.y)
|
|
172
|
+
.angle((d: any) => d.x);
|
|
173
|
+
|
|
174
|
+
// Each edge: route through the lowest common ancestor via hierarchy.path().
|
|
175
|
+
const leaves = root.leaves() as BundleHierarchy[];
|
|
176
|
+
const flatEdges = leaves.flatMap((leaf) => leaf.outgoing ?? []);
|
|
177
|
+
|
|
178
|
+
const paths = linksLayer
|
|
179
|
+
.selectAll<SVGPathElement, any>('path')
|
|
180
|
+
.data(flatEdges, (d: any) => `${d[0].data.id}->${d[1].data.id}`)
|
|
181
|
+
.join(
|
|
182
|
+
(enter) =>
|
|
183
|
+
enter
|
|
184
|
+
.append('path')
|
|
185
|
+
.attr('class', slots.path ?? '')
|
|
186
|
+
.attr('fill', 'none')
|
|
187
|
+
.attr('opacity', 0),
|
|
188
|
+
(update) => update,
|
|
189
|
+
(exit) =>
|
|
190
|
+
exit
|
|
191
|
+
.each(function () {
|
|
192
|
+
select(this).interrupt();
|
|
193
|
+
})
|
|
194
|
+
.transition()
|
|
195
|
+
.duration(TRANSITION_MS)
|
|
196
|
+
.attr('opacity', 0)
|
|
197
|
+
.remove(),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
paths
|
|
201
|
+
.each(function (d: any) {
|
|
202
|
+
d[0].pathEl = this;
|
|
203
|
+
})
|
|
204
|
+
.transition()
|
|
205
|
+
.duration(TRANSITION_MS)
|
|
206
|
+
.attr('opacity', 1)
|
|
207
|
+
.attr('d', ([s, t]) => line(s.path(t)));
|
|
208
|
+
|
|
209
|
+
// Render leaf labels along the perimeter.
|
|
210
|
+
const labels = nodesLayer
|
|
211
|
+
.selectAll<SVGGElement, any>('g.dx-bundle-leaf')
|
|
212
|
+
.data(leaves, (d: any) => d.data.id)
|
|
213
|
+
.join(
|
|
214
|
+
(enter) => {
|
|
215
|
+
const ge = enter.append('g').classed('dx-bundle-leaf', true).attr('opacity', 0);
|
|
216
|
+
ge.append('text').attr('dy', '0.32em').attr('paint-order', 'stroke').style('cursor', 'pointer');
|
|
217
|
+
return ge;
|
|
218
|
+
},
|
|
219
|
+
(update) => update,
|
|
220
|
+
(exit) =>
|
|
221
|
+
exit
|
|
222
|
+
.each(function () {
|
|
223
|
+
select(this).interrupt();
|
|
224
|
+
})
|
|
225
|
+
.transition()
|
|
226
|
+
.duration(TRANSITION_MS)
|
|
227
|
+
.attr('opacity', 0)
|
|
228
|
+
.remove(),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
labels
|
|
232
|
+
.transition()
|
|
233
|
+
.duration(TRANSITION_MS)
|
|
234
|
+
.attr('opacity', 1)
|
|
235
|
+
.attr('transform', (d: any) => `rotate(${(d.x * 180) / Math.PI - 90}) translate(${d.y},0)`);
|
|
236
|
+
|
|
237
|
+
labels
|
|
238
|
+
.select<SVGTextElement>('text')
|
|
239
|
+
.attr('class', slots.text ?? '')
|
|
240
|
+
.attr('x', (d: any) => (d.x < Math.PI ? 6 : -6))
|
|
241
|
+
.attr('text-anchor', (d: any) => (d.x < Math.PI ? 'start' : 'end'))
|
|
242
|
+
.attr('transform', (d: any) => (d.x >= Math.PI ? 'rotate(180)' : null))
|
|
243
|
+
.each(function (d: BundleHierarchy) {
|
|
244
|
+
d.text = this;
|
|
245
|
+
})
|
|
246
|
+
.text((d: any) => label(d.data))
|
|
247
|
+
.on('pointerenter', function (event: MouseEvent, d: BundleHierarchy) {
|
|
248
|
+
onNodeHover(d.data, event);
|
|
249
|
+
hover(linksLayer, leaves, d, true);
|
|
250
|
+
})
|
|
251
|
+
.on('pointerleave', function (event: MouseEvent, d: BundleHierarchy) {
|
|
252
|
+
onNodeHover(null);
|
|
253
|
+
hover(linksLayer, leaves, d, false);
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const hover = (linksLayer: any, leaves: BundleHierarchy[], focused: BundleHierarchy, on: boolean) => {
|
|
258
|
+
const outgoing = new Set((focused.outgoing ?? []).map(([, t]) => t));
|
|
259
|
+
const incoming = new Set((focused.incoming ?? []).map(([s]) => s));
|
|
260
|
+
|
|
261
|
+
(linksLayer.selectAll('path') as any)
|
|
262
|
+
.classed('dx-bundle-out', (d: any) => on && d[0] === focused)
|
|
263
|
+
.classed('dx-bundle-in', (d: any) => on && d[1] === focused)
|
|
264
|
+
.classed('dx-bundle-dim', (d: any) => on && d[0] !== focused && d[1] !== focused);
|
|
265
|
+
|
|
266
|
+
for (const leaf of leaves) {
|
|
267
|
+
if (!leaf.text) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
select(leaf.text)
|
|
271
|
+
.classed('dx-bundle-focused', on && leaf === focused)
|
|
272
|
+
.classed('dx-bundle-out-text', on && outgoing.has(leaf))
|
|
273
|
+
.classed('dx-bundle-in-text', on && incoming.has(leaf));
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export default HierarchicalEdgeBundling;
|