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