@dxos/app-graph 0.6.2 → 0.6.3-main.0af171d
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/browser/index.mjs +581 -191
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +586 -185
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +99 -7
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph-builder.test.d.ts +2 -0
- package/dist/types/src/graph-builder.test.d.ts.map +1 -0
- package/dist/types/src/graph.d.ts +96 -47
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +99 -40
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/package.json +14 -12
- package/src/graph-builder.test.ts +310 -0
- package/src/graph-builder.ts +332 -19
- package/src/graph.test.ts +431 -179
- package/src/graph.ts +337 -149
- package/src/index.ts +0 -1
- package/src/node.ts +15 -42
- package/src/stories/EchoGraph.stories.tsx +84 -102
- package/dist/types/src/helpers.d.ts +0 -12
- package/dist/types/src/helpers.d.ts.map +0 -1
- package/src/helpers.ts +0 -27
package/src/graph.ts
CHANGED
|
@@ -2,17 +2,47 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { untracked } from '@preact/signals-core';
|
|
5
|
+
import { batch, effect, untracked } from '@preact/signals-core';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { asyncTimeout, Trigger } from '@dxos/async';
|
|
8
|
+
import { type ReactiveObject, create } from '@dxos/echo-schema';
|
|
8
9
|
import { invariant } from '@dxos/invariant';
|
|
9
10
|
import { nonNullable } from '@dxos/util';
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
+
import { type Relation, type Node, type NodeArg, type NodeFilter, isActionLike } from './node';
|
|
13
|
+
|
|
14
|
+
const graphSymbol = Symbol('graph');
|
|
15
|
+
type DeepWriteable<T> = { -readonly [K in keyof T]: DeepWriteable<T[K]> };
|
|
16
|
+
type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
|
|
17
|
+
|
|
18
|
+
export const getGraph = (node: Node): Graph => {
|
|
19
|
+
const graph = (node as NodeInternal)[graphSymbol];
|
|
20
|
+
invariant(graph, 'Node is not associated with a graph.');
|
|
21
|
+
return graph;
|
|
22
|
+
};
|
|
12
23
|
|
|
13
24
|
export const ROOT_ID = 'root';
|
|
25
|
+
export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
|
|
26
|
+
export const ACTION_TYPE = 'dxos.org/type/GraphAction';
|
|
27
|
+
export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
|
|
28
|
+
|
|
29
|
+
const NODE_TIMEOUT = 5_000;
|
|
30
|
+
|
|
31
|
+
export type NodesOptions<T = any, U extends Record<string, any> = Record<string, any>> = {
|
|
32
|
+
relation?: Relation;
|
|
33
|
+
filter?: NodeFilter<T, U>;
|
|
34
|
+
onlyLoaded?: boolean;
|
|
35
|
+
type?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type GraphTraversalOptions = {
|
|
39
|
+
/**
|
|
40
|
+
* A callback which is called for each node visited during traversal.
|
|
41
|
+
*
|
|
42
|
+
* If the callback returns `false`, traversal is stops recursing.
|
|
43
|
+
*/
|
|
44
|
+
visitor: (node: Node, path: string[]) => boolean | void;
|
|
14
45
|
|
|
15
|
-
export type TraversalOptions = {
|
|
16
46
|
/**
|
|
17
47
|
* The node to start traversing from.
|
|
18
48
|
*
|
|
@@ -21,40 +51,54 @@ export type TraversalOptions = {
|
|
|
21
51
|
node?: Node;
|
|
22
52
|
|
|
23
53
|
/**
|
|
24
|
-
* The
|
|
54
|
+
* The relation to traverse graph edges.
|
|
25
55
|
*
|
|
26
56
|
* @default 'outbound'
|
|
27
57
|
*/
|
|
28
|
-
|
|
58
|
+
relation?: Relation;
|
|
29
59
|
|
|
30
60
|
/**
|
|
31
|
-
*
|
|
61
|
+
* Only traverse nodes that are already loaded.
|
|
32
62
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A callback which is called for each node visited during traversal.
|
|
37
|
-
*/
|
|
38
|
-
visitor?: (node: Node, path: string[]) => void;
|
|
63
|
+
onlyLoaded?: boolean;
|
|
39
64
|
};
|
|
40
65
|
|
|
41
66
|
/**
|
|
42
67
|
* The Graph represents the structure of the application constructed via plugins.
|
|
43
68
|
*/
|
|
44
69
|
export class Graph {
|
|
70
|
+
private readonly _onInitialNode?: (id: string, type?: string) => NodeArg<any> | undefined;
|
|
71
|
+
private readonly _onInitialNodes?: (node: Node, relation: Relation, type?: string) => NodeArg<any>[] | undefined;
|
|
72
|
+
private readonly _onRemoveNode?: (id: string) => void;
|
|
73
|
+
|
|
74
|
+
private readonly _waitingForNodes: Record<string, Trigger<Node>> = {};
|
|
75
|
+
private readonly _initialized: Record<string, boolean> = {};
|
|
76
|
+
|
|
45
77
|
/**
|
|
46
78
|
* @internal
|
|
47
79
|
*/
|
|
48
|
-
readonly _nodes
|
|
49
|
-
[ROOT_ID]: { id: ROOT_ID, properties: {}, data: null },
|
|
50
|
-
});
|
|
80
|
+
readonly _nodes: Record<string, ReactiveObject<NodeInternal>> = {};
|
|
51
81
|
|
|
52
82
|
/**
|
|
53
83
|
* @internal
|
|
54
84
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
readonly _edges: Record<string, ReactiveObject<{ inbound: string[]; outbound: string[] }>> = {};
|
|
86
|
+
|
|
87
|
+
constructor({
|
|
88
|
+
onInitialNode,
|
|
89
|
+
onInitialNodes,
|
|
90
|
+
onRemoveNode,
|
|
91
|
+
}: {
|
|
92
|
+
onInitialNode?: Graph['_onInitialNode'];
|
|
93
|
+
onInitialNodes?: Graph['_onInitialNodes'];
|
|
94
|
+
onRemoveNode?: Graph['_onRemoveNode'];
|
|
95
|
+
} = {}) {
|
|
96
|
+
this._onInitialNode = onInitialNode;
|
|
97
|
+
this._onInitialNodes = onInitialNodes;
|
|
98
|
+
this._onRemoveNode = onRemoveNode;
|
|
99
|
+
this._nodes[ROOT_ID] = this._constructNode({ id: ROOT_ID, type: ROOT_TYPE, properties: {}, data: null });
|
|
100
|
+
this._edges[ROOT_ID] = create({ inbound: [], outbound: [] });
|
|
101
|
+
}
|
|
58
102
|
|
|
59
103
|
/**
|
|
60
104
|
* Alias for `findNode('root')`.
|
|
@@ -66,11 +110,16 @@ export class Graph {
|
|
|
66
110
|
/**
|
|
67
111
|
* Convert the graph to a JSON object.
|
|
68
112
|
*/
|
|
69
|
-
toJSON({
|
|
113
|
+
toJSON({
|
|
114
|
+
id = ROOT_ID,
|
|
115
|
+
maxLength = 32,
|
|
116
|
+
onlyLoaded = true,
|
|
117
|
+
}: { id?: string; maxLength?: number; onlyLoaded?: boolean } = {}) {
|
|
70
118
|
const toJSON = (node: Node, seen: string[] = []): any => {
|
|
71
|
-
const nodes =
|
|
119
|
+
const nodes = this.nodes(node, { onlyLoaded });
|
|
72
120
|
const obj: Record<string, any> = {
|
|
73
121
|
id: node.id.length > maxLength ? `${node.id.slice(0, maxLength - 3)}...` : node.id,
|
|
122
|
+
type: node.type,
|
|
74
123
|
};
|
|
75
124
|
if (node.properties.label) {
|
|
76
125
|
obj.label = node.properties.label;
|
|
@@ -94,57 +143,149 @@ export class Graph {
|
|
|
94
143
|
|
|
95
144
|
/**
|
|
96
145
|
* Find the node with the given id in the graph.
|
|
146
|
+
*
|
|
147
|
+
* If a node is not found within the graph and an `onInitialNode` callback is provided,
|
|
148
|
+
* it is called with the id and type of the node, potentially initializing the node.
|
|
97
149
|
*/
|
|
98
|
-
findNode(id: string): Node | undefined {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
findNode(id: string, type?: string): Node | undefined {
|
|
151
|
+
const existingNode = this._nodes[id];
|
|
152
|
+
const nodeArg = !existingNode && this._onInitialNode?.(id, type);
|
|
153
|
+
return existingNode ?? (nodeArg ? this._addNode(nodeArg) : undefined);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait for a node to be added to the graph.
|
|
158
|
+
*
|
|
159
|
+
* If the node is already present in the graph, the promise resolves immediately.
|
|
160
|
+
*
|
|
161
|
+
* @param id The id of the node to wait for.
|
|
162
|
+
* @param timeout The time in milliseconds to wait for the node to be added.
|
|
163
|
+
*/
|
|
164
|
+
async waitForNode(id: string, timeout = NODE_TIMEOUT): Promise<Node> {
|
|
165
|
+
const node = this.findNode(id);
|
|
166
|
+
if (node) {
|
|
167
|
+
return node;
|
|
102
168
|
}
|
|
103
169
|
|
|
104
|
-
|
|
170
|
+
const trigger = this._waitingForNodes[id] ?? (this._waitingForNodes[id] = new Trigger<Node>());
|
|
171
|
+
return asyncTimeout(trigger.wait(), timeout, `Node not found: ${id}`);
|
|
105
172
|
}
|
|
106
173
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return filter ? nodes.filter((n) => filter(n, node)) : nodes;
|
|
116
|
-
},
|
|
117
|
-
node: (id: string) => {
|
|
118
|
-
return this._getNodes({ id }).find((node) => node.id === id);
|
|
119
|
-
},
|
|
120
|
-
actions: () => {
|
|
121
|
-
return this._getNodes({ id: node.id }).filter(isActionLike);
|
|
122
|
-
},
|
|
123
|
-
};
|
|
174
|
+
/**
|
|
175
|
+
* Nodes that this node is connected to in default order.
|
|
176
|
+
*/
|
|
177
|
+
nodes<T = any, U extends Record<string, any> = Record<string, any>>(node: Node, options: NodesOptions<T, U> = {}) {
|
|
178
|
+
const { onlyLoaded, relation, filter, type } = options;
|
|
179
|
+
const nodes = this._getNodes({ node, relation, type, onlyLoaded });
|
|
180
|
+
return nodes.filter((n) => untracked(() => !isActionLike(n))).filter((n) => filter?.(n, node) ?? true);
|
|
181
|
+
}
|
|
124
182
|
|
|
125
|
-
|
|
126
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Edges that this node is connected to in default order.
|
|
185
|
+
*/
|
|
186
|
+
edges(node: Node, { relation = 'outbound' }: { relation?: Relation } = {}) {
|
|
187
|
+
return this._edges[node.id]?.[relation] ?? [];
|
|
188
|
+
}
|
|
127
189
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Actions or action groups that this node is connected to in default order.
|
|
192
|
+
*/
|
|
193
|
+
actions(node: Node, { onlyLoaded }: { onlyLoaded?: boolean } = {}) {
|
|
194
|
+
return [
|
|
195
|
+
...this._getNodes({ node, type: ACTION_GROUP_TYPE, onlyLoaded }),
|
|
196
|
+
...this._getNodes({ node, type: ACTION_TYPE, onlyLoaded }),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Recursive depth-first traversal of the graph.
|
|
202
|
+
*
|
|
203
|
+
* @param options.node The node to start traversing from.
|
|
204
|
+
* @param options.relation The relation to traverse graph edges.
|
|
205
|
+
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
206
|
+
*/
|
|
207
|
+
traverse(
|
|
208
|
+
{ visitor, node = this.root, relation = 'outbound', onlyLoaded }: GraphTraversalOptions,
|
|
209
|
+
path: string[] = [],
|
|
210
|
+
): void {
|
|
211
|
+
// Break cycles.
|
|
212
|
+
if (path.includes(node.id)) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const shouldContinue = visitor(node, [...path, node.id]);
|
|
217
|
+
if (shouldContinue === false) {
|
|
218
|
+
return;
|
|
132
219
|
}
|
|
133
220
|
|
|
134
|
-
|
|
221
|
+
Object.values(this._getNodes({ node, relation, onlyLoaded })).forEach((child) =>
|
|
222
|
+
this.traverse({ node: child, relation, visitor, onlyLoaded }, [...path, node.id]),
|
|
223
|
+
);
|
|
135
224
|
}
|
|
136
225
|
|
|
137
|
-
|
|
138
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Recursive depth-first traversal of the graph wrapping each visitor call in an effect.
|
|
228
|
+
*
|
|
229
|
+
* @param options.node The node to start traversing from.
|
|
230
|
+
* @param options.relation The relation to traverse graph edges.
|
|
231
|
+
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
232
|
+
*/
|
|
233
|
+
subscribeTraverse(
|
|
234
|
+
{ visitor, node = this.root, relation = 'outbound', onlyLoaded }: GraphTraversalOptions,
|
|
235
|
+
currentPath: string[] = [],
|
|
236
|
+
) {
|
|
237
|
+
return effect(() => {
|
|
238
|
+
const path = [...currentPath, node.id];
|
|
239
|
+
const result = visitor(node, path);
|
|
240
|
+
if (result === false) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const nodes = this._getNodes({ node, relation, onlyLoaded });
|
|
245
|
+
const nodeSubscriptions = nodes.map((n) => this.subscribeTraverse({ node: n, visitor, onlyLoaded }, path));
|
|
246
|
+
|
|
247
|
+
return () => {
|
|
248
|
+
nodeSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get the path between two nodes in the graph.
|
|
255
|
+
*/
|
|
256
|
+
getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
|
|
257
|
+
const start = this.findNode(source);
|
|
258
|
+
if (!start) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let found: string[] | undefined;
|
|
263
|
+
this.traverse({
|
|
264
|
+
onlyLoaded: true,
|
|
265
|
+
node: start,
|
|
266
|
+
visitor: (node, path) => {
|
|
267
|
+
if (found) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (node.id === target) {
|
|
272
|
+
found = path;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return found;
|
|
139
278
|
}
|
|
140
279
|
|
|
141
280
|
/**
|
|
142
281
|
* Add nodes to the graph.
|
|
282
|
+
*
|
|
283
|
+
* @internal
|
|
143
284
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
285
|
+
_addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
|
|
286
|
+
nodes: NodeArg<TData, TProperties>[],
|
|
146
287
|
): Node<TData, TProperties>[] {
|
|
147
|
-
return nodes.map((node) => this._addNode(node));
|
|
288
|
+
return batch(() => nodes.map((node) => this._addNode(node)));
|
|
148
289
|
}
|
|
149
290
|
|
|
150
291
|
private _addNode<TData, TProperties extends Record<string, any> = Record<string, any>>({
|
|
@@ -153,35 +294,65 @@ export class Graph {
|
|
|
153
294
|
..._node
|
|
154
295
|
}: NodeArg<TData, TProperties>): Node<TData, TProperties> {
|
|
155
296
|
return untracked(() => {
|
|
156
|
-
const
|
|
157
|
-
|
|
297
|
+
const existingNode = this._nodes[_node.id];
|
|
298
|
+
const node = existingNode ?? this._constructNode({ data: null, properties: {}, ..._node });
|
|
299
|
+
if (existingNode) {
|
|
300
|
+
const { data, properties, type } = _node;
|
|
301
|
+
if (data && data !== node.data) {
|
|
302
|
+
node.data = data;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (type !== node.type) {
|
|
306
|
+
node.type = type;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const key in properties) {
|
|
310
|
+
if (properties[key] !== node.properties[key]) {
|
|
311
|
+
node.properties[key] = properties[key];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
this._nodes[node.id] = node;
|
|
316
|
+
this._edges[node.id] = create({ inbound: [], outbound: [] });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const trigger = this._waitingForNodes[node.id];
|
|
320
|
+
if (trigger) {
|
|
321
|
+
trigger.wake(node);
|
|
322
|
+
delete this._waitingForNodes[node.id];
|
|
323
|
+
}
|
|
158
324
|
|
|
159
325
|
if (nodes) {
|
|
160
326
|
nodes.forEach((subNode) => {
|
|
161
327
|
this._addNode(subNode);
|
|
162
|
-
this.
|
|
328
|
+
this._addEdge({ source: node.id, target: subNode.id });
|
|
163
329
|
});
|
|
164
330
|
}
|
|
165
331
|
|
|
166
332
|
if (edges) {
|
|
167
|
-
edges.forEach(([id,
|
|
168
|
-
|
|
169
|
-
? this.
|
|
170
|
-
: this.
|
|
333
|
+
edges.forEach(([id, relation]) =>
|
|
334
|
+
relation === 'outbound'
|
|
335
|
+
? this._addEdge({ source: node.id, target: id })
|
|
336
|
+
: this._addEdge({ source: id, target: node.id }),
|
|
171
337
|
);
|
|
172
338
|
}
|
|
173
339
|
|
|
174
|
-
return
|
|
340
|
+
return node as unknown as Node<TData, TProperties>;
|
|
175
341
|
});
|
|
176
342
|
}
|
|
177
343
|
|
|
178
344
|
/**
|
|
179
345
|
* Remove nodes from the graph.
|
|
180
346
|
*
|
|
181
|
-
* @param
|
|
347
|
+
* @param ids The id of the node to remove.
|
|
182
348
|
* @param edges Whether to remove edges connected to the node from the graph as well.
|
|
349
|
+
* @internal
|
|
183
350
|
*/
|
|
184
|
-
|
|
351
|
+
_removeNodes(ids: string[], edges = false) {
|
|
352
|
+
batch(() => ids.forEach((id) => this._removeNode(id, edges)));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private _removeNode(id: string, edges = false) {
|
|
185
356
|
untracked(() => {
|
|
186
357
|
const node = this.findNode(id);
|
|
187
358
|
if (!node) {
|
|
@@ -189,40 +360,75 @@ export class Graph {
|
|
|
189
360
|
}
|
|
190
361
|
|
|
191
362
|
if (edges) {
|
|
192
|
-
// Remove edges from node.
|
|
193
|
-
delete this._edges[this.getEdgeKey(id, 'outbound')];
|
|
194
|
-
delete this._edges[this.getEdgeKey(id, 'inbound')];
|
|
195
|
-
|
|
196
363
|
// Remove edges from connected nodes.
|
|
197
|
-
this._getNodes({
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
)
|
|
364
|
+
this._getNodes({ node, onlyLoaded: true }).forEach((node) => {
|
|
365
|
+
this._removeEdge({ source: id, target: node.id });
|
|
366
|
+
});
|
|
367
|
+
this._getNodes({ node, relation: 'inbound', onlyLoaded: true }).forEach((node) => {
|
|
368
|
+
this._removeEdge({ source: node.id, target: id });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Remove edges from node.
|
|
372
|
+
delete this._edges[id];
|
|
201
373
|
}
|
|
202
374
|
|
|
203
375
|
// Remove node.
|
|
204
376
|
delete this._nodes[id];
|
|
377
|
+
this._onRemoveNode?.(id);
|
|
205
378
|
});
|
|
206
379
|
}
|
|
207
380
|
|
|
208
381
|
/**
|
|
209
|
-
* Add
|
|
382
|
+
* Add edges to the graph.
|
|
383
|
+
*
|
|
384
|
+
* @internal
|
|
210
385
|
*/
|
|
211
|
-
|
|
386
|
+
_addEdges(edges: { source: string; target: string }[]) {
|
|
387
|
+
batch(() => edges.forEach((edge) => this._addEdge(edge)));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private _addEdge({ source, target }: { source: string; target: string }) {
|
|
212
391
|
untracked(() => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
392
|
+
if (!this._edges[source]) {
|
|
393
|
+
this._edges[source] = create({ inbound: [], outbound: [] });
|
|
394
|
+
}
|
|
395
|
+
if (!this._edges[target]) {
|
|
396
|
+
this._edges[target] = create({ inbound: [], outbound: [] });
|
|
218
397
|
}
|
|
219
398
|
|
|
220
|
-
const
|
|
221
|
-
if (!
|
|
222
|
-
|
|
223
|
-
} else if (!inbound.includes(source)) {
|
|
224
|
-
inbound.push(source);
|
|
399
|
+
const sourceEdges = this._edges[source];
|
|
400
|
+
if (!sourceEdges.outbound.includes(target)) {
|
|
401
|
+
sourceEdges.outbound.push(target);
|
|
225
402
|
}
|
|
403
|
+
|
|
404
|
+
const targetEdges = this._edges[target];
|
|
405
|
+
if (!targetEdges.inbound.includes(source)) {
|
|
406
|
+
targetEdges.inbound.push(source);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Remove edges from the graph.
|
|
413
|
+
* @internal
|
|
414
|
+
*/
|
|
415
|
+
_removeEdges(edges: { source: string; target: string }[]) {
|
|
416
|
+
batch(() => edges.forEach((edge) => this._removeEdge(edge)));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private _removeEdge({ source, target }: { source: string; target: string }) {
|
|
420
|
+
untracked(() => {
|
|
421
|
+
batch(() => {
|
|
422
|
+
const outboundIndex = this._edges[source]?.outbound.findIndex((id) => id === target);
|
|
423
|
+
if (outboundIndex !== undefined && outboundIndex !== -1) {
|
|
424
|
+
this._edges[source].outbound.splice(outboundIndex, 1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const inboundIndex = this._edges[target]?.inbound.findIndex((id) => id === source);
|
|
428
|
+
if (inboundIndex !== undefined && inboundIndex !== -1) {
|
|
429
|
+
this._edges[target].inbound.splice(inboundIndex, 1);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
226
432
|
});
|
|
227
433
|
}
|
|
228
434
|
|
|
@@ -232,80 +438,62 @@ export class Graph {
|
|
|
232
438
|
* Edges not included in the sorted list are appended to the end of the list.
|
|
233
439
|
*
|
|
234
440
|
* @param nodeId The id of the node to sort edges for.
|
|
235
|
-
* @param
|
|
441
|
+
* @param relation The relation of the edges from the node to sort.
|
|
236
442
|
* @param edges The ordered list of edges.
|
|
443
|
+
* @ignore
|
|
237
444
|
*/
|
|
238
|
-
|
|
445
|
+
_sortEdges(nodeId: string, relation: Relation, edges: string[]) {
|
|
239
446
|
untracked(() => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
447
|
+
batch(() => {
|
|
448
|
+
const current = this._edges[nodeId];
|
|
449
|
+
if (current) {
|
|
450
|
+
const unsorted = current[relation].filter((id) => !edges.includes(id)) ?? [];
|
|
451
|
+
const sorted = edges.filter((id) => current[relation].includes(id)) ?? [];
|
|
452
|
+
current[relation].splice(0, current[relation].length, ...[...sorted, ...unsorted]);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
246
455
|
});
|
|
247
456
|
}
|
|
248
457
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
removeEdge({ source, target }: { source: string; target: string }) {
|
|
253
|
-
untracked(() => {
|
|
254
|
-
const outboundIndex = this._edges[this.getEdgeKey(source, 'outbound')]?.findIndex((id) => id === target);
|
|
255
|
-
if (outboundIndex !== -1) {
|
|
256
|
-
this._edges[this.getEdgeKey(source, 'outbound')].splice(outboundIndex, 1);
|
|
257
|
-
}
|
|
458
|
+
private _constructNode = (node: Omit<Node, typeof graphSymbol>) => {
|
|
459
|
+
return create<NodeInternal>({ ...node, [graphSymbol]: this });
|
|
460
|
+
};
|
|
258
461
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
462
|
+
private _getNodes({
|
|
463
|
+
node,
|
|
464
|
+
relation = 'outbound',
|
|
465
|
+
type,
|
|
466
|
+
onlyLoaded,
|
|
467
|
+
}: {
|
|
468
|
+
node: Node;
|
|
469
|
+
relation?: Relation;
|
|
470
|
+
type?: string;
|
|
471
|
+
onlyLoaded?: boolean;
|
|
472
|
+
}): Node[] {
|
|
473
|
+
// TODO(wittjosiah): Factor out helper.
|
|
474
|
+
const key = `${node.id}-${relation}-${type}`;
|
|
475
|
+
const initialized = this._initialized[key];
|
|
476
|
+
if (!initialized && !onlyLoaded && this._onInitialNodes) {
|
|
477
|
+
const args = this._onInitialNodes(node, relation, type)?.filter((n) => !type || n.type === type);
|
|
478
|
+
this._initialized[key] = true;
|
|
479
|
+
if (args && args.length > 0) {
|
|
480
|
+
const nodes = this._addNodes(args);
|
|
481
|
+
this._addEdges(
|
|
482
|
+
nodes.map(({ id }) =>
|
|
483
|
+
relation === 'outbound' ? { source: node.id, target: id } : { source: id, target: node.id },
|
|
484
|
+
),
|
|
485
|
+
);
|
|
262
486
|
}
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Recursive depth-first traversal.
|
|
268
|
-
*
|
|
269
|
-
* @param options.node The node to start traversing from.
|
|
270
|
-
* @param options.direction The direction to traverse graph edges.
|
|
271
|
-
* @param options.filter A predicate to filter nodes which are passed to the `visitor` callback.
|
|
272
|
-
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
273
|
-
*/
|
|
274
|
-
traverse({ node = this.root, direction = 'outbound', filter, visitor }: TraversalOptions, path: string[] = []): void {
|
|
275
|
-
// Break cycles.
|
|
276
|
-
if (path.includes(node.id)) {
|
|
277
|
-
return;
|
|
278
487
|
}
|
|
279
488
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Get the path between two nodes in the graph.
|
|
291
|
-
*/
|
|
292
|
-
getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
|
|
293
|
-
const start = this.findNode(source);
|
|
294
|
-
if (!start) {
|
|
295
|
-
return undefined;
|
|
489
|
+
const edges = this._edges[node.id];
|
|
490
|
+
if (!edges) {
|
|
491
|
+
return [];
|
|
492
|
+
} else {
|
|
493
|
+
return edges[relation]
|
|
494
|
+
.map((id) => this._nodes[id])
|
|
495
|
+
.filter(nonNullable)
|
|
496
|
+
.filter((n) => !type || n.type === type);
|
|
296
497
|
}
|
|
297
|
-
|
|
298
|
-
let found: string[] | undefined;
|
|
299
|
-
this.traverse({
|
|
300
|
-
node: start,
|
|
301
|
-
filter: () => !found,
|
|
302
|
-
visitor: (node, path) => {
|
|
303
|
-
if (node.id === target) {
|
|
304
|
-
found = path;
|
|
305
|
-
}
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
return found;
|
|
310
498
|
}
|
|
311
499
|
}
|