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