@dxos/plugin-explorer 0.9.0 → 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-3D7BYXOR.mjs → chunk-4F6AT7RA.mjs} +6 -4
- package/dist/lib/neutral/chunk-4F6AT7RA.mjs.map +7 -0
- package/dist/lib/neutral/chunk-C56B464A.mjs +53 -0
- package/dist/lib/neutral/chunk-C56B464A.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 +2 -2
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/lib/neutral/meta.mjs +1 -1
- package/dist/lib/neutral/plugin.mjs +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 +5 -2
- package/dist/lib/neutral/translations.mjs.map +3 -3
- package/dist/lib/neutral/types/index.mjs +1 -1
- package/dist/types/dx.config.d.ts +28 -0
- package/dist/types/dx.config.d.ts.map +1 -0
- 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 +3 -7
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/capabilities/react-surface.d.ts +2 -2
- package/dist/types/src/capabilities/react-surface.d.ts.map +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 +2 -2
- 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 +32 -2
- 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/{dist/lib/neutral/chunk-42BYLQQA.mjs → dx.config.ts} +18 -21
- package/package.json +42 -37
- package/src/ExplorerPlugin.test.ts +1 -1
- package/src/ExplorerPlugin.tsx +3 -2
- 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 +9 -10
- 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/meta.ts +2 -32
- package/src/testing/relations.ts +1 -3
- package/src/translations.ts +4 -1
- package/src/types/Graph.ts +18 -20
- package/dist/lib/neutral/ExplorerArticle-4I7PNGDC.mjs +0 -459
- package/dist/lib/neutral/ExplorerArticle-4I7PNGDC.mjs.map +0 -7
- package/dist/lib/neutral/chunk-3D7BYXOR.mjs.map +0 -7
- package/dist/lib/neutral/chunk-42BYLQQA.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-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
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { curveCatmullRom, line as d3Line } from 'd3';
|
|
6
|
+
|
|
7
|
+
import { Obj } from '@dxos/echo';
|
|
8
|
+
import {
|
|
9
|
+
CLUSTER_NODE_TYPE_LEAF,
|
|
10
|
+
CLUSTER_NODE_TYPE_ROOT,
|
|
11
|
+
GraphBundleProjector,
|
|
12
|
+
GraphClusterProjector,
|
|
13
|
+
GraphForceProjector,
|
|
14
|
+
GraphLatticeProjector,
|
|
15
|
+
type GraphLayout,
|
|
16
|
+
type GraphLayoutEdge,
|
|
17
|
+
type GraphLayoutNode,
|
|
18
|
+
GraphPlexusProjector,
|
|
19
|
+
type GraphProjector,
|
|
20
|
+
GraphSwarmProjector,
|
|
21
|
+
GraphTreeProjector,
|
|
22
|
+
PLEXUS_NODE_TYPE_FOCUS,
|
|
23
|
+
PLEXUS_NODE_TYPE_RELATION,
|
|
24
|
+
type PlexusRelation,
|
|
25
|
+
type RenderNode,
|
|
26
|
+
type SVGContext,
|
|
27
|
+
type SwarmNode,
|
|
28
|
+
TREE_NODE_TYPE_INBOUND,
|
|
29
|
+
TREE_NODE_TYPE_ROOT,
|
|
30
|
+
appendRadialGroupLabel,
|
|
31
|
+
appendRadialLeafLabel,
|
|
32
|
+
appendRootLabel,
|
|
33
|
+
} from '@dxos/react-ui-graph';
|
|
34
|
+
import { type SpaceGraphNode } from '@dxos/schema';
|
|
35
|
+
|
|
36
|
+
import { getNodeFillForObject } from '../../util';
|
|
37
|
+
|
|
38
|
+
/** Variants offered in the main `ExplorerArticle` toolbar. */
|
|
39
|
+
export type ExplorerArticleVariant = 'force' | 'cluster' | 'bundle' | 'lattice' | 'swarm' | 'plexus';
|
|
40
|
+
|
|
41
|
+
/** All variant ids, including companion-only variants not shown in the toolbar (e.g. `neighborhood`). */
|
|
42
|
+
export type VisualizationVariantId = ExplorerArticleVariant | 'neighborhood';
|
|
43
|
+
|
|
44
|
+
/** Options threaded to a variant's projector factory when (re)creating the live projector. */
|
|
45
|
+
export type CreateProjectorOptions = {
|
|
46
|
+
/** Focus node id; centres ego/plexus layouts and (for plexus) dispatches to its focused layout. */
|
|
47
|
+
focusId?: string;
|
|
48
|
+
/** Layout snapshot from the outgoing projector so the new one animates from the prior positions. */
|
|
49
|
+
prev?: GraphLayout<SpaceGraphNode>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Self-contained definition of a single visualization variant. Collecting the projector factory,
|
|
54
|
+
* node renderer, per-tick hook, and interaction flags in one record keeps each variant composable:
|
|
55
|
+
* adding or changing a variant touches exactly one entry rather than several parallel switches.
|
|
56
|
+
*/
|
|
57
|
+
export type VisualizationVariant = {
|
|
58
|
+
value: VisualizationVariantId;
|
|
59
|
+
/** Toolbar icon. */
|
|
60
|
+
icon: string;
|
|
61
|
+
/** Toolbar label / aria-label. */
|
|
62
|
+
label: string;
|
|
63
|
+
/** Build the projector that drives this variant's layout. */
|
|
64
|
+
createProjector: (ctx: SVGContext, options: CreateProjectorOptions) => GraphProjector<SpaceGraphNode>;
|
|
65
|
+
/** Append the SVG for a single node; omitted variants fall back to the renderer default. */
|
|
66
|
+
renderNode?: RenderNode<SpaceGraphNode>;
|
|
67
|
+
/** Per-tick post-transform hook (swarm tail trails). */
|
|
68
|
+
applyNode?: (group: SVGGElement, node: GraphLayoutNode<SpaceGraphNode>) => void;
|
|
69
|
+
/** Enable node dragging (force only). */
|
|
70
|
+
drag?: boolean;
|
|
71
|
+
/** Highlight connected nodes on hover (bundle only). */
|
|
72
|
+
highlightOnHover?: boolean;
|
|
73
|
+
/** Edge opacity override; omitted uses the renderer default. */
|
|
74
|
+
edgeOpacity?: number;
|
|
75
|
+
/** Clicking an object node focuses on it (plexus only). */
|
|
76
|
+
focusable?: boolean;
|
|
77
|
+
/** Track the cursor in model coordinates for boid avoidance (swarm only). */
|
|
78
|
+
trackPointer?: boolean;
|
|
79
|
+
/** Emit `onNodeHover` for preview popovers (every variant except plexus). */
|
|
80
|
+
emitsHover?: boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Cross-variant tween duration. Matches the renderer's edge fade timing so node movement and edge enter/exit complete together. */
|
|
84
|
+
const TWEEN_MS = 500;
|
|
85
|
+
|
|
86
|
+
/** Fade-in duration applied to labels after the layout tween completes. */
|
|
87
|
+
const LABEL_FADE_MS = 200;
|
|
88
|
+
|
|
89
|
+
/** Base radius shared by all plexus leaf + relation nodes; the focus node is double. */
|
|
90
|
+
const PLEXUS_LEAF_RADIUS = 5;
|
|
91
|
+
const PLEXUS_FOCUS_RADIUS = PLEXUS_LEAF_RADIUS * 2;
|
|
92
|
+
|
|
93
|
+
/** Base radius for neighborhood tidy-tree nodes; the root node is double. */
|
|
94
|
+
const TREE_NODE_RADIUS = 6;
|
|
95
|
+
const TREE_ROOT_RADIUS = TREE_NODE_RADIUS * 2;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Catmull-Rom curve generator (α=0.5, "centripetal") for swarm boid trails.
|
|
99
|
+
* Passes through every point and avoids the looping/overshoot artifacts a plain
|
|
100
|
+
* cardinal spline produces when consecutive history samples land close together.
|
|
101
|
+
*/
|
|
102
|
+
const swarmTrailLine = d3Line<[number, number]>().curve(curveCatmullRom.alpha(0.5));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Per-tick swarm tail update. The dx-node `<g>` transform has just been written, so we work
|
|
106
|
+
* in node-local coordinates: head at (0,0), history deltas trailing behind. The single
|
|
107
|
+
* `<path>` is stroked with a per-boid `<linearGradient>` whose endpoints we sync to the
|
|
108
|
+
* tail axis so the fade tracks the direction of travel.
|
|
109
|
+
*/
|
|
110
|
+
const applyNodeSwarm = (group: SVGGElement, node: GraphLayoutNode<SpaceGraphNode>): void => {
|
|
111
|
+
const swarm = node as SwarmNode;
|
|
112
|
+
const path = group.querySelector('path.dx-swarm-tail') as SVGPathElement | null;
|
|
113
|
+
const grad = group.querySelector('linearGradient') as SVGLinearGradientElement | null;
|
|
114
|
+
if (!path || !grad) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const history = swarm.history ?? [];
|
|
118
|
+
if (history.length === 0) {
|
|
119
|
+
path.setAttribute('d', '');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const hx = swarm.x ?? 0;
|
|
124
|
+
const hy = swarm.y ?? 0;
|
|
125
|
+
// Build local-space points head → most-recent → … → oldest (history is push-at-end so
|
|
126
|
+
// we walk it backwards), then run them through a Catmull-Rom curve generator so the
|
|
127
|
+
// trail reads as a smooth arc rather than a polyline. The gradient axis below is still
|
|
128
|
+
// head → oldest, so the fade stays aligned with travel direction.
|
|
129
|
+
const points: Array<[number, number]> = [[0, 0]];
|
|
130
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
131
|
+
points.push([history[i].x - hx, history[i].y - hy]);
|
|
132
|
+
}
|
|
133
|
+
path.setAttribute('d', swarmTrailLine(points) ?? '');
|
|
134
|
+
const oldest = history[0];
|
|
135
|
+
grad.setAttribute('x2', String(oldest.x - hx));
|
|
136
|
+
grad.setAttribute('y2', String(oldest.y - hy));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classify an edge incident to the focus into a relation group. Each relation/property gets two
|
|
141
|
+
* possible nodes: one for the outgoing direction (focus is the edge source, arrow `→`) and one
|
|
142
|
+
* for the incoming direction (focus is the edge target, arrow `←`), so both sides of a relation
|
|
143
|
+
* fan out separately. Relations group by relation typename; references group by their top-level
|
|
144
|
+
* property name. Edges not incident to the focus are ignored.
|
|
145
|
+
*/
|
|
146
|
+
const plexusRelationOf = (edge: GraphLayoutEdge<SpaceGraphNode>, focusId: string): PlexusRelation | undefined => {
|
|
147
|
+
const outgoing = edge.source.id === focusId;
|
|
148
|
+
const incoming = edge.target.id === focusId;
|
|
149
|
+
if (!outgoing && !incoming) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
const direction = outgoing ? 'out' : 'in';
|
|
153
|
+
const arrow = outgoing ? '→' : '←';
|
|
154
|
+
|
|
155
|
+
if (edge.type === 'relation') {
|
|
156
|
+
const relation = (edge.data as any)?.object as Obj.Unknown | undefined;
|
|
157
|
+
const typename = relation ? Obj.getTypename(relation) : undefined;
|
|
158
|
+
const name = typename ? shortTypename(typename) : 'Relation';
|
|
159
|
+
return { key: `relation:${direction}:${typename ?? '?'}`, label: `${name} ${arrow}` };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (edge.type === 'ref') {
|
|
163
|
+
const property = (edge.data as any)?.property as string | undefined;
|
|
164
|
+
const name = property ?? 'References';
|
|
165
|
+
return { key: `ref:${direction}:${property ?? '?'}`, label: `${name} ${arrow}` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Group leaves by typename so same-type leaves cluster together — used by both the
|
|
173
|
+
* cluster (visible structural nodes) and bundle (invisible routing anchors) projectors.
|
|
174
|
+
*/
|
|
175
|
+
const typenameGroupOf = (node: GraphLayoutNode<SpaceGraphNode>): string | undefined => {
|
|
176
|
+
const obj = node.data?.data?.object;
|
|
177
|
+
return obj ? (Obj.getTypename(obj) ?? '(untyped)') : undefined;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Drop the package prefix from a typename for display: `org.dxos.type.Person` → `Person`. */
|
|
181
|
+
const shortTypename = (typename: string): string => {
|
|
182
|
+
const last = typename.split('.').pop() ?? typename;
|
|
183
|
+
return last.charAt(0).toUpperCase() + last.slice(1);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Resolve a leaf's display label from its ECHO object, falling back to node-level metadata. */
|
|
187
|
+
const labelForLeaf = (node: GraphLayoutNode<SpaceGraphNode>, obj: Obj.Unknown | undefined): string | undefined =>
|
|
188
|
+
(obj && Obj.getLabel(obj)) ?? node.data?.data?.label ?? node.id;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Append a label beside a tidy-tree node. Outgoing nodes (right side) read rightward, inbound nodes
|
|
192
|
+
* (left side) read leftward, and the centred root reads above — so every label points away from the tree.
|
|
193
|
+
*/
|
|
194
|
+
const appendTreeLabel = (
|
|
195
|
+
group: Parameters<RenderNode<SpaceGraphNode>>[0],
|
|
196
|
+
text: string,
|
|
197
|
+
r: number,
|
|
198
|
+
placement: 'left' | 'right' | 'above',
|
|
199
|
+
): void => {
|
|
200
|
+
const offset = r + 4;
|
|
201
|
+
const label = group.append('text').attr('class', 'dx-cluster-label').attr('opacity', 0).style('cursor', 'pointer');
|
|
202
|
+
if (placement === 'above') {
|
|
203
|
+
label.attr('x', 0).attr('y', -offset).attr('text-anchor', 'middle');
|
|
204
|
+
} else {
|
|
205
|
+
const toLeft = placement === 'left';
|
|
206
|
+
label
|
|
207
|
+
.attr('dy', '0.32em')
|
|
208
|
+
.attr('x', toLeft ? -offset : offset)
|
|
209
|
+
.attr('text-anchor', toLeft ? 'end' : 'start');
|
|
210
|
+
}
|
|
211
|
+
label.text(text).transition().delay(TWEEN_MS).duration(LABEL_FADE_MS).attr('opacity', 1);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* All variant definitions keyed by id. Each entry fully describes one variant's layout, rendering,
|
|
216
|
+
* and interaction behavior — `Visualization` looks an entry up by id via `getVariant`.
|
|
217
|
+
*/
|
|
218
|
+
const DEFINITIONS: Record<VisualizationVariantId, VisualizationVariant> = {
|
|
219
|
+
force: {
|
|
220
|
+
value: 'force',
|
|
221
|
+
icon: 'ph--graph--regular',
|
|
222
|
+
label: 'Force-directed',
|
|
223
|
+
drag: true,
|
|
224
|
+
emitsHover: true,
|
|
225
|
+
// Force has no `duration` — its own simulation drives motion via ticks.
|
|
226
|
+
createProjector: (ctx, { prev }) => new GraphForceProjector<SpaceGraphNode>(ctx, undefined, undefined, prev),
|
|
227
|
+
renderNode: (group, node) => {
|
|
228
|
+
const r = node.r ?? 6;
|
|
229
|
+
group
|
|
230
|
+
.append('circle')
|
|
231
|
+
.attr('r', r)
|
|
232
|
+
.style('cursor', 'pointer')
|
|
233
|
+
.style('fill', getNodeFillForObject(node.data?.data?.object as Obj.Unknown | undefined));
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
cluster: {
|
|
237
|
+
value: 'cluster',
|
|
238
|
+
icon: 'ph--asterisk-simple--regular',
|
|
239
|
+
label: 'Radial',
|
|
240
|
+
emitsHover: true,
|
|
241
|
+
createProjector: (ctx, { prev }) =>
|
|
242
|
+
new GraphClusterProjector<SpaceGraphNode>(
|
|
243
|
+
ctx,
|
|
244
|
+
{
|
|
245
|
+
duration: TWEEN_MS,
|
|
246
|
+
groupOf: typenameGroupOf,
|
|
247
|
+
rootLabel: 'Database',
|
|
248
|
+
groupLabel: shortTypename,
|
|
249
|
+
// All three node kinds share the same radius — leaves, groups, and root read
|
|
250
|
+
// as members of the same circle rather than ranked by size.
|
|
251
|
+
rootRadius: 4,
|
|
252
|
+
groupRadius: 4,
|
|
253
|
+
},
|
|
254
|
+
undefined,
|
|
255
|
+
prev,
|
|
256
|
+
),
|
|
257
|
+
renderNode: (group, node) => {
|
|
258
|
+
const obj = node.data?.data?.object as Obj.Unknown | undefined;
|
|
259
|
+
const r = node.r ?? 4;
|
|
260
|
+
// Synthetic root / group nodes have no underlying ECHO object; render them as
|
|
261
|
+
// smaller, neutral circles so the hierarchy reads as "structure + leaves".
|
|
262
|
+
group
|
|
263
|
+
.append('circle')
|
|
264
|
+
.attr('r', r)
|
|
265
|
+
.style('cursor', 'pointer')
|
|
266
|
+
.style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
|
|
267
|
+
const labelOptions = { delay: TWEEN_MS, duration: LABEL_FADE_MS };
|
|
268
|
+
if (node.type === CLUSTER_NODE_TYPE_LEAF) {
|
|
269
|
+
const text = labelForLeaf(node, obj);
|
|
270
|
+
if (text) {
|
|
271
|
+
appendRadialLeafLabel(group, node, text, r, labelOptions);
|
|
272
|
+
}
|
|
273
|
+
} else if (node.type === CLUSTER_NODE_TYPE_ROOT) {
|
|
274
|
+
if (node.label) {
|
|
275
|
+
appendRootLabel(group, node.label, r, labelOptions);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
if (node.label) {
|
|
279
|
+
appendRadialGroupLabel(group, node, node.label, r, labelOptions);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
bundle: {
|
|
285
|
+
value: 'bundle',
|
|
286
|
+
icon: 'ph--circles-three-plus--regular',
|
|
287
|
+
label: 'Connections',
|
|
288
|
+
highlightOnHover: true,
|
|
289
|
+
emitsHover: true,
|
|
290
|
+
createProjector: (ctx, { prev }) =>
|
|
291
|
+
new GraphBundleProjector<SpaceGraphNode>(ctx, { duration: TWEEN_MS, groupOf: typenameGroupOf }, undefined, prev),
|
|
292
|
+
// Bundle layout renders ONLY leaves (root/group are invisible routing anchors).
|
|
293
|
+
renderNode: (group, node) => {
|
|
294
|
+
const obj = node.data?.data?.object as Obj.Unknown | undefined;
|
|
295
|
+
const r = node.r ?? 4;
|
|
296
|
+
group
|
|
297
|
+
.append('circle')
|
|
298
|
+
.attr('r', r)
|
|
299
|
+
.style('cursor', 'pointer')
|
|
300
|
+
.style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
|
|
301
|
+
const text = labelForLeaf(node, obj);
|
|
302
|
+
if (text) {
|
|
303
|
+
appendRadialLeafLabel(group, node, text, r, { delay: TWEEN_MS, duration: LABEL_FADE_MS });
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
plexus: {
|
|
308
|
+
value: 'plexus',
|
|
309
|
+
icon: 'ph--share-network--regular',
|
|
310
|
+
label: 'Plexus',
|
|
311
|
+
focusable: true,
|
|
312
|
+
// No focus → dispatch to the bundle projector (the unfocused overview). Once a node is
|
|
313
|
+
// focused, switch to the focus-centric plexus layout; both receive `prev` so the swap
|
|
314
|
+
// (and every re-focus) animates from the previous node positions.
|
|
315
|
+
createProjector: (ctx, { focusId, prev }) => {
|
|
316
|
+
if (!focusId) {
|
|
317
|
+
return new GraphBundleProjector<SpaceGraphNode>(
|
|
318
|
+
ctx,
|
|
319
|
+
{ duration: TWEEN_MS, groupOf: typenameGroupOf },
|
|
320
|
+
undefined,
|
|
321
|
+
prev,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return new GraphPlexusProjector<SpaceGraphNode>(
|
|
325
|
+
ctx,
|
|
326
|
+
{
|
|
327
|
+
duration: TWEEN_MS,
|
|
328
|
+
focus: focusId,
|
|
329
|
+
relationOf: plexusRelationOf,
|
|
330
|
+
leafRadius: PLEXUS_LEAF_RADIUS,
|
|
331
|
+
relationRadius: PLEXUS_LEAF_RADIUS,
|
|
332
|
+
focusRadius: PLEXUS_FOCUS_RADIUS,
|
|
333
|
+
},
|
|
334
|
+
undefined,
|
|
335
|
+
prev,
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
// Three node kinds: the focus at the centre (larger, object fill), synthetic relation
|
|
339
|
+
// nodes on the inner ring (neutral, labelled by relation/property), and object leaves on
|
|
340
|
+
// the outer ring (object fill). Type tags are set by the projector.
|
|
341
|
+
renderNode: (group, node) => {
|
|
342
|
+
const obj = node.data?.data?.object as Obj.Unknown | undefined;
|
|
343
|
+
const labelOptions = { delay: TWEEN_MS, duration: LABEL_FADE_MS };
|
|
344
|
+
|
|
345
|
+
// Size by node type (not node.r): the renderer bakes the circle radius at enter time
|
|
346
|
+
// from the tween's start value, so a node.r fallback would inherit the previous
|
|
347
|
+
// variant's radius. All leaves + relations share the base size; the focus is double.
|
|
348
|
+
if (node.type === PLEXUS_NODE_TYPE_RELATION) {
|
|
349
|
+
const r = PLEXUS_LEAF_RADIUS;
|
|
350
|
+
group.append('circle').attr('r', r).style('cursor', 'default').style('fill', 'var(--color-neutral-500)');
|
|
351
|
+
if (node.label) {
|
|
352
|
+
appendRadialGroupLabel(group, node, node.label, r, labelOptions);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const isFocus = node.type === PLEXUS_NODE_TYPE_FOCUS;
|
|
358
|
+
const r = isFocus ? PLEXUS_FOCUS_RADIUS : PLEXUS_LEAF_RADIUS;
|
|
359
|
+
group
|
|
360
|
+
.append('circle')
|
|
361
|
+
.attr('r', r)
|
|
362
|
+
.style('cursor', 'pointer')
|
|
363
|
+
.style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
|
|
364
|
+
const text = labelForLeaf(node, obj);
|
|
365
|
+
if (text) {
|
|
366
|
+
if (isFocus) {
|
|
367
|
+
appendRootLabel(group, text, r, labelOptions);
|
|
368
|
+
} else {
|
|
369
|
+
appendRadialLeafLabel(group, node, text, r, labelOptions);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
lattice: {
|
|
375
|
+
value: 'lattice',
|
|
376
|
+
icon: 'ph--grid-four--regular',
|
|
377
|
+
label: 'Lattice',
|
|
378
|
+
emitsHover: true,
|
|
379
|
+
createProjector: (ctx, { prev }) =>
|
|
380
|
+
new GraphLatticeProjector<SpaceGraphNode>(
|
|
381
|
+
ctx,
|
|
382
|
+
{
|
|
383
|
+
duration: TWEEN_MS,
|
|
384
|
+
// Plugin-explorer overrides the projector's force-matched default (6)
|
|
385
|
+
// with a smaller node so the lattice reads as a dense matrix.
|
|
386
|
+
radius: 4,
|
|
387
|
+
// Cluster by typename first so same-type rects sit together; break ties by label.
|
|
388
|
+
sortBy: (node: GraphLayoutNode<SpaceGraphNode>) => {
|
|
389
|
+
const obj = node.data?.data?.object;
|
|
390
|
+
const typename = obj ? (Obj.getTypename(obj) ?? '(untyped)') : '(untyped)';
|
|
391
|
+
const label = (obj && Obj.getLabel(obj)) ?? node.data?.data?.label ?? node.id;
|
|
392
|
+
return `${typename} ${label}`;
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
undefined,
|
|
396
|
+
prev,
|
|
397
|
+
),
|
|
398
|
+
renderNode: (group, node) => {
|
|
399
|
+
const r = node.r ?? 6;
|
|
400
|
+
const sz = r * 2;
|
|
401
|
+
group
|
|
402
|
+
.append('rect')
|
|
403
|
+
.attr('x', -r)
|
|
404
|
+
.attr('y', -r)
|
|
405
|
+
.attr('width', sz)
|
|
406
|
+
.attr('height', sz)
|
|
407
|
+
.attr('rx', r * 0.3)
|
|
408
|
+
.attr('ry', r * 0.3)
|
|
409
|
+
.style('cursor', 'pointer')
|
|
410
|
+
.style('fill', getNodeFillForObject(node.data?.data?.object as Obj.Unknown | undefined));
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
swarm: {
|
|
414
|
+
value: 'swarm',
|
|
415
|
+
icon: 'ph--microscope--regular',
|
|
416
|
+
label: 'Swarm',
|
|
417
|
+
edgeOpacity: 0.3,
|
|
418
|
+
trackPointer: true,
|
|
419
|
+
emitsHover: true,
|
|
420
|
+
applyNode: applyNodeSwarm,
|
|
421
|
+
// Swarm in SVG: a per-tick projector mirroring force's emit-positions pattern.
|
|
422
|
+
createProjector: (ctx, { prev }) => new GraphSwarmProjector<SpaceGraphNode>(ctx, undefined, undefined, prev),
|
|
423
|
+
// Match the force variant's shape so identity-by-id transitions read continuously.
|
|
424
|
+
// The tail is a SINGLE `<path>` traced through head + history points; its stroke
|
|
425
|
+
// uses a per-boid `<linearGradient>` that fades head → tail. Done this way (rather
|
|
426
|
+
// than as N overlapping `<line>` segments) so coincident segment endpoints don't
|
|
427
|
+
// compound their alpha and read as a striped trail.
|
|
428
|
+
renderNode: (group, node) => {
|
|
429
|
+
const fill = getNodeFillForObject(node.data?.data?.object as Obj.Unknown | undefined);
|
|
430
|
+
const r = node.r ?? 6;
|
|
431
|
+
const strokeWidth = Math.max(1, r * 0.6);
|
|
432
|
+
// Gradient id must be unique document-wide. node.id is the DXN, which contains
|
|
433
|
+
// characters (`:`, `/`, etc.) that aren't valid in NCName-style ids, so sanitize.
|
|
434
|
+
const gradId = `dx-swarm-grad-${String(node.id).replace(/[^\w-]/g, '_')}`;
|
|
435
|
+
const grad = group
|
|
436
|
+
.append('defs')
|
|
437
|
+
.append('linearGradient')
|
|
438
|
+
.attr('id', gradId)
|
|
439
|
+
// userSpaceOnUse so x1/y1/x2/y2 are interpreted in the dx-node's local coord space
|
|
440
|
+
// (head at 0,0). The applyNode hook overwrites them per tick to align with the
|
|
441
|
+
// path's head → tail axis.
|
|
442
|
+
.attr('gradientUnits', 'userSpaceOnUse')
|
|
443
|
+
.attr('x1', 0)
|
|
444
|
+
.attr('y1', 0)
|
|
445
|
+
.attr('x2', 0)
|
|
446
|
+
.attr('y2', 0);
|
|
447
|
+
grad.append('stop').attr('offset', 0).attr('stop-color', fill).attr('stop-opacity', 0.7);
|
|
448
|
+
grad.append('stop').attr('offset', 1).attr('stop-color', fill).attr('stop-opacity', 0);
|
|
449
|
+
// Path first so the head circle sits on top.
|
|
450
|
+
group
|
|
451
|
+
.append('path')
|
|
452
|
+
.classed('dx-swarm-tail', true)
|
|
453
|
+
.attr('fill', 'none')
|
|
454
|
+
.attr('stroke', `url(#${gradId})`)
|
|
455
|
+
.attr('stroke-width', strokeWidth)
|
|
456
|
+
.attr('stroke-linecap', 'round')
|
|
457
|
+
.attr('stroke-linejoin', 'round')
|
|
458
|
+
.attr('pointer-events', 'none');
|
|
459
|
+
group.append('circle').attr('r', r).style('cursor', 'pointer').style('fill', fill);
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
neighborhood: {
|
|
463
|
+
value: 'neighborhood',
|
|
464
|
+
icon: 'ph--tree-structure--regular',
|
|
465
|
+
label: 'Neighborhood',
|
|
466
|
+
emitsHover: true,
|
|
467
|
+
// Tidy tree: the active node (passed as `focusId`) is the root, drawn on the left and vertically
|
|
468
|
+
// centred; the pre-traversed n-hop neighbourhood fans out to the right. No focus → plain ring.
|
|
469
|
+
createProjector: (ctx, { focusId, prev }) =>
|
|
470
|
+
new GraphTreeProjector<SpaceGraphNode>(
|
|
471
|
+
ctx,
|
|
472
|
+
{ duration: TWEEN_MS, focus: focusId, nodeRadius: TREE_NODE_RADIUS, rootRadius: TREE_ROOT_RADIUS },
|
|
473
|
+
undefined,
|
|
474
|
+
prev,
|
|
475
|
+
),
|
|
476
|
+
renderNode: (group, node) => {
|
|
477
|
+
const obj = node.data?.data?.object as Obj.Unknown | undefined;
|
|
478
|
+
const isRoot = node.type === TREE_NODE_TYPE_ROOT;
|
|
479
|
+
const r = isRoot ? TREE_ROOT_RADIUS : TREE_NODE_RADIUS;
|
|
480
|
+
group
|
|
481
|
+
.append('circle')
|
|
482
|
+
.attr('r', r)
|
|
483
|
+
.style('cursor', 'pointer')
|
|
484
|
+
.style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
|
|
485
|
+
const text = labelForLeaf(node, obj);
|
|
486
|
+
if (text) {
|
|
487
|
+
// Centred root labels above; inbound (left) nodes read leftward; outgoing (right) read rightward.
|
|
488
|
+
const placement = isRoot ? 'above' : node.type === TREE_NODE_TYPE_INBOUND ? 'left' : 'right';
|
|
489
|
+
appendTreeLabel(group, text, r, placement);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Variants offered in the main `ExplorerArticle` toolbar, in display order. Excludes companion-only
|
|
497
|
+
* variants (e.g. `neighborhood`), which are addressed directly by their container via `getVariant`.
|
|
498
|
+
*/
|
|
499
|
+
export const VARIANTS: VisualizationVariant[] = (
|
|
500
|
+
['force', 'cluster', 'bundle', 'plexus', 'lattice', 'swarm'] satisfies ExplorerArticleVariant[]
|
|
501
|
+
).map((id) => DEFINITIONS[id]);
|
|
502
|
+
|
|
503
|
+
/** Look up a variant definition by id. */
|
|
504
|
+
export const getVariant = (value: VisualizationVariantId): VisualizationVariant => DEFINITIONS[value];
|
|
505
|
+
|
|
506
|
+
/** Type guard for the toolbar-selectable variants (excludes companion-only variants). */
|
|
507
|
+
export const isVariant = (value: unknown): value is ExplorerArticleVariant =>
|
|
508
|
+
VARIANTS.some((variant) => variant.value === value);
|
package/src/components/index.ts
CHANGED
|
@@ -25,9 +25,9 @@ const generator = random as any as ValueGenerator;
|
|
|
25
25
|
|
|
26
26
|
random.seed(7);
|
|
27
27
|
|
|
28
|
-
type
|
|
28
|
+
type StoryArgs = { variant: ExplorerArticleVariant };
|
|
29
29
|
|
|
30
|
-
const DefaultStory = ({ variant }:
|
|
30
|
+
const DefaultStory = ({ variant }: StoryArgs) => {
|
|
31
31
|
const [space] = useSpaces();
|
|
32
32
|
const [graph] = useQuery(space?.db, Filter.type(Graph.Graph));
|
|
33
33
|
if (!space || !graph) {
|
|
@@ -37,7 +37,7 @@ const DefaultStory = ({ variant }: DefaultStoryProps) => {
|
|
|
37
37
|
return <ExplorerArticle role='article' subject={graph as any} attendableId={graph.id} variant={variant} />;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
const meta: Meta<
|
|
40
|
+
const meta: Meta<StoryArgs> = {
|
|
41
41
|
title: 'plugins/plugin-explorer/containers/ExplorerArticle',
|
|
42
42
|
render: DefaultStory,
|
|
43
43
|
decorators: [
|
|
@@ -4,21 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { AppSurface } from '@dxos/app-toolkit/ui';
|
|
8
8
|
import { type Filter, Obj, type View } from '@dxos/echo';
|
|
9
9
|
import { QueryBuilder } from '@dxos/echo-query';
|
|
10
10
|
import { useObject } from '@dxos/react-client/echo';
|
|
11
11
|
import { DxAnchorActivate, Icon, Panel, Toolbar } from '@dxos/react-ui';
|
|
12
12
|
import { QueryEditor, type QueryEditorProps } from '@dxos/react-ui-components';
|
|
13
|
+
import { type TreeNode } from '@dxos/react-ui-graph';
|
|
13
14
|
import '@dxos/react-ui-graph/styles/graph.css';
|
|
14
15
|
|
|
15
|
-
import { type
|
|
16
|
+
import { type ExplorerArticleVariant, VARIANTS, Visualization, isVariant } from '#components';
|
|
16
17
|
import { useGraphModel } from '#hooks';
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
import { Visualization } from './Visualization';
|
|
20
|
-
|
|
21
|
-
export type { ExplorerArticleVariant } from './variants';
|
|
19
|
+
export type { ExplorerArticleVariant } from '#components';
|
|
22
20
|
|
|
23
21
|
export type ExplorerArticleProps = AppSurface.ObjectArticleProps<View.View>;
|
|
24
22
|
|
|
@@ -84,7 +82,7 @@ export const ExplorerArticle = ({ role, subject, variant }: ExplorerArticleProps
|
|
|
84
82
|
);
|
|
85
83
|
}, []);
|
|
86
84
|
|
|
87
|
-
const showToolbar = role ===
|
|
85
|
+
const showToolbar = role === AppSurface.Article.role;
|
|
88
86
|
|
|
89
87
|
if (!db || !model) {
|
|
90
88
|
return null;
|
|
@@ -107,13 +105,14 @@ export const ExplorerArticle = ({ role, subject, variant }: ExplorerArticleProps
|
|
|
107
105
|
</Panel.Toolbar>
|
|
108
106
|
)}
|
|
109
107
|
<Panel.Content>
|
|
110
|
-
<Visualization
|
|
108
|
+
<Visualization.Root
|
|
111
109
|
classNames='bg-base-surface'
|
|
112
110
|
variant={selected}
|
|
113
111
|
model={model}
|
|
114
|
-
onNodeHover={handleHover}
|
|
115
112
|
onSurfaceClick={handleDismiss}
|
|
116
|
-
|
|
113
|
+
>
|
|
114
|
+
<Visualization.Graph onNodeHover={handleHover} />
|
|
115
|
+
</Visualization.Root>
|
|
117
116
|
</Panel.Content>
|
|
118
117
|
</Panel.Root>
|
|
119
118
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useCallback, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Obj } from '@dxos/echo';
|
|
8
|
+
import { DxAnchorActivate, Panel, Toolbar, useTranslation } from '@dxos/react-ui';
|
|
9
|
+
import { type TreeNode } from '@dxos/react-ui-graph';
|
|
10
|
+
import '@dxos/react-ui-graph/styles/graph.css';
|
|
11
|
+
|
|
12
|
+
import { Visualization } from '#components';
|
|
13
|
+
import { DEFAULT_NEIGHBORHOOD_DEPTH, useNeighborhoodModel } from '#hooks';
|
|
14
|
+
import { meta } from '#meta';
|
|
15
|
+
|
|
16
|
+
/** Selectable traversal depths offered in the companion toolbar. */
|
|
17
|
+
const DEPTHS = [1, 2, 3] as const;
|
|
18
|
+
|
|
19
|
+
export type NeighborhoodCompanionProps = {
|
|
20
|
+
role?: string;
|
|
21
|
+
/** The active node whose neighbourhood is rendered (passed as the companion subject). */
|
|
22
|
+
subject: Obj.Any;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Companion that renders the n-hop neighbourhood of the active node as an ego-centric radial graph,
|
|
27
|
+
* with the active node at the centre. Depth (number of hops traversed) is adjustable in the toolbar.
|
|
28
|
+
*/
|
|
29
|
+
export const NeighborhoodCompanion = ({ role = 'article', subject }: NeighborhoodCompanionProps) => {
|
|
30
|
+
const { t } = useTranslation(meta.profile.key);
|
|
31
|
+
const [depth, setDepth] = useState<number>(DEFAULT_NEIGHBORHOOD_DEPTH);
|
|
32
|
+
const model = useNeighborhoodModel(subject, depth);
|
|
33
|
+
|
|
34
|
+
const handleDepthChange = useCallback((value: string) => {
|
|
35
|
+
const next = Number.parseInt(value, 10);
|
|
36
|
+
if (Number.isFinite(next)) {
|
|
37
|
+
setDepth(next);
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
// Dismiss the preview popover. The dxn/label/trigger fields are placeholders ignored on `state: false`.
|
|
42
|
+
const handleDismiss = useCallback(() => {
|
|
43
|
+
document.defaultView?.dispatchEvent(
|
|
44
|
+
new DxAnchorActivate({ dxn: '', label: '', trigger: document.body, state: false }),
|
|
45
|
+
);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const handleHover = useCallback((node: TreeNode | null, event?: MouseEvent) => {
|
|
49
|
+
// Pointer left the node/label: keep the popover open so it can be hovered/interacted with.
|
|
50
|
+
if (!node || !event) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const obj = node.data;
|
|
54
|
+
if (!obj || !Obj.isObject(obj)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const dxn = Obj.getURI(obj);
|
|
58
|
+
if (!dxn) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const target = event.target as HTMLElement;
|
|
63
|
+
target.dispatchEvent(new DxAnchorActivate({ dxn, kind: 'card', trigger: target, label: Obj.getLabel(obj) ?? dxn }));
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
if (!subject || !model) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Panel.Root role={role}>
|
|
72
|
+
<Panel.Toolbar asChild>
|
|
73
|
+
<Toolbar.Root>
|
|
74
|
+
<div role='none' className='grow' />
|
|
75
|
+
<Toolbar.ToggleGroup type='single' value={String(depth)} onValueChange={handleDepthChange}>
|
|
76
|
+
{DEPTHS.map((value) => (
|
|
77
|
+
<Toolbar.ToggleGroupItem
|
|
78
|
+
key={value}
|
|
79
|
+
value={String(value)}
|
|
80
|
+
aria-label={t('depth.label', { count: value })}
|
|
81
|
+
title={t('depth.label', { count: value })}
|
|
82
|
+
>
|
|
83
|
+
{value}
|
|
84
|
+
</Toolbar.ToggleGroupItem>
|
|
85
|
+
))}
|
|
86
|
+
</Toolbar.ToggleGroup>
|
|
87
|
+
</Toolbar.Root>
|
|
88
|
+
</Panel.Toolbar>
|
|
89
|
+
<Panel.Content>
|
|
90
|
+
<Visualization.Root
|
|
91
|
+
classNames='bg-base-surface'
|
|
92
|
+
model={model}
|
|
93
|
+
variant='neighborhood'
|
|
94
|
+
focus={subject.id}
|
|
95
|
+
onSurfaceClick={handleDismiss}
|
|
96
|
+
>
|
|
97
|
+
<Visualization.Graph onNodeHover={handleHover} />
|
|
98
|
+
</Visualization.Root>
|
|
99
|
+
</Panel.Content>
|
|
100
|
+
</Panel.Root>
|
|
101
|
+
);
|
|
102
|
+
};
|