@truedat/lm 5.13.1 → 5.13.3
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/package.json +3 -3
- package/src/components/RelationGraphDepth.js +2 -2
- package/src/components/__tests__/ImplementationRelationForm.spec.js +6 -3
- package/src/components/__tests__/StructureRelationForm.spec.js +6 -3
- package/src/selectors/index.js +1 -1
- package/src/services/__tests__/relationGraphTraversal.spec.js +388 -0
- package/src/services/relationGraphTraversal.js +134 -34
- package/src/styles/relationGraph.less +17 -7
- package/src/selectors/__tests__/relationGraphTraversal.spec.js +0 -188
- package/src/selectors/relationGraphTraversal.js +0 -113
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@truedat/lm",
|
|
3
|
-
"version": "5.13.
|
|
3
|
+
"version": "5.13.3",
|
|
4
4
|
"description": "Truedat Link Manager",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"jsnext:main": "src/index.js",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
]
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@truedat/core": "5.13.
|
|
89
|
+
"@truedat/core": "5.13.3",
|
|
90
90
|
"path-to-regexp": "^1.7.0",
|
|
91
91
|
"prop-types": "^15.8.1",
|
|
92
92
|
"react-graph-vis": "1.0.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"react-dom": ">= 16.8.6 < 17",
|
|
108
108
|
"semantic-ui-react": ">= 2.0.3 < 2.2"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "93c1576f82fedd1d9b607cd0c9053279763877bc"
|
|
111
111
|
}
|
|
@@ -8,7 +8,7 @@ import { FormattedMessage } from "react-intl";
|
|
|
8
8
|
|
|
9
9
|
export const RelationGraphDepth = ({ onChange, depth, maxDepth }) => {
|
|
10
10
|
return _.isUndefined(maxDepth) ? null : (
|
|
11
|
-
<Segment className=
|
|
11
|
+
<Segment className={`graph-depth ${maxDepth == 0 ? "disabled" : ""}`}>
|
|
12
12
|
<FormattedMessage id="relationGraph.depth" />
|
|
13
13
|
<div>{depth}</div>
|
|
14
14
|
<Slider
|
|
@@ -16,7 +16,7 @@ export const RelationGraphDepth = ({ onChange, depth, maxDepth }) => {
|
|
|
16
16
|
orientation="vertical"
|
|
17
17
|
reverse
|
|
18
18
|
onChange={onChange}
|
|
19
|
-
min={
|
|
19
|
+
min={0}
|
|
20
20
|
max={maxDepth}
|
|
21
21
|
tooltip={false}
|
|
22
22
|
/>
|
|
@@ -80,9 +80,12 @@ describe("<ImplementationRelationForm />", () => {
|
|
|
80
80
|
renderOpts
|
|
81
81
|
);
|
|
82
82
|
|
|
83
|
-
await waitFor(
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
await waitFor(
|
|
84
|
+
() => {
|
|
85
|
+
expect(getByText(/relates_to/)).toBeInTheDocument();
|
|
86
|
+
},
|
|
87
|
+
{ timeout: 60000 }
|
|
88
|
+
);
|
|
86
89
|
expect(container).toMatchSnapshot();
|
|
87
90
|
});
|
|
88
91
|
|
|
@@ -85,9 +85,12 @@ describe("<StructureRelationForm />", () => {
|
|
|
85
85
|
renderOpts
|
|
86
86
|
);
|
|
87
87
|
|
|
88
|
-
await waitFor(
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
await waitFor(
|
|
89
|
+
() => {
|
|
90
|
+
expect(getByText(/relates_to/)).toBeInTheDocument();
|
|
91
|
+
},
|
|
92
|
+
{ timeout: 60000 }
|
|
93
|
+
);
|
|
91
94
|
|
|
92
95
|
expect(container).toMatchSnapshot();
|
|
93
96
|
});
|
package/src/selectors/index.js
CHANGED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import _ from "lodash/fp";
|
|
2
|
+
import { findMaxDepth, EMPTY, pruneGraph } from "../relationGraphTraversal";
|
|
3
|
+
|
|
4
|
+
const nodes = [
|
|
5
|
+
{
|
|
6
|
+
id: "character:taiga",
|
|
7
|
+
name: "Taiga",
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: "character:ryuuji",
|
|
11
|
+
name: "Ryuuji",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "character:minori",
|
|
15
|
+
name: "Minori",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "character:ami",
|
|
19
|
+
name: "Ami",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "character:yuusaku",
|
|
23
|
+
name: "Yuusaku",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "character:yasuko",
|
|
27
|
+
name: "Yasuko",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "character:sumire",
|
|
31
|
+
name: "Sumire",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "character:maya",
|
|
35
|
+
name: "Maya",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "character:non_connected",
|
|
39
|
+
name: "unrelated",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const tag_suki = {
|
|
44
|
+
id: "suki",
|
|
45
|
+
value: {
|
|
46
|
+
target_type: "character",
|
|
47
|
+
type: "loves",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const tag_shinyuu = {
|
|
52
|
+
id: "shinyuu",
|
|
53
|
+
value: {
|
|
54
|
+
target_type: "character",
|
|
55
|
+
type: "close_friend",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const tag_seitokai = {
|
|
60
|
+
id: "seitokai",
|
|
61
|
+
value: {
|
|
62
|
+
target_type: "character",
|
|
63
|
+
type: "student_council",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tag_sonkei = {
|
|
68
|
+
id: "sonkei",
|
|
69
|
+
value: {
|
|
70
|
+
target_type: "character",
|
|
71
|
+
type: "respect",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tag_haha = {
|
|
76
|
+
id: "haha",
|
|
77
|
+
value: {
|
|
78
|
+
target_type: "character",
|
|
79
|
+
type: "mother",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const tag_musuko = {
|
|
84
|
+
id: "musuko",
|
|
85
|
+
value: {
|
|
86
|
+
target_type: "character",
|
|
87
|
+
type: "son",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const tag_osananajimi = {
|
|
92
|
+
id: "osananajimi",
|
|
93
|
+
value: {
|
|
94
|
+
target_type: "character",
|
|
95
|
+
type: "childhood_friend",
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const edges = [
|
|
100
|
+
{
|
|
101
|
+
id: "taiga_to_yuusaku",
|
|
102
|
+
source_id: "character:taiga",
|
|
103
|
+
target_id: "character:yuusaku",
|
|
104
|
+
tags: [tag_suki],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "yuusaku_to_taiga",
|
|
108
|
+
source_id: "character:yuusaku",
|
|
109
|
+
target_id: "character:taiga",
|
|
110
|
+
tags: [],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "taiga_to_minori",
|
|
114
|
+
source_id: "character:taiga",
|
|
115
|
+
target_id: "character:minori",
|
|
116
|
+
tags: [tag_shinyuu],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "minori_to_taiga",
|
|
120
|
+
source_id: "character:minori",
|
|
121
|
+
target_id: "character:taiga",
|
|
122
|
+
tags: [tag_shinyuu],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "taiga_to_ryuuji",
|
|
126
|
+
source_id: "character:taiga",
|
|
127
|
+
target_id: "character:ryuuji",
|
|
128
|
+
tags: [],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "ryuuji_to_taiga",
|
|
132
|
+
source_id: "character:ryuuji",
|
|
133
|
+
target_id: "character:taiga",
|
|
134
|
+
tags: [],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "taiga_to_ami",
|
|
138
|
+
source_id: "character:taiga",
|
|
139
|
+
target_id: "character:ami",
|
|
140
|
+
tags: [],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "ami_to_taiga",
|
|
144
|
+
source_id: "character:ami",
|
|
145
|
+
target_id: "character:taiga",
|
|
146
|
+
tags: [],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "ryuuji_to_minori",
|
|
150
|
+
source_id: "character:ryuuji",
|
|
151
|
+
target_id: "character:minori",
|
|
152
|
+
tags: [tag_suki],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "minori_to_ryuuji",
|
|
156
|
+
source_id: "character:minori",
|
|
157
|
+
target_id: "character:ryuuji",
|
|
158
|
+
tags: [],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "ryuuji_to_yuusaku",
|
|
162
|
+
source_id: "character:ryuuji",
|
|
163
|
+
target_id: "character:yuusaku",
|
|
164
|
+
tags: [tag_shinyuu],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "yuusaku_to_ryuuji",
|
|
168
|
+
source_id: "character:yuusaku",
|
|
169
|
+
target_id: "character:ryuuji",
|
|
170
|
+
tags: [tag_shinyuu],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "ryuuji_to_yasuko",
|
|
174
|
+
source_id: "character:ryuuji",
|
|
175
|
+
target_id: "character:yasuko",
|
|
176
|
+
tags: [tag_haha],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "yasuko_to_ryuuji",
|
|
180
|
+
source_id: "character:yasuko",
|
|
181
|
+
target_id: "character:ryuuji",
|
|
182
|
+
tags: [tag_musuko],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "yuusaku_to_sumire",
|
|
186
|
+
source_id: "character:yuusaku",
|
|
187
|
+
target_id: "character:sumire",
|
|
188
|
+
tags: [tag_seitokai, tag_sonkei],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "sumire_to_yuusaku",
|
|
192
|
+
source_id: "character:sumire",
|
|
193
|
+
target_id: "character:yuusaku",
|
|
194
|
+
tags: [tag_seitokai],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "yuusaku_to_maya",
|
|
198
|
+
source_id: "character:yuusaku",
|
|
199
|
+
target_id: "character:maya",
|
|
200
|
+
tags: [],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "maya_to_yuusaku",
|
|
204
|
+
source_id: "character:maya",
|
|
205
|
+
target_id: "character:yuusaku",
|
|
206
|
+
tags: [tag_suki],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "yuusaku_to_ami",
|
|
210
|
+
source_id: "character:yuusaku",
|
|
211
|
+
target_id: "character:ami",
|
|
212
|
+
tags: [tag_osananajimi],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "ami_to_yuusaku",
|
|
216
|
+
source_id: "character:ami",
|
|
217
|
+
target_id: "character:yuusaku",
|
|
218
|
+
tags: [tag_osananajimi],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
const relationsGraph = {
|
|
223
|
+
nodes,
|
|
224
|
+
edges,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
describe("relationGraphTraversal", () => {
|
|
228
|
+
const traversalTags = [];
|
|
229
|
+
|
|
230
|
+
it("get graph max depth, starting from taiga, no limitations (all tags)", () => {
|
|
231
|
+
const currentId = "character:taiga";
|
|
232
|
+
expect(findMaxDepth(relationsGraph, currentId, traversalTags)).toBe(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("get graph max depth, starting from ami, no limitations (all tags)", () => {
|
|
236
|
+
const currentId = "character:ami";
|
|
237
|
+
expect(findMaxDepth(relationsGraph, currentId, traversalTags)).toBe(3);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("empty input graph", () => {
|
|
241
|
+
const currentId = "character:taiga";
|
|
242
|
+
const limitedGraph = pruneGraph({}, currentId, 3, traversalTags);
|
|
243
|
+
expect(limitedGraph).toStrictEqual({});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("get graph, no limitations (max depth, all tags)", () => {
|
|
247
|
+
const currentId = "character:taiga";
|
|
248
|
+
const limitedGraph = pruneGraph(
|
|
249
|
+
relationsGraph,
|
|
250
|
+
currentId,
|
|
251
|
+
2,
|
|
252
|
+
traversalTags
|
|
253
|
+
);
|
|
254
|
+
const { nodes: outputNodes, edges: outputEdges } = limitedGraph;
|
|
255
|
+
|
|
256
|
+
const inputNodeIds = _.map((node) => node.id, nodes);
|
|
257
|
+
const outputNodeIds = _.map((node) => node.id, outputNodes);
|
|
258
|
+
|
|
259
|
+
// excludes non connected nodes
|
|
260
|
+
expect(_.difference(inputNodeIds, outputNodeIds)).toStrictEqual([
|
|
261
|
+
"character:non_connected",
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const inputEdgeIds = _.map((edge) => edge.id, edges);
|
|
265
|
+
const outputEdgeIds = _.map((edge) => edge.id, outputEdges);
|
|
266
|
+
|
|
267
|
+
expect(_.difference(inputEdgeIds, outputEdgeIds)).toStrictEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("get graph, limit depth to 1, all tags", () => {
|
|
271
|
+
const currentId = "character:taiga";
|
|
272
|
+
const limitedGraph = pruneGraph(
|
|
273
|
+
relationsGraph,
|
|
274
|
+
currentId,
|
|
275
|
+
1,
|
|
276
|
+
traversalTags
|
|
277
|
+
);
|
|
278
|
+
const { nodes: outputNodes, edges: outputEdges } = limitedGraph;
|
|
279
|
+
const outputNodeIds = new Set(_.map((node) => node.id, outputNodes));
|
|
280
|
+
const expectedNodeIds = new Set([
|
|
281
|
+
"character:taiga",
|
|
282
|
+
"character:ryuuji",
|
|
283
|
+
"character:minori",
|
|
284
|
+
"character:ami",
|
|
285
|
+
"character:yuusaku",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
expect(_.isEqual(outputNodeIds, expectedNodeIds)).toBeTruthy();
|
|
289
|
+
|
|
290
|
+
const outputEdgeIds = new Set(_.map((edge) => edge.id, outputEdges));
|
|
291
|
+
const expectedEdgeIds = new Set([
|
|
292
|
+
// Directly reachable edges
|
|
293
|
+
"taiga_to_yuusaku",
|
|
294
|
+
"yuusaku_to_taiga",
|
|
295
|
+
"taiga_to_minori",
|
|
296
|
+
"minori_to_taiga",
|
|
297
|
+
"taiga_to_ryuuji",
|
|
298
|
+
"ryuuji_to_taiga",
|
|
299
|
+
"taiga_to_ami",
|
|
300
|
+
"ami_to_taiga",
|
|
301
|
+
/* Indirectly reachable edges
|
|
302
|
+
* (are shown even though depth first search has not actually traversed
|
|
303
|
+
* through them, to avoid possible user confusion thinking they
|
|
304
|
+
* do not exist).
|
|
305
|
+
*/
|
|
306
|
+
"ryuuji_to_minori",
|
|
307
|
+
"minori_to_ryuuji",
|
|
308
|
+
"ami_to_yuusaku",
|
|
309
|
+
"yuusaku_to_ami",
|
|
310
|
+
"ryuuji_to_yuusaku",
|
|
311
|
+
"yuusaku_to_ryuuji",
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
expect(_.isEqual(outputEdgeIds, expectedEdgeIds)).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("get graph, limit depth to 2, with traversal tags", () => {
|
|
318
|
+
const currentId = "character:taiga";
|
|
319
|
+
const limitedGraph = pruneGraph(relationsGraph, currentId, 2, [
|
|
320
|
+
"suki",
|
|
321
|
+
"sonkei",
|
|
322
|
+
]);
|
|
323
|
+
const { nodes: outputNodes, edges: outputEdges } = limitedGraph;
|
|
324
|
+
const outputNodeIds = new Set(_.map((node) => node.id, outputNodes));
|
|
325
|
+
const expectedNodeIds = new Set([
|
|
326
|
+
"character:taiga",
|
|
327
|
+
"character:yuusaku",
|
|
328
|
+
"character:sumire",
|
|
329
|
+
"character:maya",
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
expect(_.isEqual(outputNodeIds, expectedNodeIds)).toBeTruthy();
|
|
333
|
+
|
|
334
|
+
const outputEdgeIds = new Set(_.map((edge) => edge.id, outputEdges));
|
|
335
|
+
const expectedEdgeIds = new Set([
|
|
336
|
+
"taiga_to_yuusaku",
|
|
337
|
+
"yuusaku_to_sumire",
|
|
338
|
+
"maya_to_yuusaku",
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
expect(_.isEqual(outputEdgeIds, expectedEdgeIds)).toBeTruthy();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("EMPTY tag", () => {
|
|
345
|
+
const currentId = "character:taiga";
|
|
346
|
+
const limitedGraph = pruneGraph(relationsGraph, currentId, 2, [EMPTY]);
|
|
347
|
+
const { nodes: outputNodes, edges: outputEdges } = limitedGraph;
|
|
348
|
+
const outputNodeIds = new Set(_.map((node) => node.id, outputNodes));
|
|
349
|
+
const expectedNodeIds = new Set([
|
|
350
|
+
"character:taiga",
|
|
351
|
+
"character:ryuuji",
|
|
352
|
+
"character:ami",
|
|
353
|
+
"character:maya",
|
|
354
|
+
"character:minori",
|
|
355
|
+
"character:yuusaku",
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
expect(_.isEqual(outputNodeIds, expectedNodeIds)).toBeTruthy();
|
|
359
|
+
|
|
360
|
+
const outputEdgeIds = new Set(_.map((edge) => edge.id, outputEdges));
|
|
361
|
+
const expectedEdgeIds = new Set([
|
|
362
|
+
"taiga_to_ryuuji",
|
|
363
|
+
"ryuuji_to_taiga",
|
|
364
|
+
"taiga_to_ami",
|
|
365
|
+
"ami_to_taiga",
|
|
366
|
+
"yuusaku_to_taiga",
|
|
367
|
+
"minori_to_ryuuji",
|
|
368
|
+
"yuusaku_to_maya",
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
expect(_.isEqual(outputEdgeIds, expectedEdgeIds)).toBeTruthy();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("unreachable tag", () => {
|
|
375
|
+
const currentId = "character:taiga";
|
|
376
|
+
const limitedGraph = pruneGraph(relationsGraph, currentId, 2, ["sonkei"]);
|
|
377
|
+
const { nodes: outputNodes, edges: outputEdges } = limitedGraph;
|
|
378
|
+
const outputNodeIds = new Set(_.map((node) => node.id, outputNodes));
|
|
379
|
+
const expectedNodeIds = new Set(["character:taiga"]);
|
|
380
|
+
|
|
381
|
+
expect(_.isEqual(outputNodeIds, expectedNodeIds)).toBeTruthy();
|
|
382
|
+
|
|
383
|
+
const outputEdgeIds = new Set(_.map((edge) => edge.id, outputEdges));
|
|
384
|
+
const expectedEdgeIds = new Set([]);
|
|
385
|
+
|
|
386
|
+
expect(_.isEqual(outputEdgeIds, expectedEdgeIds)).toBeTruthy();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -1,31 +1,117 @@
|
|
|
1
|
+
/* eslint-disable fp/no-mutation */
|
|
2
|
+
/* eslint-disable fp/no-let */
|
|
1
3
|
import _ from "lodash/fp";
|
|
4
|
+
import { defaultMemoize } from "reselect";
|
|
2
5
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
export const EMPTY = "_EMPTY";
|
|
7
|
+
|
|
8
|
+
const currentIdToNode = (graph, currentId) =>
|
|
9
|
+
_.flow(_.getOr([], "nodes"), _.find({ id: currentId }))(graph) || {};
|
|
10
|
+
|
|
11
|
+
const equalityCheck = (previousVal, currentVal) =>
|
|
12
|
+
_.isArray(previousVal) && _.isArray(currentVal) // traversalTags uses arrays
|
|
13
|
+
? _.isEqual(previousVal.sort(), currentVal.sort())
|
|
14
|
+
: previousVal === currentVal;
|
|
15
|
+
|
|
16
|
+
export const findMaxDepth = defaultMemoize(
|
|
17
|
+
(graph, currentId, traversalTags) => {
|
|
18
|
+
const start = currentIdToNode(graph, currentId);
|
|
19
|
+
return bfsMaxDepth(graph, start, traversalTags);
|
|
20
|
+
},
|
|
21
|
+
{ maxSize: 10, equalityCheck }
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const pruneGraph = defaultMemoize(
|
|
25
|
+
(graph, currentId, depth, traversalTags) => {
|
|
26
|
+
const start = currentIdToNode(graph, currentId);
|
|
27
|
+
const maxDepth = findMaxDepth(graph, currentId, traversalTags);
|
|
28
|
+
|
|
29
|
+
if (!_.isEmpty(graph) && !_.isEmpty(start)) {
|
|
30
|
+
const nodes = dfs(
|
|
31
|
+
graph,
|
|
32
|
+
start,
|
|
33
|
+
{},
|
|
34
|
+
depth < maxDepth ? depth : maxDepth,
|
|
35
|
+
traversalTags
|
|
36
|
+
);
|
|
37
|
+
return {
|
|
38
|
+
nodes,
|
|
39
|
+
/* This will show both directly and indirectly reachable edges
|
|
40
|
+
* Indirectly reachable edges are shown even though depth first search
|
|
41
|
+
* has not actually traversed through them. Could avoid by accumulating
|
|
42
|
+
* edges while traversing. However, I prefer to display them to avoid
|
|
43
|
+
* possible user confusion thinking they do not exist.
|
|
44
|
+
* See test for more details
|
|
45
|
+
*/
|
|
46
|
+
edges: filterEdges(graph, nodes, traversalTags),
|
|
47
|
+
};
|
|
48
|
+
} else {
|
|
49
|
+
return graph;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{ maxSize: 10, equalityCheck }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const anyTraversalTags = (traversalTagIds, tags) => {
|
|
56
|
+
const tagIds = _.map((tag) => tag.id, tags);
|
|
57
|
+
return (
|
|
58
|
+
_.isEmpty(traversalTagIds) || // If no tags specified, any tag is accepted
|
|
59
|
+
(traversalTagIds.includes(EMPTY) && _.isEmpty(tagIds)) ||
|
|
60
|
+
!_.isEmpty(_.intersection(traversalTagIds, tagIds))
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const findNeighbours = (relationsGraph, start, traversalTags) => {
|
|
65
|
+
const inNeighbours = _.flow(
|
|
66
|
+
_.filter(
|
|
67
|
+
(edge) =>
|
|
68
|
+
edge.target_id === start.id &&
|
|
69
|
+
anyTraversalTags(traversalTags, edge.tags)
|
|
70
|
+
),
|
|
71
|
+
_.flatMap((edge) => _.find({ id: edge.source_id }, relationsGraph.nodes))
|
|
72
|
+
);
|
|
73
|
+
const outNeighbours = _.flow(
|
|
74
|
+
_.filter(
|
|
75
|
+
(edge) =>
|
|
76
|
+
edge.source_id === start.id &&
|
|
77
|
+
anyTraversalTags(traversalTags, edge.tags)
|
|
78
|
+
),
|
|
79
|
+
_.flatMap((edge) => _.find({ id: edge.target_id }, relationsGraph.nodes))
|
|
15
80
|
);
|
|
81
|
+
|
|
82
|
+
return _.flow(
|
|
83
|
+
(edges) => [...inNeighbours(edges), ...outNeighbours(edges)],
|
|
84
|
+
_.uniq
|
|
85
|
+
)(relationsGraph.edges);
|
|
16
86
|
};
|
|
17
87
|
|
|
18
|
-
export const
|
|
88
|
+
export const filterEdges = (relationsGraph, nodes, traversalTags) => {
|
|
19
89
|
const nodeIds = Object.keys(nodes);
|
|
20
|
-
|
|
90
|
+
const edges = _.filter(
|
|
21
91
|
(edge) =>
|
|
22
|
-
nodeIds.includes(edge.source_id) &&
|
|
92
|
+
nodeIds.includes(edge.source_id) &&
|
|
93
|
+
nodeIds.includes(edge.target_id) &&
|
|
94
|
+
anyTraversalTags(traversalTags, edge.tags),
|
|
23
95
|
relationsGraph.edges
|
|
24
96
|
);
|
|
97
|
+
return edges;
|
|
25
98
|
};
|
|
26
99
|
|
|
27
|
-
export const
|
|
28
|
-
|
|
100
|
+
export const graphDistinctTags = ({ edges }) =>
|
|
101
|
+
_.flow(
|
|
102
|
+
_.flatMap((edge) => edge?.tags),
|
|
103
|
+
_.uniqBy((tag) => tag?.id)
|
|
104
|
+
)(edges);
|
|
105
|
+
|
|
106
|
+
export const dfs = (
|
|
107
|
+
relationsGraph,
|
|
108
|
+
start,
|
|
109
|
+
visited,
|
|
110
|
+
maxDepth,
|
|
111
|
+
traversalTags
|
|
112
|
+
) => {
|
|
113
|
+
// Depth range: [0, maxDepth]
|
|
114
|
+
if (Object.keys(visited).includes(start.id) || maxDepth === -1) return {};
|
|
29
115
|
|
|
30
116
|
return _.reduce(
|
|
31
117
|
(acc, neighbour) => ({
|
|
@@ -34,27 +120,41 @@ export const dfs = (relationsGraph, start, visited, maxDepth) => {
|
|
|
34
120
|
relationsGraph,
|
|
35
121
|
neighbour,
|
|
36
122
|
{ ...visited, [start.id]: start },
|
|
37
|
-
maxDepth - 1
|
|
123
|
+
maxDepth - 1,
|
|
124
|
+
traversalTags
|
|
38
125
|
),
|
|
39
126
|
}),
|
|
40
127
|
{ [start.id]: start }
|
|
41
|
-
)(
|
|
128
|
+
)(findNeighbours(relationsGraph, start, traversalTags));
|
|
42
129
|
};
|
|
43
130
|
|
|
44
|
-
|
|
45
|
-
|
|
131
|
+
/*
|
|
132
|
+
* Shortest Path (Unweighted Graph)
|
|
133
|
+
* If the graph is unweighed, then finding the shortest path is easy: we can
|
|
134
|
+
* use the breadth-first search algorithm. For a weighted graph, we can use Dijkstra's algorithm.
|
|
135
|
+
* https://aquarchitect.github.io/swift-algorithm-club/Shortest%20Path%20%28Unweighted%29/
|
|
136
|
+
*/
|
|
137
|
+
const bfsMaxDepth = (relationsGraph, start, traversalTags) => {
|
|
138
|
+
let queue = [];
|
|
139
|
+
let nodesDistance = {};
|
|
46
140
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
141
|
+
queue.push(start);
|
|
142
|
+
nodesDistance[start.id] = 0; // Depth range: [0, maxDepth]
|
|
143
|
+
|
|
144
|
+
let current = start;
|
|
145
|
+
while (!_.isEmpty(current)) {
|
|
146
|
+
current = queue.shift() || {};
|
|
147
|
+
for (let neighbour of findNeighbours(
|
|
148
|
+
relationsGraph,
|
|
149
|
+
current,
|
|
150
|
+
traversalTags
|
|
151
|
+
)) {
|
|
152
|
+
if (!(neighbour.id in nodesDistance)) {
|
|
153
|
+
queue.push(neighbour);
|
|
154
|
+
nodesDistance[neighbour.id] = nodesDistance[current.id] + 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return Math.max(...Object.values(nodesDistance));
|
|
60
160
|
};
|
|
@@ -2,20 +2,30 @@
|
|
|
2
2
|
position: absolute !important;
|
|
3
3
|
z-index: 1;
|
|
4
4
|
margin-right: 0px;
|
|
5
|
+
right: 14px;
|
|
5
6
|
|
|
6
7
|
div {
|
|
7
8
|
text-align: center;
|
|
8
9
|
font-weight: bold;
|
|
9
10
|
}
|
|
10
|
-
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
&.disabled {
|
|
13
|
+
pointer-events: none;
|
|
14
|
+
opacity: 0.7;
|
|
15
|
+
|
|
16
|
+
.rangeslider-vertical .rangeslider__fill {
|
|
17
|
+
background-color: gray;
|
|
18
|
+
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
.
|
|
19
|
-
|
|
21
|
+
.rangeslider-vertical {
|
|
22
|
+
.rangeslider__fill {
|
|
23
|
+
background-color: #ed5c17;
|
|
24
|
+
border: 1px solid gray;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.rangeslider__handle {
|
|
28
|
+
border-radius: 4px;
|
|
29
|
+
}
|
|
20
30
|
}
|
|
21
31
|
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import _ from "lodash/fp";
|
|
2
|
-
import { selectLimitedGraph } from "..";
|
|
3
|
-
import { selectMaxDepth } from "../relationGraphTraversal";
|
|
4
|
-
|
|
5
|
-
const nodes = [
|
|
6
|
-
{
|
|
7
|
-
id: "business_concept:92",
|
|
8
|
-
name: "My Awesome Concept",
|
|
9
|
-
},
|
|
10
|
-
{
|
|
11
|
-
id: "business_concept:5",
|
|
12
|
-
name: "New Concept 4",
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
id: "business_concept:18",
|
|
16
|
-
name: "Float Concept",
|
|
17
|
-
},
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const edges = [
|
|
21
|
-
{
|
|
22
|
-
id: 1,
|
|
23
|
-
source_id: "business_concept:92",
|
|
24
|
-
target_id: "business_concept:5",
|
|
25
|
-
tags: [
|
|
26
|
-
{
|
|
27
|
-
id: 3,
|
|
28
|
-
value: {
|
|
29
|
-
target_type: "business_concept",
|
|
30
|
-
type: "bc_padre",
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
],
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: 2,
|
|
37
|
-
source_id: "business_concept:5",
|
|
38
|
-
target_id: "business_concept:18",
|
|
39
|
-
tags: [
|
|
40
|
-
{
|
|
41
|
-
id: 3,
|
|
42
|
-
value: {
|
|
43
|
-
target_type: "business_concept",
|
|
44
|
-
type: "bc_padre",
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
id: 3,
|
|
51
|
-
source_id: "business_concept:18",
|
|
52
|
-
target_id: "business_concept:5",
|
|
53
|
-
tags: [
|
|
54
|
-
{
|
|
55
|
-
id: 3,
|
|
56
|
-
value: {
|
|
57
|
-
target_type: "business_concept",
|
|
58
|
-
type: "bc_padre",
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
},
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
const relationsGraph = {
|
|
66
|
-
nodes,
|
|
67
|
-
edges,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
describe("selectors: relationGraphTraversal", () => {
|
|
71
|
-
const currentId = "business_concept:92";
|
|
72
|
-
|
|
73
|
-
it("gets Graph on non-empty relationsGraph", () => {
|
|
74
|
-
const limitedGraph = selectLimitedGraph({ relationsGraph }, currentId, 3);
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
_.isUndefined(
|
|
78
|
-
_.find({ id: nodes[0].id, name: nodes[0].name }, limitedGraph.nodes)
|
|
79
|
-
) &&
|
|
80
|
-
_.isUndefined(
|
|
81
|
-
_.find({ id: nodes[1].id, name: nodes[1].name }, limitedGraph.nodes)
|
|
82
|
-
) &&
|
|
83
|
-
_.isUndefined(
|
|
84
|
-
_.find({ id: nodes[2].id, name: nodes[2].name }, limitedGraph.nodes)
|
|
85
|
-
)
|
|
86
|
-
).toBeFalsy();
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
_.isUndefined(
|
|
90
|
-
_.find(
|
|
91
|
-
{
|
|
92
|
-
source_id: edges[0].source_id,
|
|
93
|
-
target_id: edges[0].target_id,
|
|
94
|
-
},
|
|
95
|
-
limitedGraph.edges
|
|
96
|
-
)
|
|
97
|
-
) &&
|
|
98
|
-
_.isUndefined(
|
|
99
|
-
_.find(
|
|
100
|
-
{
|
|
101
|
-
source_id: edges[1].source_id,
|
|
102
|
-
target_id: edges[1].target_id,
|
|
103
|
-
},
|
|
104
|
-
limitedGraph.edges
|
|
105
|
-
)
|
|
106
|
-
) &&
|
|
107
|
-
_.isUndefined(
|
|
108
|
-
_.find(
|
|
109
|
-
{
|
|
110
|
-
source_id: edges[2].source_id,
|
|
111
|
-
target_id: edges[2].target_id,
|
|
112
|
-
},
|
|
113
|
-
limitedGraph.edges
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
).toBeFalsy();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("gets Graph on empty relationsGraph", () => {
|
|
120
|
-
const limitedGraph = selectLimitedGraph(
|
|
121
|
-
{ relationsGraph: {} },
|
|
122
|
-
currentId,
|
|
123
|
-
3
|
|
124
|
-
);
|
|
125
|
-
expect(limitedGraph).toStrictEqual({});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("gets depth limited graph", () => {
|
|
129
|
-
const limitedGraph = selectLimitedGraph({ relationsGraph }, currentId, 2);
|
|
130
|
-
|
|
131
|
-
expect(
|
|
132
|
-
_.isUndefined(
|
|
133
|
-
_.find({ id: nodes[0].id, name: nodes[0].name }, limitedGraph.nodes)
|
|
134
|
-
) &&
|
|
135
|
-
_.isUndefined(
|
|
136
|
-
_.find({ id: nodes[1].id, name: nodes[1].name }, limitedGraph.nodes)
|
|
137
|
-
)
|
|
138
|
-
).toBeFalsy();
|
|
139
|
-
|
|
140
|
-
expect(
|
|
141
|
-
_.isUndefined(
|
|
142
|
-
_.find({ id: nodes[2].id, name: nodes[2].name }, limitedGraph.nodes)
|
|
143
|
-
)
|
|
144
|
-
).toBeTruthy();
|
|
145
|
-
|
|
146
|
-
expect(
|
|
147
|
-
_.isUndefined(
|
|
148
|
-
_.find(
|
|
149
|
-
{
|
|
150
|
-
source_id: edges[0].source_id,
|
|
151
|
-
target_id: edges[0].target_id,
|
|
152
|
-
},
|
|
153
|
-
limitedGraph.edges
|
|
154
|
-
)
|
|
155
|
-
)
|
|
156
|
-
).toBeFalsy();
|
|
157
|
-
|
|
158
|
-
expect(
|
|
159
|
-
_.isUndefined(
|
|
160
|
-
_.find(
|
|
161
|
-
{
|
|
162
|
-
source_id: edges[1].source_id,
|
|
163
|
-
target_id: edges[1].target_id,
|
|
164
|
-
},
|
|
165
|
-
limitedGraph.edges
|
|
166
|
-
)
|
|
167
|
-
) &&
|
|
168
|
-
_.isUndefined(
|
|
169
|
-
_.find(
|
|
170
|
-
{
|
|
171
|
-
source_id: edges[2].source_id,
|
|
172
|
-
target_id: edges[2].target_id,
|
|
173
|
-
},
|
|
174
|
-
limitedGraph.edges
|
|
175
|
-
)
|
|
176
|
-
)
|
|
177
|
-
).toBeTruthy();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("get graph max depth", () => {
|
|
181
|
-
const start = {
|
|
182
|
-
id: "business_concept:92",
|
|
183
|
-
name: "My Awesome Concept",
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
expect(selectMaxDepth({ relationsGraph }, currentId, start)).toBe(3);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/* eslint-disable fp/no-mutation */
|
|
2
|
-
/* eslint-disable fp/no-let */
|
|
3
|
-
import _ from "lodash/fp";
|
|
4
|
-
import { createSelector } from "reselect";
|
|
5
|
-
|
|
6
|
-
const selectStart = createSelector(
|
|
7
|
-
(state) => state.relationsGraph,
|
|
8
|
-
(state, currentId) => currentId,
|
|
9
|
-
(relationsGraph, currentId) => {
|
|
10
|
-
return (
|
|
11
|
-
_.flow(_.getOr([], "nodes"), _.find({ id: currentId }))(relationsGraph) ||
|
|
12
|
-
{}
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
export const selectMaxDepth = createSelector(
|
|
18
|
-
(state) => state.relationsGraph,
|
|
19
|
-
(state, currentId) => selectStart(state, currentId),
|
|
20
|
-
(relationsGraph, start) => bfsMaxDepth(relationsGraph, start)
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
export const selectLimitedGraph = createSelector(
|
|
24
|
-
(state) => state.relationsGraph,
|
|
25
|
-
(state, currentId) => selectStart(state, currentId),
|
|
26
|
-
(state, currentId) => selectMaxDepth(state, currentId),
|
|
27
|
-
(state, currentId, depth) => depth,
|
|
28
|
-
(relationsGraph, start, maxDepth, depth) =>
|
|
29
|
-
getLimitedGraph(relationsGraph, start, maxDepth, depth)
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const getLimitedGraph = (relationsGraph, start, maxDepth, depth) => {
|
|
33
|
-
if (!_.isEmpty(relationsGraph) && !_.isEmpty(start) && depth !== Infinity) {
|
|
34
|
-
const nodes = dfs(relationsGraph, start, {}, depth);
|
|
35
|
-
return depth < maxDepth
|
|
36
|
-
? {
|
|
37
|
-
nodes,
|
|
38
|
-
edges: getEdges(relationsGraph, nodes),
|
|
39
|
-
}
|
|
40
|
-
: relationsGraph;
|
|
41
|
-
} else {
|
|
42
|
-
return relationsGraph;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const getNeighbours = (relationsGraph, start) => {
|
|
47
|
-
const neighbours = _.concat(
|
|
48
|
-
// Up
|
|
49
|
-
_.flow(
|
|
50
|
-
_.filter((edge) => edge.target_id === start.id),
|
|
51
|
-
_.flatMap((edge) => _.find({ id: edge.source_id }, relationsGraph.nodes))
|
|
52
|
-
)(relationsGraph.edges),
|
|
53
|
-
// Down
|
|
54
|
-
_.flow(
|
|
55
|
-
_.filter((edge) => edge.source_id === start.id),
|
|
56
|
-
_.flatMap((edge) => _.find({ id: edge.target_id }, relationsGraph.nodes))
|
|
57
|
-
)(relationsGraph.edges)
|
|
58
|
-
);
|
|
59
|
-
return neighbours;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export const getEdges = (relationsGraph, nodes) => {
|
|
63
|
-
const nodeIds = Object.keys(nodes);
|
|
64
|
-
return _.filter(
|
|
65
|
-
(edge) =>
|
|
66
|
-
nodeIds.includes(edge.source_id) && nodeIds.includes(edge.target_id),
|
|
67
|
-
relationsGraph.edges
|
|
68
|
-
);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export const dfs = (relationsGraph, start, visited, maxDepth) => {
|
|
72
|
-
if (Object.keys(visited).includes(start.id) || maxDepth === 0) return {};
|
|
73
|
-
|
|
74
|
-
return _.reduce(
|
|
75
|
-
(acc, neighbour) => ({
|
|
76
|
-
...acc,
|
|
77
|
-
...dfs(
|
|
78
|
-
relationsGraph,
|
|
79
|
-
neighbour,
|
|
80
|
-
{ ...visited, [start.id]: start },
|
|
81
|
-
maxDepth - 1
|
|
82
|
-
),
|
|
83
|
-
}),
|
|
84
|
-
{ [start.id]: start }
|
|
85
|
-
)(getNeighbours(relationsGraph, start));
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/*
|
|
89
|
-
* Shortest Path (Unweighted Graph)
|
|
90
|
-
* If the graph is unweighed, then finding the shortest path is easy: we can
|
|
91
|
-
* use the breadth-first search algorithm. For a weighted graph, we can use Dijkstra's algorithm.
|
|
92
|
-
* https://aquarchitect.github.io/swift-algorithm-club/Shortest%20Path%20%28Unweighted%29/
|
|
93
|
-
*/
|
|
94
|
-
const bfsMaxDepth = (relationsGraph, start) => {
|
|
95
|
-
let queue = [];
|
|
96
|
-
let nodesDistance = {};
|
|
97
|
-
|
|
98
|
-
queue.push(start);
|
|
99
|
-
nodesDistance[start.id] = 1;
|
|
100
|
-
|
|
101
|
-
let current = start;
|
|
102
|
-
while (!_.isEmpty(current)) {
|
|
103
|
-
current = queue.shift() || {};
|
|
104
|
-
for (let neighbour of getNeighbours(relationsGraph, current)) {
|
|
105
|
-
if (!nodesDistance[neighbour.id]) {
|
|
106
|
-
queue.push(neighbour);
|
|
107
|
-
nodesDistance[neighbour.id] = nodesDistance[current.id] + 1;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return Math.max(...Object.values(nodesDistance));
|
|
113
|
-
};
|