@dxos/app-graph 0.8.2-main.fbd8ed0 → 0.8.2-staging.7ac8446
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 +789 -541
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +780 -533
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +789 -541
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/graph-builder.d.ts +91 -48
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +98 -191
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +3 -3
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -23
- package/src/graph-builder.test.ts +310 -293
- package/src/graph-builder.ts +317 -209
- package/src/graph.test.ts +463 -314
- package/src/graph.ts +455 -452
- package/src/node.ts +4 -4
- package/src/stories/EchoGraph.stories.tsx +78 -57
- package/dist/types/src/experimental/graph-projections.test.d.ts +0 -25
- package/dist/types/src/experimental/graph-projections.test.d.ts.map +0 -1
- package/dist/types/src/signals-integration.test.d.ts +0 -2
- package/dist/types/src/signals-integration.test.d.ts.map +0 -1
- package/dist/types/src/testing.d.ts +0 -5
- package/dist/types/src/testing.d.ts.map +0 -1
- package/src/experimental/graph-projections.test.ts +0 -56
- package/src/signals-integration.test.ts +0 -218
- package/src/testing.ts +0 -20
package/src/graph.ts
CHANGED
|
@@ -2,24 +2,20 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { Option, pipe, Record } from 'effect';
|
|
5
|
+
import { batch, effect, untracked } from '@preact/signals-core';
|
|
7
6
|
|
|
8
|
-
import {
|
|
9
|
-
import { todo } from '@dxos/debug';
|
|
7
|
+
import { asyncTimeout, Trigger } from '@dxos/async';
|
|
10
8
|
import { invariant } from '@dxos/invariant';
|
|
9
|
+
import { type ReactiveObject, create } from '@dxos/live-object';
|
|
11
10
|
import { log } from '@dxos/log';
|
|
12
|
-
import { isNonNullable,
|
|
11
|
+
import { type MakeOptional, isNonNullable, pick } from '@dxos/util';
|
|
13
12
|
|
|
14
|
-
import { type
|
|
13
|
+
import { type Node, type NodeArg, type NodeFilter, type Relation, actionGroupSymbol, isActionLike } from './node';
|
|
15
14
|
|
|
16
15
|
const graphSymbol = Symbol('graph');
|
|
17
16
|
type DeepWriteable<T> = { -readonly [K in keyof T]: T[K] extends object ? DeepWriteable<T[K]> : T[K] };
|
|
18
17
|
type NodeInternal = DeepWriteable<Node> & { [graphSymbol]: Graph };
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
* Get the Graph a Node is currently associated with.
|
|
22
|
-
*/
|
|
23
19
|
export const getGraph = (node: Node): Graph => {
|
|
24
20
|
const graph = (node as NodeInternal)[graphSymbol];
|
|
25
21
|
invariant(graph, 'Node is not associated with a graph.');
|
|
@@ -31,6 +27,16 @@ export const ROOT_TYPE = 'dxos.org/type/GraphRoot';
|
|
|
31
27
|
export const ACTION_TYPE = 'dxos.org/type/GraphAction';
|
|
32
28
|
export const ACTION_GROUP_TYPE = 'dxos.org/type/GraphActionGroup';
|
|
33
29
|
|
|
30
|
+
export type NodesOptions<T = any, U extends Record<string, any> = Record<string, any>> = {
|
|
31
|
+
relation?: Relation;
|
|
32
|
+
filter?: NodeFilter<T, U>;
|
|
33
|
+
expansion?: boolean;
|
|
34
|
+
type?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// TODO(wittjosiah): Consider having default be undefined. This is current default for backwards compatibility.
|
|
38
|
+
const DEFAULT_FILTER = (node: Node) => untracked(() => !isActionLike(node));
|
|
39
|
+
|
|
34
40
|
export type GraphTraversalOptions = {
|
|
35
41
|
/**
|
|
36
42
|
* A callback which is called for each node visited during traversal.
|
|
@@ -42,9 +48,9 @@ export type GraphTraversalOptions = {
|
|
|
42
48
|
/**
|
|
43
49
|
* The node to start traversing from.
|
|
44
50
|
*
|
|
45
|
-
* @default
|
|
51
|
+
* @default root
|
|
46
52
|
*/
|
|
47
|
-
|
|
53
|
+
node?: Node;
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
56
|
* The relation to traverse graph edges.
|
|
@@ -52,552 +58,549 @@ export type GraphTraversalOptions = {
|
|
|
52
58
|
* @default 'outbound'
|
|
53
59
|
*/
|
|
54
60
|
relation?: Relation;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Allow traversal to trigger expansion of the graph via `onInitialNodes`.
|
|
64
|
+
*/
|
|
65
|
+
expansion?: boolean;
|
|
55
66
|
};
|
|
56
67
|
|
|
57
68
|
export type GraphParams = {
|
|
58
|
-
registry?: Registry.Registry;
|
|
59
69
|
nodes?: MakeOptional<Node, 'data' | 'cacheable'>[];
|
|
60
|
-
edges?: Record<string,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// onInitialize?: Graph['_onInitialize'];
|
|
70
|
+
edges?: Record<string, string[]>;
|
|
71
|
+
onInitialNode?: Graph['_onInitialNode'];
|
|
72
|
+
onInitialNodes?: Graph['_onInitialNodes'];
|
|
64
73
|
onRemoveNode?: Graph['_onRemoveNode'];
|
|
65
74
|
};
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The Graph represents the structure of the application constructed via plugins.
|
|
78
|
+
*/
|
|
79
|
+
export class Graph {
|
|
80
|
+
private readonly _onInitialNode?: (id: string) => Promise<void>;
|
|
81
|
+
private readonly _onInitialNodes?: (node: Node, relation: Relation, type?: string) => Promise<void>;
|
|
82
|
+
private readonly _onRemoveNode?: (id: string) => Promise<void>;
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* Event emitted when a node is changed.
|
|
73
|
-
*/
|
|
74
|
-
onNodeChanged: Event<{ id: string; node: Option.Option<Node> }>;
|
|
84
|
+
private readonly _waitingForNodes: Record<string, Trigger<Node>> = {};
|
|
85
|
+
private readonly _initialized: Record<string, boolean> = {};
|
|
75
86
|
|
|
76
87
|
/**
|
|
77
|
-
*
|
|
88
|
+
* @internal
|
|
78
89
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
json(id?: string): Rx.Rx<any>;
|
|
90
|
+
readonly _nodes: Record<string, ReactiveObject<NodeInternal>> = {};
|
|
82
91
|
|
|
83
92
|
/**
|
|
84
|
-
*
|
|
93
|
+
* @internal
|
|
85
94
|
*/
|
|
86
|
-
|
|
95
|
+
readonly _edges: Record<string, ReactiveObject<{ inbound: string[]; outbound: string[] }>> = {};
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
constructor({ nodes, edges, onInitialNode, onInitialNodes, onRemoveNode }: GraphParams = {}) {
|
|
98
|
+
this._onInitialNode = onInitialNode;
|
|
99
|
+
this._onInitialNodes = onInitialNodes;
|
|
100
|
+
this._onRemoveNode = onRemoveNode;
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
this._nodes[ROOT_ID] = this._constructNode({
|
|
103
|
+
id: ROOT_ID,
|
|
104
|
+
type: ROOT_TYPE,
|
|
105
|
+
cacheable: [],
|
|
106
|
+
properties: {},
|
|
107
|
+
data: null,
|
|
108
|
+
});
|
|
109
|
+
if (nodes) {
|
|
110
|
+
nodes.forEach((node) => {
|
|
111
|
+
const cacheable = Object.keys(node.properties ?? {});
|
|
112
|
+
if (node.type === ACTION_TYPE) {
|
|
113
|
+
this._addNode({ cacheable, data: () => log.warn('Pickled action invocation'), ...node });
|
|
114
|
+
} else if (node.type === ACTION_GROUP_TYPE) {
|
|
115
|
+
this._addNode({ cacheable, data: actionGroupSymbol, ...node });
|
|
116
|
+
} else {
|
|
117
|
+
this._addNode({ cacheable, ...node });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
97
121
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
this._edges[ROOT_ID] = create({ inbound: [], outbound: [] });
|
|
123
|
+
if (edges) {
|
|
124
|
+
Object.entries(edges).forEach(([source, edges]) => {
|
|
125
|
+
edges.forEach((target) => {
|
|
126
|
+
this._addEdge({ source, target });
|
|
127
|
+
});
|
|
128
|
+
this._sortEdges(source, 'outbound', edges);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
102
132
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
static from(pickle: string, options: Omit<GraphParams, 'nodes' | 'edges'> = {}) {
|
|
134
|
+
const { nodes, edges } = JSON.parse(pickle);
|
|
135
|
+
return new Graph({ nodes, edges, ...options });
|
|
136
|
+
}
|
|
107
137
|
|
|
108
138
|
/**
|
|
109
|
-
* Alias for `
|
|
139
|
+
* Alias for `findNode('root')`.
|
|
110
140
|
*/
|
|
111
|
-
get root()
|
|
141
|
+
get root() {
|
|
142
|
+
return this.findNode(ROOT_ID)!;
|
|
143
|
+
}
|
|
112
144
|
|
|
113
145
|
/**
|
|
114
|
-
*
|
|
146
|
+
* Convert the graph to a JSON object.
|
|
115
147
|
*/
|
|
116
|
-
|
|
148
|
+
toJSON({ id = ROOT_ID, maxLength = 32 }: { id?: string; maxLength?: number } = {}) {
|
|
149
|
+
const toJSON = (node: Node, seen: string[] = []): any => {
|
|
150
|
+
const nodes = this.nodes(node);
|
|
151
|
+
const obj: Record<string, any> = {
|
|
152
|
+
id: node.id.length > maxLength ? `${node.id.slice(0, maxLength - 3)}...` : node.id,
|
|
153
|
+
type: node.type,
|
|
154
|
+
};
|
|
155
|
+
if (node.properties.label) {
|
|
156
|
+
obj.label = node.properties.label;
|
|
157
|
+
}
|
|
158
|
+
if (nodes.length) {
|
|
159
|
+
obj.nodes = nodes
|
|
160
|
+
.map((n) => {
|
|
161
|
+
// Break cycles.
|
|
162
|
+
const nextSeen = [...seen, node.id];
|
|
163
|
+
return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
|
|
164
|
+
})
|
|
165
|
+
.filter(isNonNullable);
|
|
166
|
+
}
|
|
167
|
+
return obj;
|
|
168
|
+
};
|
|
117
169
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*/
|
|
123
|
-
getNodeOrThrow(id: string): Node;
|
|
170
|
+
const root = this.findNode(id);
|
|
171
|
+
invariant(root, `Node not found: ${id}`);
|
|
172
|
+
return toJSON(root);
|
|
173
|
+
}
|
|
124
174
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
175
|
+
pickle() {
|
|
176
|
+
const nodes = Object.values(this._nodes)
|
|
177
|
+
.filter((node) => !!node.cacheable)
|
|
178
|
+
.map((node) => {
|
|
179
|
+
return {
|
|
180
|
+
id: node.id,
|
|
181
|
+
type: node.type,
|
|
182
|
+
properties: pick(node.properties, node.cacheable!),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
129
185
|
|
|
130
|
-
|
|
131
|
-
* Get all actions connected to the node with the given id from the graph's registry.
|
|
132
|
-
*/
|
|
133
|
-
getActions(id: string): Node[];
|
|
186
|
+
const cacheable = new Set(nodes.map((node) => node.id));
|
|
134
187
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
188
|
+
const edges = Object.fromEntries(
|
|
189
|
+
Object.entries(this._edges)
|
|
190
|
+
.filter(([id]) => cacheable.has(id))
|
|
191
|
+
.map(([id, { outbound }]): [string, string[]] => [id, outbound.filter((nodeId) => cacheable.has(nodeId))])
|
|
192
|
+
// TODO(wittjosiah): Why sort?
|
|
193
|
+
.toSorted(([a], [b]) => a.localeCompare(b)),
|
|
194
|
+
);
|
|
139
195
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
*
|
|
143
|
-
* @param options.node The node to start traversing from.
|
|
144
|
-
* @param options.relation The relation to traverse graph edges.
|
|
145
|
-
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
146
|
-
*/
|
|
147
|
-
traverse(options: GraphTraversalOptions, path?: string[]): void;
|
|
196
|
+
return JSON.stringify({ nodes, edges });
|
|
197
|
+
}
|
|
148
198
|
|
|
149
199
|
/**
|
|
150
|
-
*
|
|
200
|
+
* Find the node with the given id in the graph.
|
|
201
|
+
*
|
|
202
|
+
* If a node is not found within the graph and an `onInitialNode` callback is provided,
|
|
203
|
+
* it is called with the id and type of the node, potentially initializing the node.
|
|
151
204
|
*/
|
|
152
|
-
|
|
205
|
+
findNode(id: string, expansion = true): Node | undefined {
|
|
206
|
+
const existingNode = this._nodes[id];
|
|
207
|
+
if (!existingNode && expansion) {
|
|
208
|
+
void this._onInitialNode?.(id);
|
|
209
|
+
}
|
|
153
210
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
*/
|
|
157
|
-
waitForPath(
|
|
158
|
-
params: { source?: string; target: string },
|
|
159
|
-
options?: { timeout?: number; interval?: number },
|
|
160
|
-
): Promise<string[]>;
|
|
161
|
-
}
|
|
211
|
+
return existingNode;
|
|
212
|
+
}
|
|
162
213
|
|
|
163
|
-
export interface ExpandableGraph extends ReadableGraph {
|
|
164
214
|
/**
|
|
165
|
-
*
|
|
215
|
+
* Wait for a node to be added to the graph.
|
|
166
216
|
*
|
|
167
|
-
*
|
|
168
|
-
*/
|
|
169
|
-
// initialize(id: string): Promise<void>;
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Expand a node in the graph.
|
|
217
|
+
* If the node is already present in the graph, the promise resolves immediately.
|
|
173
218
|
*
|
|
174
|
-
*
|
|
219
|
+
* @param id The id of the node to wait for.
|
|
220
|
+
* @param timeout The time in milliseconds to wait for the node to be added.
|
|
175
221
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export interface WritableGraph extends ExpandableGraph {
|
|
185
|
-
/**
|
|
186
|
-
* Add nodes to the graph.
|
|
187
|
-
*/
|
|
188
|
-
addNodes(nodes: NodeArg<any, Record<string, any>>[]): void;
|
|
222
|
+
async waitForNode(id: string, timeout?: number): Promise<Node> {
|
|
223
|
+
const trigger = this._waitingForNodes[id] ?? (this._waitingForNodes[id] = new Trigger<Node>());
|
|
224
|
+
const node = this.findNode(id);
|
|
225
|
+
if (node) {
|
|
226
|
+
delete this._waitingForNodes[id];
|
|
227
|
+
return node;
|
|
228
|
+
}
|
|
189
229
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
230
|
+
if (timeout === undefined) {
|
|
231
|
+
return trigger.wait();
|
|
232
|
+
} else {
|
|
233
|
+
return asyncTimeout(trigger.wait(), timeout, `Node not found: ${id}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
194
236
|
|
|
195
237
|
/**
|
|
196
|
-
*
|
|
238
|
+
* Nodes that this node is connected to in default order.
|
|
197
239
|
*/
|
|
198
|
-
|
|
240
|
+
nodes<T = any, U extends Record<string, any> = Record<string, any>>(node: Node, options: NodesOptions<T, U> = {}) {
|
|
241
|
+
const { relation, expansion, filter = DEFAULT_FILTER, type } = options;
|
|
242
|
+
const nodes = this._getNodes({ node, relation, expansion, type });
|
|
243
|
+
return nodes.filter((n) => filter(n, node));
|
|
244
|
+
}
|
|
199
245
|
|
|
200
246
|
/**
|
|
201
|
-
*
|
|
247
|
+
* Edges that this node is connected to in default order.
|
|
202
248
|
*/
|
|
203
|
-
|
|
249
|
+
edges(node: Node, { relation = 'outbound' }: { relation?: Relation } = {}) {
|
|
250
|
+
return this._edges[node.id]?.[relation] ?? [];
|
|
251
|
+
}
|
|
204
252
|
|
|
205
253
|
/**
|
|
206
|
-
*
|
|
254
|
+
* Actions or action groups that this node is connected to in default order.
|
|
207
255
|
*/
|
|
208
|
-
|
|
256
|
+
actions(node: Node, { expansion }: { expansion?: boolean } = {}) {
|
|
257
|
+
return [
|
|
258
|
+
...this._getNodes({ node, expansion, type: ACTION_GROUP_TYPE }),
|
|
259
|
+
...this._getNodes({ node, expansion, type: ACTION_TYPE }),
|
|
260
|
+
];
|
|
261
|
+
}
|
|
209
262
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
263
|
+
async expand(node: Node, relation: Relation = 'outbound', type?: string) {
|
|
264
|
+
const key = this._key(node, relation, type);
|
|
265
|
+
const initialized = this._initialized[key];
|
|
266
|
+
if (!initialized && this._onInitialNodes) {
|
|
267
|
+
await this._onInitialNodes(node, relation, type);
|
|
268
|
+
this._initialized[key] = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
214
271
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
removeEdges(edges: Edge[], removeOrphans?: boolean): void;
|
|
272
|
+
private _key(node: Node, relation: Relation, type?: string) {
|
|
273
|
+
return `${node.id}-${relation}-${type}`;
|
|
274
|
+
}
|
|
219
275
|
|
|
220
276
|
/**
|
|
221
|
-
*
|
|
277
|
+
* Recursive depth-first traversal of the graph.
|
|
278
|
+
*
|
|
279
|
+
* @param options.node The node to start traversing from.
|
|
280
|
+
* @param options.relation The relation to traverse graph edges.
|
|
281
|
+
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
222
282
|
*/
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
readonly onNodeChanged = new Event<{ id: string; node: Option.Option<Node> }>();
|
|
231
|
-
|
|
232
|
-
private readonly _onExpand?: (id: string, relation: Relation) => void;
|
|
233
|
-
// private readonly _onInitialize?: (id: string) => Promise<void>;
|
|
234
|
-
private readonly _onRemoveNode?: (id: string) => void;
|
|
235
|
-
|
|
236
|
-
private readonly _registry: Registry.Registry;
|
|
237
|
-
private readonly _expanded = Record.empty<string, boolean>();
|
|
238
|
-
private readonly _initialized = Record.empty<string, boolean>();
|
|
239
|
-
private readonly _initialEdges = Record.empty<string, Edges>();
|
|
240
|
-
private readonly _initialNodes = Record.fromEntries([
|
|
241
|
-
[ROOT_ID, this._constructNode({ id: ROOT_ID, type: ROOT_TYPE, data: null, properties: {} })],
|
|
242
|
-
]);
|
|
243
|
-
|
|
244
|
-
/** @internal */
|
|
245
|
-
readonly _node = Rx.family<string, Rx.Writable<Option.Option<Node>>>((id) => {
|
|
246
|
-
const initial = Option.flatten(Record.get(this._initialNodes, id));
|
|
247
|
-
return Rx.make<Option.Option<Node>>(initial).pipe(Rx.keepAlive, Rx.withLabel(`graph:node:${id}`));
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
private readonly _nodeOrThrow = Rx.family<string, Rx.Rx<Node>>((id) => {
|
|
251
|
-
return Rx.make((get) => {
|
|
252
|
-
const node = get(this._node(id));
|
|
253
|
-
invariant(Option.isSome(node), `Node not available: ${id}`);
|
|
254
|
-
return node.value;
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
private readonly _edges = Rx.family<string, Rx.Writable<Edges>>((id) => {
|
|
259
|
-
const initial = Record.get(this._initialEdges, id).pipe(Option.getOrElse(() => ({ inbound: [], outbound: [] })));
|
|
260
|
-
return Rx.make<Edges>(initial).pipe(Rx.keepAlive, Rx.withLabel(`graph:edges:${id}`));
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// NOTE: Currently the argument to the family needs to be referentially stable for the rx to be referentially stable.
|
|
264
|
-
// TODO(wittjosiah): Rx feature request, support for something akin to `ComplexMap` to allow for complex arguments.
|
|
265
|
-
private readonly _connections = Rx.family<string, Rx.Rx<Node[]>>((key) => {
|
|
266
|
-
return Rx.make((get) => {
|
|
267
|
-
const [id, relation] = key.split('$');
|
|
268
|
-
const edges = get(this._edges(id));
|
|
269
|
-
return edges[relation as Relation]
|
|
270
|
-
.map((id) => get(this._node(id)))
|
|
271
|
-
.filter(Option.isSome)
|
|
272
|
-
.map((o) => o.value);
|
|
273
|
-
}).pipe(Rx.withLabel(`graph:connections:${key}`));
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
private readonly _actions = Rx.family<string, Rx.Rx<(Action | ActionGroup)[]>>((id) => {
|
|
277
|
-
return Rx.make((get) => {
|
|
278
|
-
return get(this._connections(`${id}$outbound`)).filter(
|
|
279
|
-
(node) => node.type === ACTION_TYPE || node.type === ACTION_GROUP_TYPE,
|
|
280
|
-
);
|
|
281
|
-
}).pipe(Rx.withLabel(`graph:actions:${id}`));
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
private readonly _json = Rx.family<string, Rx.Rx<any>>((id) => {
|
|
285
|
-
return Rx.make((get) => {
|
|
286
|
-
const toJSON = (node: Node, seen: string[] = []): any => {
|
|
287
|
-
const nodes = get(this.connections(node.id));
|
|
288
|
-
const obj: Record<string, any> = {
|
|
289
|
-
id: node.id.length > 32 ? `${node.id.slice(0, 32)}...` : node.id,
|
|
290
|
-
type: node.type,
|
|
291
|
-
};
|
|
292
|
-
if (node.properties.label) {
|
|
293
|
-
obj.label = node.properties.label;
|
|
294
|
-
}
|
|
295
|
-
if (nodes.length) {
|
|
296
|
-
obj.nodes = nodes
|
|
297
|
-
.map((n) => {
|
|
298
|
-
// Break cycles.
|
|
299
|
-
const nextSeen = [...seen, node.id];
|
|
300
|
-
return nextSeen.includes(n.id) ? undefined : toJSON(n, nextSeen);
|
|
301
|
-
})
|
|
302
|
-
.filter(isNonNullable);
|
|
303
|
-
}
|
|
304
|
-
return obj;
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
const root = get(this.nodeOrThrow(id));
|
|
308
|
-
return toJSON(root);
|
|
309
|
-
}).pipe(Rx.withLabel(`graph:json:${id}`));
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
constructor({ registry, nodes, edges, onExpand, onRemoveNode }: GraphParams = {}) {
|
|
313
|
-
this._registry = registry ?? Registry.make();
|
|
314
|
-
this._onExpand = onExpand;
|
|
315
|
-
this._onRemoveNode = onRemoveNode;
|
|
316
|
-
|
|
317
|
-
if (nodes) {
|
|
318
|
-
nodes.forEach((node) => {
|
|
319
|
-
Record.set(this._initialNodes, node.id, this._constructNode(node));
|
|
320
|
-
});
|
|
283
|
+
traverse(
|
|
284
|
+
{ visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
|
|
285
|
+
path: string[] = [],
|
|
286
|
+
): void {
|
|
287
|
+
// Break cycles.
|
|
288
|
+
if (path.includes(node.id)) {
|
|
289
|
+
return;
|
|
321
290
|
}
|
|
322
291
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
});
|
|
292
|
+
const shouldContinue = visitor(node, [...path, node.id]);
|
|
293
|
+
if (shouldContinue === false) {
|
|
294
|
+
return;
|
|
327
295
|
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
toJSON(id = ROOT_ID) {
|
|
331
|
-
return this._registry.get(this._json(id));
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
json(id = ROOT_ID) {
|
|
335
|
-
return this._json(id);
|
|
336
|
-
}
|
|
337
296
|
|
|
338
|
-
|
|
339
|
-
|
|
297
|
+
Object.values(this._getNodes({ node, relation, expansion })).forEach((child) =>
|
|
298
|
+
this.traverse({ node: child, relation, visitor, expansion }, [...path, node.id]),
|
|
299
|
+
);
|
|
340
300
|
}
|
|
341
301
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Recursive depth-first traversal of the graph wrapping each visitor call in an effect.
|
|
304
|
+
*
|
|
305
|
+
* @param options.node The node to start traversing from.
|
|
306
|
+
* @param options.relation The relation to traverse graph edges.
|
|
307
|
+
* @param options.visitor A callback which is called for each node visited during traversal.
|
|
308
|
+
*/
|
|
309
|
+
subscribeTraverse(
|
|
310
|
+
{ visitor, node = this.root, relation = 'outbound', expansion }: GraphTraversalOptions,
|
|
311
|
+
currentPath: string[] = [],
|
|
312
|
+
) {
|
|
313
|
+
return effect(() => {
|
|
314
|
+
const path = [...currentPath, node.id];
|
|
315
|
+
const result = visitor(node, path);
|
|
316
|
+
if (result === false) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
345
319
|
|
|
346
|
-
|
|
347
|
-
|
|
320
|
+
const nodes = this._getNodes({ node, relation, expansion });
|
|
321
|
+
const nodeSubscriptions = nodes.map((n) => this.subscribeTraverse({ node: n, visitor, expansion }, path));
|
|
322
|
+
return () => {
|
|
323
|
+
nodeSubscriptions.forEach((unsubscribe) => unsubscribe());
|
|
324
|
+
};
|
|
325
|
+
});
|
|
348
326
|
}
|
|
349
327
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Get the path between two nodes in the graph.
|
|
330
|
+
*/
|
|
331
|
+
getPath({ source = 'root', target }: { source?: string; target: string }): string[] | undefined {
|
|
332
|
+
const start = this.findNode(source);
|
|
333
|
+
if (!start) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
353
336
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
337
|
+
let found: string[] | undefined;
|
|
338
|
+
this.traverse({
|
|
339
|
+
node: start,
|
|
340
|
+
visitor: (node, path) => {
|
|
341
|
+
if (found) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
357
344
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
345
|
+
if (node.id === target) {
|
|
346
|
+
found = path;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
});
|
|
361
350
|
|
|
362
|
-
|
|
363
|
-
return this._registry.get(this.node(id));
|
|
351
|
+
return found;
|
|
364
352
|
}
|
|
365
353
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Wait for the path between two nodes in the graph to be established.
|
|
356
|
+
*/
|
|
357
|
+
async waitForPath(
|
|
358
|
+
params: { source?: string; target: string },
|
|
359
|
+
{ timeout = 5_000, interval = 500 }: { timeout?: number; interval?: number } = {},
|
|
360
|
+
) {
|
|
361
|
+
const path = this.getPath(params);
|
|
362
|
+
if (path) {
|
|
363
|
+
return path;
|
|
364
|
+
}
|
|
369
365
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
366
|
+
const trigger = new Trigger<string[]>();
|
|
367
|
+
const i = setInterval(() => {
|
|
368
|
+
const path = this.getPath(params);
|
|
369
|
+
if (path) {
|
|
370
|
+
trigger.wake(path);
|
|
371
|
+
}
|
|
372
|
+
}, interval);
|
|
373
373
|
|
|
374
|
-
|
|
375
|
-
return this._registry.get(this.actions(id));
|
|
374
|
+
return trigger.wait({ timeout }).finally(() => clearInterval(i));
|
|
376
375
|
}
|
|
377
376
|
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Add nodes to the graph.
|
|
379
|
+
*
|
|
380
|
+
* @internal
|
|
381
|
+
*/
|
|
382
|
+
_addNodes<TData = null, TProperties extends Record<string, any> = Record<string, any>>(
|
|
383
|
+
nodes: NodeArg<TData, TProperties>[],
|
|
384
|
+
): Node<TData, TProperties>[] {
|
|
385
|
+
return batch(() => nodes.map((node) => this._addNode(node)));
|
|
380
386
|
}
|
|
381
387
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
log('expand', { key, expanded });
|
|
396
|
-
if (!expanded) {
|
|
397
|
-
this._onExpand?.(id, relation);
|
|
398
|
-
Record.set(this._expanded, key, true);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
388
|
+
private _addNode<TData, TProperties extends Record<string, any> = Record<string, any>>({
|
|
389
|
+
nodes,
|
|
390
|
+
edges,
|
|
391
|
+
..._node
|
|
392
|
+
}: NodeArg<TData, TProperties>): Node<TData, TProperties> {
|
|
393
|
+
return untracked(() => {
|
|
394
|
+
const existingNode = this._nodes[_node.id];
|
|
395
|
+
const node = existingNode ?? this._constructNode({ data: null, properties: {}, ..._node });
|
|
396
|
+
if (existingNode) {
|
|
397
|
+
const { data = null, properties, type } = _node;
|
|
398
|
+
if (data !== node.data) {
|
|
399
|
+
node.data = data;
|
|
400
|
+
}
|
|
401
401
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
});
|
|
406
|
-
}
|
|
402
|
+
if (type !== node.type) {
|
|
403
|
+
node.type = type;
|
|
404
|
+
}
|
|
407
405
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
Option.match(node, {
|
|
413
|
-
onSome: (node) => {
|
|
414
|
-
const typeChanged = node.type !== type;
|
|
415
|
-
const dataChanged = node.data !== data;
|
|
416
|
-
const propertiesChanged = Object.keys(properties).some((key) => node.properties[key] !== properties[key]);
|
|
417
|
-
log('existing node', { typeChanged, dataChanged, propertiesChanged });
|
|
418
|
-
if (typeChanged || dataChanged || propertiesChanged) {
|
|
419
|
-
log('updating node', { id, type, data, properties });
|
|
420
|
-
const newNode = Option.some({ ...node, type, data, properties: { ...node.properties, ...properties } });
|
|
421
|
-
this._registry.set(nodeRx, newNode);
|
|
422
|
-
this.onNodeChanged.emit({ id, node: newNode });
|
|
406
|
+
for (const key in properties) {
|
|
407
|
+
if (properties[key] !== node.properties[key]) {
|
|
408
|
+
node.properties[key] = properties[key];
|
|
409
|
+
}
|
|
423
410
|
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
this._registry.set(nodeRx, newNode);
|
|
429
|
-
this.onNodeChanged.emit({ id, node: newNode });
|
|
430
|
-
},
|
|
431
|
-
});
|
|
411
|
+
} else {
|
|
412
|
+
this._nodes[node.id] = node;
|
|
413
|
+
this._edges[node.id] = create({ inbound: [], outbound: [] });
|
|
414
|
+
}
|
|
432
415
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// });
|
|
439
|
-
}
|
|
416
|
+
const trigger = this._waitingForNodes[node.id];
|
|
417
|
+
if (trigger) {
|
|
418
|
+
trigger.wake(node);
|
|
419
|
+
delete this._waitingForNodes[node.id];
|
|
420
|
+
}
|
|
440
421
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
422
|
+
if (nodes) {
|
|
423
|
+
nodes.forEach((subNode) => {
|
|
424
|
+
this._addNode(subNode);
|
|
425
|
+
this._addEdge({ source: node.id, target: subNode.id });
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (edges) {
|
|
430
|
+
edges.forEach(([id, relation]) =>
|
|
431
|
+
relation === 'outbound'
|
|
432
|
+
? this._addEdge({ source: node.id, target: id })
|
|
433
|
+
: this._addEdge({ source: id, target: node.id }),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
445
436
|
|
|
446
|
-
|
|
447
|
-
Rx.batch(() => {
|
|
448
|
-
ids.map((id) => this.removeNode(id, edges));
|
|
437
|
+
return node as unknown as Node<TData, TProperties>;
|
|
449
438
|
});
|
|
450
439
|
}
|
|
451
440
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const edges = [
|
|
462
|
-
...inbound.map((source) => ({ source, target: id })),
|
|
463
|
-
...outbound.map((target) => ({ source: id, target })),
|
|
464
|
-
];
|
|
465
|
-
this.removeEdges(edges);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
this._onRemoveNode?.(id);
|
|
441
|
+
/**
|
|
442
|
+
* Remove nodes from the graph.
|
|
443
|
+
*
|
|
444
|
+
* @param ids The id of the node to remove.
|
|
445
|
+
* @param edges Whether to remove edges connected to the node from the graph as well.
|
|
446
|
+
* @internal
|
|
447
|
+
*/
|
|
448
|
+
_removeNodes(ids: string[], edges = false) {
|
|
449
|
+
batch(() => ids.forEach((id) => this._removeNode(id, edges)));
|
|
469
450
|
}
|
|
470
451
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
452
|
+
private _removeNode(id: string, edges = false) {
|
|
453
|
+
untracked(() => {
|
|
454
|
+
const node = this.findNode(id, false);
|
|
455
|
+
if (!node) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
476
458
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
459
|
+
if (edges) {
|
|
460
|
+
// Remove edges from connected nodes.
|
|
461
|
+
this._getNodes({ node }).forEach((node) => {
|
|
462
|
+
this._removeEdge({ source: id, target: node.id });
|
|
463
|
+
});
|
|
464
|
+
this._getNodes({ node, relation: 'inbound' }).forEach((node) => {
|
|
465
|
+
this._removeEdge({ source: node.id, target: id });
|
|
466
|
+
});
|
|
484
467
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
log('add inbound edge', { source: edgeArg.source, target: edgeArg.target });
|
|
489
|
-
this._registry.set(targetRx, { inbound: [...target.inbound, edgeArg.source], outbound: target.outbound });
|
|
490
|
-
}
|
|
491
|
-
}
|
|
468
|
+
// Remove edges from node.
|
|
469
|
+
delete this._edges[id];
|
|
470
|
+
}
|
|
492
471
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
472
|
+
// Remove node.
|
|
473
|
+
delete this._nodes[id];
|
|
474
|
+
Object.keys(this._initialized)
|
|
475
|
+
.filter((key) => key.startsWith(id))
|
|
476
|
+
.forEach((key) => {
|
|
477
|
+
delete this._initialized[key];
|
|
478
|
+
});
|
|
479
|
+
void this._onRemoveNode?.(id);
|
|
496
480
|
});
|
|
497
481
|
}
|
|
498
482
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
483
|
+
/**
|
|
484
|
+
* Add edges to the graph.
|
|
485
|
+
*
|
|
486
|
+
* @internal
|
|
487
|
+
*/
|
|
488
|
+
_addEdges(edges: { source: string; target: string }[]) {
|
|
489
|
+
batch(() => edges.forEach((edge) => this._addEdge(edge)));
|
|
490
|
+
}
|
|
508
491
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
492
|
+
private _addEdge({ source, target }: { source: string; target: string }) {
|
|
493
|
+
untracked(() => {
|
|
494
|
+
if (!this._edges[source]) {
|
|
495
|
+
this._edges[source] = create({ inbound: [], outbound: [] });
|
|
496
|
+
}
|
|
497
|
+
if (!this._edges[target]) {
|
|
498
|
+
this._edges[target] = create({ inbound: [], outbound: [] });
|
|
499
|
+
}
|
|
517
500
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (source.outbound.length === 0 && source.inbound.length === 0 && edgeArg.source !== ROOT_ID) {
|
|
522
|
-
this.removeNodes([edgeArg.source]);
|
|
501
|
+
const sourceEdges = this._edges[source];
|
|
502
|
+
if (!sourceEdges.outbound.includes(target)) {
|
|
503
|
+
sourceEdges.outbound.push(target);
|
|
523
504
|
}
|
|
524
|
-
|
|
525
|
-
|
|
505
|
+
|
|
506
|
+
const targetEdges = this._edges[target];
|
|
507
|
+
if (!targetEdges.inbound.includes(source)) {
|
|
508
|
+
targetEdges.inbound.push(source);
|
|
526
509
|
}
|
|
527
|
-
}
|
|
510
|
+
});
|
|
528
511
|
}
|
|
529
512
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
edges
|
|
536
|
-
this._registry.set(edgesRx, edges);
|
|
513
|
+
/**
|
|
514
|
+
* Remove edges from the graph.
|
|
515
|
+
* @internal
|
|
516
|
+
*/
|
|
517
|
+
_removeEdges(edges: { source: string; target: string }[], removeOrphans = false) {
|
|
518
|
+
batch(() => edges.forEach((edge) => this._removeEdge(edge, removeOrphans)));
|
|
537
519
|
}
|
|
538
520
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
521
|
+
private _removeEdge({ source, target }: { source: string; target: string }, removeOrphans = false) {
|
|
522
|
+
untracked(() => {
|
|
523
|
+
batch(() => {
|
|
524
|
+
const outboundIndex = this._edges[source]?.outbound.findIndex((id) => id === target);
|
|
525
|
+
if (outboundIndex !== undefined && outboundIndex !== -1) {
|
|
526
|
+
this._edges[source].outbound.splice(outboundIndex, 1);
|
|
527
|
+
}
|
|
544
528
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
529
|
+
const inboundIndex = this._edges[target]?.inbound.findIndex((id) => id === source);
|
|
530
|
+
if (inboundIndex !== undefined && inboundIndex !== -1) {
|
|
531
|
+
this._edges[target].inbound.splice(inboundIndex, 1);
|
|
532
|
+
}
|
|
550
533
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
534
|
+
if (removeOrphans) {
|
|
535
|
+
if (
|
|
536
|
+
this._edges[source]?.outbound.length === 0 &&
|
|
537
|
+
this._edges[source]?.inbound.length === 0 &&
|
|
538
|
+
source !== ROOT_ID
|
|
539
|
+
) {
|
|
540
|
+
this._removeNode(source, true);
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
this._edges[target]?.outbound.length === 0 &&
|
|
544
|
+
this._edges[target]?.inbound.length === 0 &&
|
|
545
|
+
target !== ROOT_ID
|
|
546
|
+
) {
|
|
547
|
+
this._removeNode(target, true);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
});
|
|
554
552
|
}
|
|
555
553
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
})
|
|
576
|
-
);
|
|
554
|
+
/**
|
|
555
|
+
* Sort edges for a node.
|
|
556
|
+
*
|
|
557
|
+
* Edges not included in the sorted list are appended to the end of the list.
|
|
558
|
+
*
|
|
559
|
+
* @param nodeId The id of the node to sort edges for.
|
|
560
|
+
* @param relation The relation of the edges from the node to sort.
|
|
561
|
+
* @param edges The ordered list of edges.
|
|
562
|
+
* @ignore
|
|
563
|
+
*/
|
|
564
|
+
_sortEdges(nodeId: string, relation: Relation, edges: string[]) {
|
|
565
|
+
untracked(() => {
|
|
566
|
+
batch(() => {
|
|
567
|
+
const current = this._edges[nodeId];
|
|
568
|
+
if (current) {
|
|
569
|
+
const unsorted = current[relation].filter((id) => !edges.includes(id)) ?? [];
|
|
570
|
+
const sorted = edges.filter((id) => current[relation].includes(id)) ?? [];
|
|
571
|
+
current[relation].splice(0, current[relation].length, ...[...sorted, ...unsorted]);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
});
|
|
577
575
|
}
|
|
578
576
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
577
|
+
private _constructNode = (node: Omit<Node, typeof graphSymbol>) => {
|
|
578
|
+
return create<NodeInternal>({ ...node, [graphSymbol]: this });
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
private _getNodes({
|
|
582
|
+
node,
|
|
583
|
+
relation = 'outbound',
|
|
584
|
+
type,
|
|
585
|
+
expansion,
|
|
586
|
+
}: {
|
|
587
|
+
node: Node;
|
|
588
|
+
relation?: Relation;
|
|
589
|
+
type?: string;
|
|
590
|
+
expansion?: boolean;
|
|
591
|
+
}): Node[] {
|
|
592
|
+
if (expansion) {
|
|
593
|
+
void this.expand(node, relation, type);
|
|
586
594
|
}
|
|
587
595
|
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/** @internal */
|
|
600
|
-
_constructNode(node: NodeArg<any>): Option.Option<Node> {
|
|
601
|
-
return Option.some({ [graphSymbol]: this, data: null, properties: {}, ...node });
|
|
596
|
+
const edges = this._edges[node.id];
|
|
597
|
+
if (!edges) {
|
|
598
|
+
return [];
|
|
599
|
+
} else {
|
|
600
|
+
return edges[relation]
|
|
601
|
+
.map((id) => this._nodes[id])
|
|
602
|
+
.filter(isNonNullable)
|
|
603
|
+
.filter((n) => !type || n.type === type);
|
|
604
|
+
}
|
|
602
605
|
}
|
|
603
606
|
}
|