@truedat/dd 8.11.6 → 8.11.9

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.
Files changed (43) hide show
  1. package/package.json +4 -3
  2. package/src/components/Graphs.js +30 -4
  3. package/src/components/LineageContextMenu.js +185 -58
  4. package/src/components/LineageDownloadButton.js +16 -6
  5. package/src/components/LineageEventsPanel.js +80 -27
  6. package/src/components/LineageEventsTable.js +7 -3
  7. package/src/components/LineageGraph.js +5166 -306
  8. package/src/components/LineageGraphToolbar.js +896 -0
  9. package/src/components/LineageInspectorPanel.js +748 -0
  10. package/src/components/LineageResourceNode.js +501 -0
  11. package/src/components/LineageRoutes.js +1 -2
  12. package/src/components/LineageSelectionPanel.js +28 -12
  13. package/src/components/StructureGraph.js +8 -1
  14. package/src/components/StructureGraphView.js +14 -0
  15. package/src/components/StructureImpact.js +2 -22
  16. package/src/components/StructureLineage.js +2 -19
  17. package/src/components/StructuresUploadEventsTable.js +0 -3
  18. package/src/components/__tests__/LineageContextMenu.spec.js +42 -0
  19. package/src/components/__tests__/LineageGraph.spec.js +896 -0
  20. package/src/components/__tests__/LineageGraphLayout.spec.js +639 -0
  21. package/src/components/__tests__/LineageGraphToolbar.spec.js +366 -0
  22. package/src/components/__tests__/LineageHierarchy.spec.js +116 -0
  23. package/src/components/__tests__/LineageInspectorPanel.spec.js +364 -0
  24. package/src/components/__tests__/LineageResourceNode.spec.js +338 -0
  25. package/src/components/__tests__/LineageRoutes.spec.js +27 -9
  26. package/src/components/__tests__/__snapshots__/LineageDownloadButton.spec.js.snap +23 -3
  27. package/src/components/__tests__/__snapshots__/LineageForm.spec.js.snap +23 -3
  28. package/src/components/__tests__/lineageEventHelpers.spec.js +60 -0
  29. package/src/components/lineageEventHelpers.js +53 -17
  30. package/src/reducers/__tests__/lineageEvents.spec.js +164 -0
  31. package/src/reducers/__tests__/lineageQuery.spec.js +11 -0
  32. package/src/reducers/lineageEvents.js +68 -2
  33. package/src/reducers/lineageQuery.js +2 -6
  34. package/src/styles/LineageGraph.less +4094 -0
  35. package/src/styles/LineageGraphColors.less +105 -0
  36. package/src/styles/LineageGraphCore.less +20 -0
  37. package/src/styles/LineageResourceNode.less +711 -0
  38. package/src/utils/__tests__/lineageGraphIndex.spec.js +52 -0
  39. package/src/utils/__tests__/lineageLayout.spec.js +252 -0
  40. package/src/utils/__tests__/lineageTopology.spec.js +256 -0
  41. package/src/utils/lineageGraphIndex.js +296 -0
  42. package/src/utils/lineageLayout.js +1050 -0
  43. package/src/styles/lineageGraph.less +0 -1236
@@ -0,0 +1,639 @@
1
+ import {
2
+ applyFieldHandlesToEdges,
3
+ collapseLineageNodes,
4
+ bundleLineageEdges,
5
+ lineageEdge,
6
+ lineageCollapsedGroupIdsForDepth,
7
+ lineageMaximumExpansionDepth,
8
+ planLineageEdgeRoutes,
9
+ redesignedLineageEdges,
10
+ } from "../LineageGraph";
11
+ import { layoutVisibleLineageGraph } from "../../utils/lineageLayout";
12
+
13
+ const groupNode = (id, x, y, w, h, options = {}) => ({
14
+ id,
15
+ type: "lineageGroup",
16
+ width: w,
17
+ height: h,
18
+ z: options.z,
19
+ position: { x, y },
20
+ data: {
21
+ absoluteGeometry: { x, y, w, h },
22
+ collapsed: false,
23
+ variant: "absolute",
24
+ },
25
+ });
26
+
27
+ const resourceNode = (id, parent, x, y, w, h, options = {}) => {
28
+ const parentGeometry = parent.data.absoluteGeometry;
29
+
30
+ return {
31
+ id,
32
+ type: "lineageResource",
33
+ parentId: parent.id,
34
+ width: w,
35
+ height: h,
36
+ z: options.z,
37
+ position: { x, y },
38
+ data: {
39
+ absoluteGeometry: {
40
+ x: parentGeometry.x + x,
41
+ y: parentGeometry.y + y,
42
+ w,
43
+ h,
44
+ },
45
+ },
46
+ };
47
+ };
48
+
49
+ const childGroupNode = (id, parent, x, y, w, h, options = {}) => ({
50
+ ...groupNode(
51
+ id,
52
+ parent.data.absoluteGeometry.x + x,
53
+ parent.data.absoluteGeometry.y + y,
54
+ w,
55
+ h,
56
+ options,
57
+ ),
58
+ parentId: parent.id,
59
+ position: { x, y },
60
+ });
61
+
62
+ const standaloneResourceNode = (id, x = 0, y = 0, options = {}) => ({
63
+ id,
64
+ type: "lineageResource",
65
+ width: options.width || 288,
66
+ height: options.height || 80,
67
+ position: { x, y },
68
+ data: {
69
+ absoluteGeometry: {
70
+ x,
71
+ y,
72
+ w: options.width || 288,
73
+ h: options.height || 80,
74
+ },
75
+ label: options.label || id,
76
+ z: options.z,
77
+ },
78
+ });
79
+
80
+ const absoluteGeometryFor = (nodes, id) =>
81
+ nodes.find((node) => node.id === id).data.absoluteGeometry;
82
+
83
+ const overlaps = (geometryA, geometryB) =>
84
+ geometryA.x < geometryB.x + geometryB.w &&
85
+ geometryA.x + geometryA.w > geometryB.x &&
86
+ geometryA.y < geometryB.y + geometryB.h &&
87
+ geometryA.y + geometryA.h > geometryB.y;
88
+
89
+ describe("lineageCollapsedGroupIdsForDepth", () => {
90
+ it("uses zero as the base view and opens groups from level one", () => {
91
+ const level1 = groupNode("level-1", 0, 0, 300, 300);
92
+ const level2 = childGroupNode("level-2", level1, 10, 50, 260, 220);
93
+ const level3 = childGroupNode("level-3", level2, 10, 50, 220, 140);
94
+ const leaf = resourceNode("leaf", level3, 10, 50, 120, 60);
95
+ const nodes = [level1, level2, level3, leaf];
96
+
97
+ expect(lineageCollapsedGroupIdsForDepth(nodes, 0)).toEqual(
98
+ new Set(["level-1", "level-2", "level-3"]),
99
+ );
100
+ expect(lineageCollapsedGroupIdsForDepth(nodes, 1)).toEqual(
101
+ new Set(["level-2", "level-3"]),
102
+ );
103
+ expect(lineageCollapsedGroupIdsForDepth(nodes, 2)).toEqual(
104
+ new Set(["level-3"]),
105
+ );
106
+ expect(lineageCollapsedGroupIdsForDepth(nodes, 3)).toEqual(new Set());
107
+ expect(lineageMaximumExpansionDepth(nodes)).toBe(3);
108
+ });
109
+
110
+ it("derives the maximum only from collapsible graph groups", () => {
111
+ const level1 = groupNode("level-1", 0, 0, 300, 300);
112
+ const leaf = resourceNode("leaf", level1, 10, 50, 120, 60);
113
+ const deepLevels = [level1];
114
+
115
+ for (let depth = 2; depth <= 7; depth += 1) {
116
+ deepLevels.push(
117
+ childGroupNode(
118
+ `level-${depth}`,
119
+ deepLevels[deepLevels.length - 1],
120
+ 5,
121
+ 5,
122
+ 300 - depth * 10,
123
+ 300 - depth * 10,
124
+ ),
125
+ );
126
+ }
127
+ const deepLeaf = resourceNode(
128
+ "deep-leaf",
129
+ deepLevels[deepLevels.length - 1],
130
+ 5,
131
+ 5,
132
+ 80,
133
+ 40,
134
+ );
135
+
136
+ expect(lineageMaximumExpansionDepth([level1, leaf])).toBe(1);
137
+ expect(lineageMaximumExpansionDepth([leaf])).toBe(0);
138
+ expect(lineageMaximumExpansionDepth([...deepLevels, deepLeaf])).toBe(7);
139
+ });
140
+ });
141
+
142
+ describe("layoutVisibleLineageGraph", () => {
143
+ it("keeps a compact footer gap below expanded group contents", () => {
144
+ const group = groupNode("group", 0, 0, 360, 220);
145
+ const leaf = resourceNode("leaf", group, 20, 58, 288, 80);
146
+ const laid = layoutVisibleLineageGraph({ nodes: [group, leaf], edges: [] });
147
+ const laidGroup = absoluteGeometryFor(laid, "group");
148
+ const laidLeaf = absoluteGeometryFor(laid, "leaf");
149
+
150
+ expect(laidGroup.y + laidGroup.h - (laidLeaf.y + laidLeaf.h)).toBe(12);
151
+ });
152
+
153
+ it("orders impact chains from left to right", () => {
154
+ const nodes = [
155
+ standaloneResourceNode("source"),
156
+ standaloneResourceNode("transform"),
157
+ standaloneResourceNode("target"),
158
+ ];
159
+ const laid = layoutVisibleLineageGraph({
160
+ nodes,
161
+ edges: [
162
+ { id: "e1", source: "source", target: "transform" },
163
+ { id: "e2", source: "transform", target: "target" },
164
+ ],
165
+ graphType: "impact",
166
+ });
167
+
168
+ expect(absoluteGeometryFor(laid, "source").x).toBeLessThan(
169
+ absoluteGeometryFor(laid, "transform").x,
170
+ );
171
+ expect(absoluteGeometryFor(laid, "transform").x).toBeLessThan(
172
+ absoluteGeometryFor(laid, "target").x,
173
+ );
174
+ });
175
+
176
+ it("reserves a wide horizontal corridor for curves entering arrowheads", () => {
177
+ const nodes = [
178
+ standaloneResourceNode("source"),
179
+ standaloneResourceNode("target"),
180
+ ];
181
+ const laid = layoutVisibleLineageGraph({
182
+ nodes,
183
+ edges: [{ id: "e1", source: "source", target: "target" }],
184
+ graphType: "impact",
185
+ });
186
+ const source = absoluteGeometryFor(laid, "source");
187
+ const target = absoluteGeometryFor(laid, "target");
188
+ const horizontalCorridor = target.x - (source.x + source.w);
189
+
190
+ expect(horizontalCorridor).toBeGreaterThanOrEqual(144);
191
+ });
192
+
193
+ it("normalizes lineage edges before ranking real flow left to right", () => {
194
+ const nodes = [
195
+ standaloneResourceNode("selected"),
196
+ standaloneResourceNode("upstream"),
197
+ ];
198
+ const laid = layoutVisibleLineageGraph({
199
+ nodes,
200
+ edges: [
201
+ lineageEdge(
202
+ undefined,
203
+ (id) => id,
204
+ new Map(nodes.map((node) => [node.id, node])),
205
+ "lineage",
206
+ )({ id: "e1", source: "selected", target: "upstream" }, 0),
207
+ ],
208
+ graphType: "lineage",
209
+ });
210
+
211
+ expect(absoluteGeometryFor(laid, "upstream").x).toBeLessThan(
212
+ absoluteGeometryFor(laid, "selected").x,
213
+ );
214
+ });
215
+
216
+ it("lets an open group span every rank occupied by its descendants", () => {
217
+ const group = groupNode("pipeline", 0, 0, 720, 300);
218
+ const source = resourceNode("source", group, 20, 52, 288, 80);
219
+ const target = resourceNode("target", group, 360, 52, 288, 80);
220
+ const laid = layoutVisibleLineageGraph({
221
+ nodes: [group, source, target],
222
+ edges: [{ id: "e1", source: "source", target: "target" }],
223
+ });
224
+ const laidGroup = absoluteGeometryFor(laid, "pipeline");
225
+ const laidSource = absoluteGeometryFor(laid, "source");
226
+ const laidTarget = absoluteGeometryFor(laid, "target");
227
+
228
+ expect(laidSource.x).toBeLessThan(laidTarget.x);
229
+ expect(laidGroup.x).toBeLessThanOrEqual(laidSource.x);
230
+ expect(laidGroup.x + laidGroup.w).toBeGreaterThanOrEqual(
231
+ laidTarget.x + laidTarget.w,
232
+ );
233
+ });
234
+
235
+ it("keeps strongly connected nodes in one rank", () => {
236
+ const nodes = [
237
+ standaloneResourceNode("a"),
238
+ standaloneResourceNode("b"),
239
+ standaloneResourceNode("c"),
240
+ ];
241
+ const laid = layoutVisibleLineageGraph({
242
+ nodes,
243
+ edges: [
244
+ { id: "e1", source: "a", target: "b" },
245
+ { id: "e2", source: "b", target: "c" },
246
+ { id: "e3", source: "c", target: "a" },
247
+ ],
248
+ });
249
+
250
+ expect(new Set(laid.map((node) => node.data.absoluteGeometry.x)).size).toBe(
251
+ 1,
252
+ );
253
+ });
254
+
255
+ it("uses compact backend depth ranks for disconnected nodes", () => {
256
+ const nodes = [
257
+ standaloneResourceNode("first", 0, 0, { z: 0 }),
258
+ standaloneResourceNode("second", 0, 100, { z: 40 }),
259
+ standaloneResourceNode("third", 0, 200, { z: 80 }),
260
+ ];
261
+ const laid = layoutVisibleLineageGraph({ nodes, edges: [] });
262
+
263
+ expect(absoluteGeometryFor(laid, "first").x).toBeLessThan(
264
+ absoluteGeometryFor(laid, "second").x,
265
+ );
266
+ expect(absoluteGeometryFor(laid, "second").x).toBeLessThan(
267
+ absoluteGeometryFor(laid, "third").x,
268
+ );
269
+ });
270
+
271
+ it("is deterministic when node and edge inputs are permuted", () => {
272
+ const nodes = [
273
+ standaloneResourceNode("a"),
274
+ standaloneResourceNode("b"),
275
+ standaloneResourceNode("c"),
276
+ standaloneResourceNode("d"),
277
+ ];
278
+ const edges = [
279
+ { id: "e1", source: "a", target: "c" },
280
+ { id: "e2", source: "b", target: "d" },
281
+ ];
282
+ const first = layoutVisibleLineageGraph({ nodes, edges });
283
+ const second = layoutVisibleLineageGraph({
284
+ nodes: [...nodes].reverse(),
285
+ edges: [...edges].reverse(),
286
+ });
287
+ const geometry = (laid) =>
288
+ [...laid]
289
+ .sort((nodeA, nodeB) => nodeA.id.localeCompare(nodeB.id))
290
+ .map((node) => [node.id, node.data.absoluteGeometry]);
291
+
292
+ expect(geometry(second)).toEqual(geometry(first));
293
+ });
294
+
295
+ it("preserves the anchor position when its rendered height changes", () => {
296
+ const nodes = [
297
+ standaloneResourceNode("anchor", 0, 0, { height: 80 }),
298
+ standaloneResourceNode("sibling", 0, 120, { height: 80 }),
299
+ ];
300
+ const previousLayout = layoutVisibleLineageGraph({ nodes, edges: [] });
301
+ const expandedNodes = nodes.map((node) =>
302
+ node.id === "anchor"
303
+ ? {
304
+ ...node,
305
+ height: 240,
306
+ data: {
307
+ ...node.data,
308
+ absoluteGeometry: {
309
+ ...node.data.absoluteGeometry,
310
+ h: 240,
311
+ },
312
+ },
313
+ }
314
+ : node,
315
+ );
316
+ const expandedLayout = layoutVisibleLineageGraph({
317
+ anchorNodeId: "anchor",
318
+ nodes: expandedNodes,
319
+ edges: [],
320
+ previousLayout,
321
+ });
322
+
323
+ expect(absoluteGeometryFor(expandedLayout, "anchor")).toMatchObject({
324
+ x: absoluteGeometryFor(previousLayout, "anchor").x,
325
+ y: absoluteGeometryFor(previousLayout, "anchor").y,
326
+ h: 240,
327
+ });
328
+ });
329
+
330
+ it("orders bifurcations and convergences without overlapping peers", () => {
331
+ const nodes = [
332
+ standaloneResourceNode("source"),
333
+ standaloneResourceNode("branch-a", 0, 240),
334
+ standaloneResourceNode("branch-b", 0, 0),
335
+ standaloneResourceNode("target"),
336
+ ];
337
+ const laid = layoutVisibleLineageGraph({
338
+ nodes,
339
+ edges: [
340
+ { id: "e1", source: "source", target: "branch-a" },
341
+ { id: "e2", source: "source", target: "branch-b" },
342
+ { id: "e3", source: "branch-a", target: "target" },
343
+ { id: "e4", source: "branch-b", target: "target" },
344
+ ],
345
+ });
346
+ const branchA = absoluteGeometryFor(laid, "branch-a");
347
+ const branchB = absoluteGeometryFor(laid, "branch-b");
348
+
349
+ expect(branchA.x).toBe(branchB.x);
350
+ expect(overlaps(branchA, branchB)).toBe(false);
351
+ expect(absoluteGeometryFor(laid, "source").x).toBeLessThan(branchA.x);
352
+ expect(branchA.x).toBeLessThan(absoluteGeometryFor(laid, "target").x);
353
+ });
354
+
355
+ it("uses long-edge virtual nodes to keep parallel flows uncrossed", () => {
356
+ const nodes = [
357
+ standaloneResourceNode("source-a", 0, 0),
358
+ standaloneResourceNode("source-b", 0, 200),
359
+ standaloneResourceNode("middle-a", 0, 0),
360
+ standaloneResourceNode("middle-b", 0, 200),
361
+ standaloneResourceNode("target-a", 0, 200),
362
+ standaloneResourceNode("target-b", 0, 0),
363
+ ];
364
+ const edges = [
365
+ { id: "a-middle", source: "source-a", target: "middle-a" },
366
+ { id: "b-middle", source: "source-b", target: "middle-b" },
367
+ { id: "a-target", source: "source-a", target: "target-a" },
368
+ { id: "b-target", source: "source-b", target: "target-b" },
369
+ { id: "middle-a-target", source: "middle-a", target: "target-a" },
370
+ { id: "middle-b-target", source: "middle-b", target: "target-b" },
371
+ ];
372
+ const laid = layoutVisibleLineageGraph({ nodes, edges });
373
+
374
+ expect(absoluteGeometryFor(laid, "source-a").y).toBeLessThan(
375
+ absoluteGeometryFor(laid, "source-b").y,
376
+ );
377
+ expect(absoluteGeometryFor(laid, "middle-a").y).toBeLessThan(
378
+ absoluteGeometryFor(laid, "middle-b").y,
379
+ );
380
+ expect(absoluteGeometryFor(laid, "target-a").y).toBeLessThan(
381
+ absoluteGeometryFor(laid, "target-b").y,
382
+ );
383
+ });
384
+
385
+ it("packs nested groups across ranks without overlapping sibling groups", () => {
386
+ const root = groupNode("root", 0, 0, 1200, 600);
387
+ const pipeline = childGroupNode("pipeline", root, 20, 58, 720, 260);
388
+ const sibling = childGroupNode("sibling", root, 20, 340, 320, 180);
389
+ const source = resourceNode("nested-source", pipeline, 20, 58, 288, 80);
390
+ const target = resourceNode("nested-target", pipeline, 360, 58, 288, 80);
391
+ const siblingLeaf = resourceNode("sibling-leaf", sibling, 20, 58, 288, 80);
392
+ const laid = layoutVisibleLineageGraph({
393
+ nodes: [root, pipeline, sibling, source, target, siblingLeaf],
394
+ edges: [
395
+ { id: "nested", source: "nested-source", target: "nested-target" },
396
+ { id: "sibling", source: "sibling-leaf", target: "nested-target" },
397
+ ],
398
+ });
399
+ const laidPipeline = absoluteGeometryFor(laid, "pipeline");
400
+ const laidSibling = absoluteGeometryFor(laid, "sibling");
401
+ const laidRoot = absoluteGeometryFor(laid, "root");
402
+
403
+ expect(overlaps(laidPipeline, laidSibling)).toBe(false);
404
+ [laidPipeline, laidSibling].forEach((geometry) => {
405
+ expect(geometry.x).toBeGreaterThanOrEqual(laidRoot.x);
406
+ expect(geometry.y).toBeGreaterThanOrEqual(laidRoot.y);
407
+ expect(geometry.x + geometry.w).toBeLessThanOrEqual(
408
+ laidRoot.x + laidRoot.w,
409
+ );
410
+ expect(geometry.y + geometry.h).toBeLessThanOrEqual(
411
+ laidRoot.y + laidRoot.h,
412
+ );
413
+ });
414
+ expect(absoluteGeometryFor(laid, "nested-source").x).toBeLessThan(
415
+ absoluteGeometryFor(laid, "nested-target").x,
416
+ );
417
+ });
418
+
419
+ it("does not overlap deep nested sibling groups when everything is expanded", () => {
420
+ const nest = (id, parent, leafId) => {
421
+ const level2 = childGroupNode(`${id}-l2`, parent, 20, 58, 320, 220);
422
+ const level3 = childGroupNode(`${id}-l3`, level2, 20, 58, 300, 160);
423
+ const level4 = childGroupNode(`${id}-l4`, level3, 20, 58, 280, 110);
424
+ const leaf = resourceNode(leafId, level4, 20, 58, 288, 80);
425
+
426
+ return [level2, level3, level4, leaf];
427
+ };
428
+ const root = groupNode("root", 0, 0, 1600, 900);
429
+ const systemA = childGroupNode("system-a", root, 20, 58, 720, 400);
430
+ const systemB = childGroupNode("system-b", root, 760, 58, 720, 400);
431
+ const nestedA = nest("a", systemA, "table-a");
432
+ const nestedB = nest("b", systemB, "table-b");
433
+ const laid = layoutVisibleLineageGraph({
434
+ nodes: [root, systemA, systemB, ...nestedA, ...nestedB],
435
+ edges: [{ id: "flow", source: "table-a", target: "table-b" }],
436
+ });
437
+ [
438
+ ["system-a", "system-b"],
439
+ ["a-l2", "b-l2"],
440
+ ["a-l3", "b-l3"],
441
+ ["a-l4", "b-l4"],
442
+ ["table-a", "table-b"],
443
+ ].forEach(([leftId, rightId]) => {
444
+ expect(
445
+ overlaps(
446
+ absoluteGeometryFor(laid, leftId),
447
+ absoluteGeometryFor(laid, rightId),
448
+ ),
449
+ ).toBe(false);
450
+ });
451
+ expect(absoluteGeometryFor(laid, "table-a").x).toBeLessThan(
452
+ absoluteGeometryFor(laid, "table-b").x,
453
+ );
454
+ });
455
+ });
456
+
457
+ describe("positioned lineage layout", () => {
458
+ it("routes stacked siblings through the gap between their boxes", () => {
459
+ const group = groupNode("group", 0, 0, 360, 280);
460
+ const source = resourceNode("source", group, 24, 52, 288, 80);
461
+ const target = resourceNode("target", group, 24, 148, 288, 80);
462
+ const [edge] = planLineageEdgeRoutes(
463
+ [{ id: "e1", source: "source", target: "target", data: {} }],
464
+ [group, source, target],
465
+ );
466
+
467
+ expect(edge.data.edgeOffset).toBe(12);
468
+ expect(edge.data.routeGuide).toMatchObject({
469
+ kind: "bypass",
470
+ laneY: 140,
471
+ });
472
+ });
473
+
474
+ it("keeps output right and input left for a feedback relation", () => {
475
+ const source = groupNode("source", 400, 40, 288, 120);
476
+ const target = groupNode("target", 0, 180, 288, 120);
477
+ const nodes = [source, target];
478
+ const handledEdges = applyFieldHandlesToEdges(
479
+ [{ id: "e1", source: "source", target: "target", data: {} }],
480
+ new Map(nodes.map((node) => [node.id, node])),
481
+ );
482
+ const [edge] = planLineageEdgeRoutes(handledEdges, nodes);
483
+
484
+ expect(edge.data).toMatchObject({
485
+ sourcePortPosition: "right",
486
+ targetPortPosition: "left",
487
+ routeGuide: { kind: "corridor" },
488
+ });
489
+ expect(edge.sourceHandle).toBe("end-node");
490
+ expect(edge.targetHandle).toBe("start-node");
491
+ });
492
+
493
+ it("uses node handles when a relation does not identify a field", () => {
494
+ const source = {
495
+ ...groupNode("source", 0, 40, 288, 120),
496
+ data: {
497
+ ...groupNode("source", 0, 40, 288, 120).data,
498
+ fields: [{ name: "source_field" }],
499
+ variant: "lineage-redesigned",
500
+ },
501
+ };
502
+ const target = {
503
+ ...groupNode("target", 400, 40, 288, 120),
504
+ data: {
505
+ ...groupNode("target", 400, 40, 288, 120).data,
506
+ fields: [{ name: "target_field" }],
507
+ variant: "lineage-redesigned",
508
+ },
509
+ };
510
+ const [edge] = applyFieldHandlesToEdges(
511
+ [{ id: "e1", source: "source", target: "target", data: {} }],
512
+ new Map([
513
+ [source.id, source],
514
+ [target.id, target],
515
+ ]),
516
+ );
517
+
518
+ expect(edge.sourceHandle).toBe("end-node");
519
+ expect(edge.targetHandle).toBe("start-node");
520
+ expect(edge.data.sourceFieldIndex).toBeUndefined();
521
+ expect(edge.data.targetFieldIndex).toBeUndefined();
522
+ });
523
+
524
+ it("keeps explicit field relations attached to their field handles", () => {
525
+ const source = {
526
+ ...groupNode("source", 0, 40, 288, 120),
527
+ data: {
528
+ ...groupNode("source", 0, 40, 288, 120).data,
529
+ fields: [{ name: "source_field" }],
530
+ variant: "lineage-redesigned",
531
+ },
532
+ };
533
+ const target = {
534
+ ...groupNode("target", 400, 40, 288, 120),
535
+ data: {
536
+ ...groupNode("target", 400, 40, 288, 120).data,
537
+ fields: [{ name: "target_field" }],
538
+ variant: "lineage-redesigned",
539
+ },
540
+ };
541
+ const [edge] = applyFieldHandlesToEdges(
542
+ [
543
+ {
544
+ id: "e1",
545
+ source: "source",
546
+ target: "target",
547
+ data: { sourceFieldIndex: 0, targetFieldIndex: 0 },
548
+ },
549
+ ],
550
+ new Map([
551
+ [source.id, source],
552
+ [target.id, target],
553
+ ]),
554
+ );
555
+
556
+ expect(edge.sourceHandle).toBe("field-source-0");
557
+ expect(edge.targetHandle).toBe("field-target-0");
558
+ });
559
+
560
+ it("marks N-to-1 relations so they share one target trunk", () => {
561
+ const bundled = bundleLineageEdges([
562
+ {
563
+ id: "e1",
564
+ source: "source-a",
565
+ sourceHandle: "end-node",
566
+ target: "target",
567
+ targetHandle: "start-node",
568
+ data: {},
569
+ },
570
+ {
571
+ id: "e2",
572
+ source: "source-b",
573
+ sourceHandle: "end-node",
574
+ target: "target",
575
+ targetHandle: "start-node",
576
+ data: {},
577
+ },
578
+ ]);
579
+
580
+ expect(bundled).toEqual([
581
+ expect.objectContaining({
582
+ data: expect.objectContaining({
583
+ bundleSource: false,
584
+ bundleTarget: true,
585
+ bundleOffset: 40,
586
+ }),
587
+ }),
588
+ expect.objectContaining({
589
+ data: expect.objectContaining({
590
+ bundleSource: false,
591
+ bundleTarget: true,
592
+ bundleOffset: 40,
593
+ }),
594
+ }),
595
+ ]);
596
+ });
597
+
598
+ it("lets redesigned edge states control width and opacity", () => {
599
+ const [edge] = redesignedLineageEdges([
600
+ {
601
+ id: "e1",
602
+ source: "source",
603
+ sourceHandle: "end-node",
604
+ target: "target",
605
+ targetHandle: "start-node",
606
+ data: {},
607
+ style: {
608
+ stroke: "#b0b8c8",
609
+ strokeOpacity: 0.95,
610
+ strokeWidth: 2,
611
+ },
612
+ },
613
+ ]);
614
+
615
+ expect(edge.style).toEqual({});
616
+ expect(edge.data).toMatchObject({
617
+ edgeCurvature: 0.32,
618
+ edgePath: "bezier",
619
+ });
620
+ });
621
+ });
622
+
623
+ it("compacts empty open groups while preserving collapsed group summaries", () => {
624
+ const empty = groupNode("empty", 0, 0, 288, 82);
625
+ const parent = groupNode("parent", 400, 0, 288, 200);
626
+ const child = resourceNode("child", parent, 0, 50, 200, 80);
627
+ const result = collapseLineageNodes(
628
+ [empty, parent, child],
629
+ new Set(["parent"]),
630
+ () => {},
631
+ );
632
+ const compact = result.find(({ id }) => id === "empty");
633
+ expect(compact.height).toBe(44);
634
+ expect(compact.style.height).toBe(44);
635
+ expect(compact.data.absoluteGeometry.h).toBe(44);
636
+ const collapsed = result.find(({ id }) => id === "parent");
637
+ expect(collapsed.height).toBe(82);
638
+ expect(collapsed.data.collapsed).toBe(true);
639
+ });