@lucastho/d3-sankey-circular-ng 0.1.0

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/src/sankey.js ADDED
@@ -0,0 +1,644 @@
1
+ import {max, min, sum} from "d3-array";
2
+ import {justify} from "./align.js";
3
+ import constant from "./constant.js";
4
+
5
+ function ascendingSourceBreadth(a, b) {
6
+ return ascendingBreadth(a.source, b.source) || a.index - b.index;
7
+ }
8
+
9
+ function ascendingTargetBreadth(a, b) {
10
+ return ascendingBreadth(a.target, b.target) || a.index - b.index;
11
+ }
12
+
13
+ function ascendingBreadth(a, b) {
14
+ return a.y0 - b.y0;
15
+ }
16
+
17
+ function value(d) {
18
+ return d.value;
19
+ }
20
+
21
+ function defaultId(d) {
22
+ return d.index;
23
+ }
24
+
25
+ function defaultNodes(graph) {
26
+ return graph.nodes;
27
+ }
28
+
29
+ function defaultLinks(graph) {
30
+ return graph.links;
31
+ }
32
+
33
+ function find(nodeById, id) {
34
+ const node = nodeById.get(id);
35
+ if (!node) throw new Error("missing: " + id);
36
+ return node;
37
+ }
38
+
39
+ function computeLinkBreadths({nodes}) {
40
+ for (const node of nodes) {
41
+ let y0 = node.y0;
42
+ let y1 = y0;
43
+ for (const link of node.sourceLinks) {
44
+ link.y0 = y0 + link.width / 2;
45
+ y0 += link.width;
46
+ }
47
+ for (const link of node.targetLinks) {
48
+ link.y1 = y1 + link.width / 2;
49
+ y1 += link.width;
50
+ }
51
+ }
52
+ }
53
+
54
+ // -----------------------------------------------------------------------------
55
+ // CIRCULAR LAYOUT HELPERS (module scope)
56
+ //
57
+ // These only run when a graph contains back-edges. They receive the layout
58
+ // parameters they need explicitly (dx, dy, x0, x1) rather than reading the
59
+ // sankey() closure, so the routing math is self-contained and testable.
60
+ // -----------------------------------------------------------------------------
61
+
62
+ // -----------------------------------------------------------------------------
63
+ // CIRCULAR ROUTING CONSTANTS
64
+ //
65
+ // These define the shape of back-edge routing. They're collected here so the
66
+ // geometry is tunable in one place and each magic number has a name explaining
67
+ // what it guards against. All factors multiply either a link width `w`, the
68
+ // inter-loop `gap`, the node width `dx`, or the reserved `gutter`.
69
+ // -----------------------------------------------------------------------------
70
+
71
+ // circularGap: minimum gap between stacked loops, as a floor under nodePadding.
72
+ const MIN_GAP = 4;
73
+
74
+ // Gutter sizing (computeCircularReservation): horizontal room reserved on the
75
+ // left/right for the out-and-around bends.
76
+ const GUTTER_MIN_NODE_WIDTHS = 1.5; // floor: at least this many node widths
77
+ const GUTTER_MAX_EXTENT_FRACTION = 0.15; // ceiling: never eat more than this much width
78
+
79
+ // Turn-out distance (placeCircularStack): how far the link travels horizontally
80
+ // away from the node face before turning into its lane.
81
+ const TURNOUT_MIN_WIDTHS = 3; // floor: at least this many link widths
82
+ const TURNOUT_GUTTER_FRACTION = 0.6; // or this fraction of the gutter, whichever is larger
83
+
84
+ // Minimum vertical rise (placeCircularStack): how far a lane must sit from its
85
+ // endpoints so the corner fillets have room. Breaks the radius/rise chicken-egg.
86
+ const MIN_RISE_WIDTHS = 2.5; // rise floor = this many widths + one gap
87
+
88
+ // Corner radius bounds (placeCircularStack): the fillet radius is the smallest
89
+ // of several limits so no corner can collapse or overshoot its segment.
90
+ const RADIUS_MIN = 2;
91
+ const RADIUS_GUTTER_FRACTION = 0.5; // cap radius at half the gutter
92
+ const RADIUS_WIDTH_FACTOR = 1.5; // ...and at 1.5*w + gap
93
+
94
+
95
+
96
+
97
+ // Gap between adjacent stacked loops, and gap between the node band and the
98
+ // first lane. Kept proportional to nodePadding so it scales sensibly.
99
+ function circularGap(dy) {
100
+ return Math.max(dy, MIN_GAP);
101
+ }
102
+
103
+ // Measure how much vertical room the loops need on each side and reserve
104
+ // left/right horizontal gutters so the out-and-around curves don't clip.
105
+ //
106
+ // The acyclic layout fills the entire [y0,y1] x [x0,x1] extent with nodes and
107
+ // forward ribbons, leaving no room for back-edges. To draw a back-edge we route
108
+ // it OUT of the source's right side, UP (or DOWN) into a horizontal "lane"
109
+ // stacked outside the node band, ACROSS, then back DOWN (or UP) into the
110
+ // target's left side. We reserve space *inside* the extent (no new API): a band
111
+ // at the top for "top" loops, one at the bottom for "bottom" loops, and
112
+ // left/right gutters for the bends.
113
+ function computeCircularReservation(graph, {dx, dy, x0, x1}) {
114
+ const gap = circularGap(dy);
115
+ const top = [], bottom = [];
116
+ for (const link of graph.links) {
117
+ if (!link.circular) continue;
118
+ (link.circularLinkType === "top" ? top : bottom).push(link);
119
+ }
120
+ top.sort((a, b) => b.width - a.width);
121
+ bottom.sort((a, b) => b.width - a.width);
122
+
123
+ // Assign each loop a stack index (0 = closest to node band) on its side and
124
+ // remember it for computeCircularPathData. Total reserved height per side =
125
+ // sum of loop widths + gaps between them + one gap to the node band.
126
+ function reserve(stack) {
127
+ let h = stack.length ? gap : 0;
128
+ stack.forEach((link, i) => {
129
+ link.circularLaneIndex = i;
130
+ h += link.width + (i > 0 ? gap : 0);
131
+ });
132
+ return h;
133
+ }
134
+
135
+ const maxLoopHalfWidth = Math.max(0, ...graph.links
136
+ .filter(l => l.circular).map(l => l.width / 2));
137
+ const gutter = Math.min(
138
+ Math.max(dx * GUTTER_MIN_NODE_WIDTHS, maxLoopHalfWidth + dx), // ensure room for the fattest loop's bend
139
+ (x1 - x0) * GUTTER_MAX_EXTENT_FRACTION
140
+ );
141
+
142
+ return {
143
+ top: reserve(top),
144
+ bottom: reserve(bottom),
145
+ gutter,
146
+ topStack: top,
147
+ bottomStack: bottom,
148
+ gap
149
+ };
150
+ }
151
+
152
+ // Stack one side's loops outward from the node band and compute the
153
+ // rounded-corner polyline control points for each.
154
+ function placeCircularStack(stack, side, reservation, bandTop, bandBottom) {
155
+ const {gap} = reservation;
156
+ const edge = side === "top" ? bandTop : bandBottom;
157
+ let cursor = gap;
158
+ stack.forEach(link => {
159
+ const w = link.width;
160
+ // Lane centerline Y, stacked outward from the band edge.
161
+ const laneY = side === "top"
162
+ ? edge - cursor - w / 2
163
+ : edge + cursor + w / 2;
164
+ cursor += w + gap;
165
+
166
+ const sourceX = link.source.x1;
167
+ const targetX = link.target.x0;
168
+ const sourceY = link.y0;
169
+ const targetY = link.y1;
170
+
171
+ const minTurn = Math.max(TURNOUT_MIN_WIDTHS * w, gap);
172
+ const turnOut = Math.max(minTurn, reservation.gutter * TURNOUT_GUTTER_FRACTION);
173
+ const rightX = sourceX + turnOut;
174
+ const leftX = targetX - turnOut;
175
+
176
+ // ---- Determine the lane Y with a GUARANTEED minimum vertical rise.
177
+ // The fillet at each corner needs the adjacent segment to be at least
178
+ // 2*radius long, and radius itself depends on the rise. Break the
179
+ // chicken/egg by reserving a fixed minimum rise based on width+gap,
180
+ // then pushing the lane OUTWARD (never inward) to honor it.
181
+ const isSelf = link.source === link.target;
182
+ // For clearance we measure against the relevant node face(s).
183
+ const clearRef = side === "top"
184
+ ? (isSelf ? link.source.y0 : Math.min(sourceY, targetY))
185
+ : (isSelf ? link.source.y1 : Math.max(sourceY, targetY));
186
+
187
+ // Minimum rise from the endpoints to the lane.
188
+ const minRise = MIN_RISE_WIDTHS * w + gap;
189
+ let vY;
190
+ if (side === "top") {
191
+ // lane must be ABOVE both endpoints by at least minRise, and also
192
+ // not deeper into the band than its stacked laneY.
193
+ vY = Math.min(laneY, clearRef - minRise);
194
+ // but never let it get CLOSER than minRise to the nearest endpoint
195
+ vY = Math.min(vY, Math.min(sourceY, targetY) - minRise);
196
+ } else {
197
+ vY = Math.max(laneY, clearRef + minRise);
198
+ vY = Math.max(vY, Math.max(sourceY, targetY) + minRise);
199
+ }
200
+
201
+ const points = [
202
+ { x: sourceX, y: sourceY }, // 0: leave source face
203
+ { x: rightX, y: sourceY }, // 1: turn up/down
204
+ { x: rightX, y: vY }, // 2: into lane
205
+ { x: leftX, y: vY }, // 3: across
206
+ { x: leftX, y: targetY }, // 4: turn back
207
+ { x: targetX, y: targetY } // 5: enter target face
208
+ ];
209
+
210
+ // Radius is bounded by the SHORTEST adjacent segment / 2 so no corner
211
+ // collapses. The shortest verticals are the source/target rises.
212
+ const riseSrc = Math.abs(vY - sourceY);
213
+ const riseTgt = Math.abs(vY - targetY);
214
+ const acrossLen = Math.abs(leftX - rightX);
215
+ const radius = Math.max(RADIUS_MIN, Math.min(
216
+ reservation.gutter * RADIUS_GUTTER_FRACTION,
217
+ w * RADIUS_WIDTH_FACTOR + gap,
218
+ riseSrc / 2,
219
+ riseTgt / 2,
220
+ acrossLen / 2,
221
+ turnOut / 2
222
+ ));
223
+
224
+ link.circularPathData = {
225
+ points,
226
+ radius,
227
+ type: side,
228
+ selfLoop: isSelf,
229
+ laneY: vY,
230
+ sourceX, targetX, sourceY, targetY
231
+ };
232
+ });
233
+ }
234
+
235
+ // Compute final per-loop geometry using the (phase-2) node positions and the
236
+ // reserved lanes. Anchors lanes to ACTUAL final node positions.
237
+ function computeCircularPathData(graph, reservation) {
238
+ const bandTop = Math.min(...graph.nodes.map(n => n.y0));
239
+ const bandBottom = Math.max(...graph.nodes.map(n => n.y1));
240
+ placeCircularStack(reservation.topStack, "top", reservation, bandTop, bandBottom);
241
+ placeCircularStack(reservation.bottomStack, "bottom", reservation, bandTop, bandBottom);
242
+ }
243
+
244
+ export default function Sankey() {
245
+ let x0 = 0, y0 = 0, x1 = 1, y1 = 1; // extent
246
+ let dx = 24; // nodeWidth
247
+ let dy = 8, py; // nodePadding
248
+ let id = defaultId;
249
+ let align = justify;
250
+ let sort;
251
+ let linkSort;
252
+ let nodes = defaultNodes;
253
+ let links = defaultLinks;
254
+ let iterations = 6;
255
+
256
+ function sankey() {
257
+ const graph = {nodes: nodes.apply(null, arguments), links: links.apply(null, arguments)};
258
+ computeNodeLinks(graph);
259
+ identifyCircles(graph);
260
+ computeNodeValues(graph);
261
+ computeNodeDepths(graph);
262
+ computeNodeHeights(graph);
263
+
264
+ // Does this graph contain any back-edges? If not, everything below behaves
265
+ // EXACTLY as the original acyclic d3-sankey — reservation is zero, no extra
266
+ // layout pass runs, and circular-only fields are never written. This is the
267
+ // zero-regression guarantee.
268
+ const hasCircular = graph.links.some(l => l.circular);
269
+ if (!hasCircular) {
270
+ // ---- ACYCLIC FAST PATH (unchanged original behavior) ----
271
+ computeNodeBreadths(graph);
272
+ computeLinkBreadths(graph);
273
+ return graph;
274
+ }
275
+
276
+ // ---- CIRCULAR PATH (two-phase layout) ----
277
+ //
278
+ // PHASE 1: lay out into the FULL extent to discover link widths and node
279
+ // y-positions. We need widths to know how thick each loop lane must be, and
280
+ // we need y-positions to decide top/bottom routing. Nothing here is kept as
281
+ // final geometry — it's purely a measurement pass.
282
+ computeNodeBreadths(graph);
283
+ computeLinkBreadths(graph);
284
+ selectCircularLinkTypes(graph); // freezes link.circularLinkType using phase-1 y's
285
+
286
+ // Measure vertical room needed per side and reserve horizontal gutters.
287
+ const reservation = computeCircularReservation(graph, {dx, dy, x0, x1});
288
+ graph.circularReservation = reservation;
289
+
290
+ // PHASE 2: re-run the layout inside the shrunk band. Because every breadth
291
+ // function reads the closure x0/y0/x1/y1, temporarily reassigning them is
292
+ // all it takes to confine nodes to the reduced area — no other code changes
293
+ // needed. Wrapped in try/finally so a thrown "circular link" error can't
294
+ // leak the mutated extent into the next call.
295
+ const savedX0 = x0, savedX1 = x1, savedY0 = y0, savedY1 = y1;
296
+ try {
297
+ x0 = savedX0 + reservation.gutter;
298
+ x1 = savedX1 - reservation.gutter;
299
+ y0 = savedY0 + reservation.top;
300
+ y1 = savedY1 - reservation.bottom;
301
+ computeNodeBreadths(graph);
302
+ computeLinkBreadths(graph);
303
+ } finally {
304
+ x0 = savedX0; x1 = savedX1;
305
+ y0 = savedY0; y1 = savedY1;
306
+ }
307
+
308
+ // NOTE: circularLinkType stays FROZEN from phase 1 so the reservation we
309
+ // just applied can't oscillate against a re-decided routing. Now compute
310
+ // the final per-loop geometry (lane stacking, curve control points) using
311
+ // the phase-2 node positions and the reserved lanes.
312
+ computeCircularPathData(graph, reservation);
313
+ return graph;
314
+ }
315
+
316
+ sankey.nodeId = function(_) {
317
+ return arguments.length ? (id = typeof _ === "function" ? _ : constant(_), sankey) : id;
318
+ };
319
+
320
+ sankey.nodeAlign = function(_) {
321
+ return arguments.length ? (align = typeof _ === "function" ? _ : constant(_), sankey) : align;
322
+ };
323
+
324
+ sankey.nodeSort = function(_) {
325
+ return arguments.length ? (sort = _, sankey) : sort;
326
+ };
327
+
328
+ sankey.nodeWidth = function(_) {
329
+ return arguments.length ? (dx = +_, sankey) : dx;
330
+ };
331
+
332
+ sankey.nodePadding = function(_) {
333
+ return arguments.length ? (dy = py = +_, sankey) : dy;
334
+ };
335
+
336
+ sankey.nodes = function(_) {
337
+ return arguments.length ? (nodes = typeof _ === "function" ? _ : constant(_), sankey) : nodes;
338
+ };
339
+
340
+ sankey.links = function(_) {
341
+ return arguments.length ? (links = typeof _ === "function" ? _ : constant(_), sankey) : links;
342
+ };
343
+
344
+ sankey.linkSort = function(_) {
345
+ return arguments.length ? (linkSort = _, sankey) : linkSort;
346
+ };
347
+
348
+ sankey.size = function(_) {
349
+ return arguments.length ? (x0 = y0 = 0, x1 = +_[0], y1 = +_[1], sankey) : [x1 - x0, y1 - y0];
350
+ };
351
+
352
+ sankey.extent = function(_) {
353
+ return arguments.length ? (x0 = +_[0][0], x1 = +_[1][0], y0 = +_[0][1], y1 = +_[1][1], sankey) : [[x0, y0], [x1, y1]];
354
+ };
355
+
356
+ sankey.iterations = function(_) {
357
+ return arguments.length ? (iterations = +_, sankey) : iterations;
358
+ };
359
+
360
+ function computeNodeLinks({nodes, links}) {
361
+ for (const [i, node] of nodes.entries()) {
362
+ node.index = i;
363
+ node.sourceLinks = [];
364
+ node.targetLinks = [];
365
+ }
366
+ const nodeById = new Map(nodes.map((d, i) => [id(d, i, nodes), d]));
367
+ for (const [i, link] of links.entries()) {
368
+ link.index = i;
369
+ let {source, target} = link;
370
+ if (typeof source !== "object") source = link.source = find(nodeById, source);
371
+ if (typeof target !== "object") target = link.target = find(nodeById, target);
372
+ source.sourceLinks.push(link);
373
+ target.targetLinks.push(link);
374
+ }
375
+ if (linkSort != null) {
376
+ for (const {sourceLinks, targetLinks} of nodes) {
377
+ sourceLinks.sort(linkSort);
378
+ targetLinks.sort(linkSort);
379
+ }
380
+ }
381
+ }
382
+
383
+ function computeNodeValues({nodes}) {
384
+ for (const node of nodes) {
385
+ node.value = node.fixedValue === undefined
386
+ ? Math.max(sum(node.sourceLinks, value), sum(node.targetLinks, value))
387
+ : node.fixedValue;
388
+ }
389
+ }
390
+
391
+ // Tag links that close a cycle (back-edges) via DFS.
392
+ // After this, ignoring link.circular links makes the graph a DAG.
393
+ function identifyCircles({nodes, links}) {
394
+ for (const link of links) link.circular = false;
395
+ const visited = new Set();
396
+ const inStack = new Set();
397
+ function dfs(node) {
398
+ if (visited.has(node)) return;
399
+ inStack.add(node);
400
+ for (const link of node.sourceLinks) {
401
+ if (link.target === node) {
402
+ link.circular = true; // ← self-loop: a → a
403
+ } else if (inStack.has(link.target)) {
404
+ link.circular = true; // ordinary back-edge
405
+ } else if (!visited.has(link.target)) {
406
+ dfs(link.target);
407
+ }
408
+ }
409
+ inStack.delete(node);
410
+ visited.add(node);
411
+ }
412
+ for (const node of nodes) {
413
+ if (!visited.has(node)) dfs(node);
414
+ }
415
+ }
416
+
417
+ function computeNodeDepths({nodes}) {
418
+ const n = nodes.length;
419
+ let current = new Set(nodes);
420
+ let next = new Set;
421
+ let x = 0;
422
+ while (current.size) {
423
+ for (const node of current) {
424
+ node.depth = x;
425
+ for (const link of node.sourceLinks) {
426
+ if (link.circular) continue; // ← skip back-edges
427
+ next.add(link.target);
428
+ }
429
+ }
430
+ if (++x > n) throw new Error("circular link"); // now a real safety net
431
+ current = next;
432
+ next = new Set;
433
+ }
434
+ }
435
+
436
+ function computeNodeHeights({nodes}) {
437
+ const n = nodes.length;
438
+ let current = new Set(nodes);
439
+ let next = new Set;
440
+ let x = 0;
441
+ while (current.size) {
442
+ for (const node of current) {
443
+ node.height = x;
444
+ for (const link of node.targetLinks) {
445
+ if (link.circular) continue; // ← skip back-edges
446
+ next.add(link.source);
447
+ }
448
+ }
449
+ if (++x > n) throw new Error("circular link");
450
+ current = next;
451
+ next = new Set;
452
+ }
453
+ }
454
+
455
+ function computeNodeLayers({nodes}) {
456
+ const x = max(nodes, d => d.depth) + 1;
457
+ const kx = (x1 - x0 - dx) / (x - 1);
458
+ const columns = new Array(x);
459
+ for (const node of nodes) {
460
+ const i = Math.max(0, Math.min(x - 1, Math.floor(align.call(null, node, x))));
461
+ node.layer = i;
462
+ node.x0 = x0 + i * kx;
463
+ node.x1 = node.x0 + dx;
464
+ if (columns[i]) columns[i].push(node);
465
+ else columns[i] = [node];
466
+ }
467
+ if (sort) for (const column of columns) {
468
+ column.sort(sort);
469
+ }
470
+ return columns;
471
+ }
472
+
473
+ function initializeNodeBreadths(columns) {
474
+ const ky = min(columns, c => (y1 - y0 - (c.length - 1) * py) / sum(c, value));
475
+ for (const nodes of columns) {
476
+ let y = y0;
477
+ for (const node of nodes) {
478
+ node.y0 = y;
479
+ node.y1 = y + node.value * ky;
480
+ y = node.y1 + py;
481
+ for (const link of node.sourceLinks) {
482
+ link.width = link.value * ky;
483
+ }
484
+ }
485
+ y = (y1 - y + py) / (nodes.length + 1);
486
+ for (let i = 0; i < nodes.length; ++i) {
487
+ const node = nodes[i];
488
+ node.y0 += y * (i + 1);
489
+ node.y1 += y * (i + 1);
490
+ }
491
+ reorderLinks(nodes);
492
+ }
493
+ }
494
+
495
+ function computeNodeBreadths(graph) {
496
+ const columns = computeNodeLayers(graph);
497
+ py = Math.min(dy, (y1 - y0) / (max(columns, c => c.length) - 1));
498
+ initializeNodeBreadths(columns);
499
+ for (let i = 0; i < iterations; ++i) {
500
+ const alpha = Math.pow(0.99, i);
501
+ const beta = Math.max(1 - alpha, (i + 1) / iterations);
502
+ relaxRightToLeft(columns, alpha, beta);
503
+ relaxLeftToRight(columns, alpha, beta);
504
+ }
505
+ }
506
+
507
+ // Reposition each node based on its incoming (target) links.
508
+ function relaxLeftToRight(columns, alpha, beta) {
509
+ for (let i = 1, n = columns.length; i < n; ++i) {
510
+ const column = columns[i];
511
+ for (const target of column) {
512
+ let y = 0;
513
+ let w = 0;
514
+ for (const {source, value} of target.targetLinks) {
515
+ let v = value * (target.layer - source.layer);
516
+ y += targetTop(source, target) * v;
517
+ w += v;
518
+ }
519
+ if (!(w > 0)) continue;
520
+ let dy = (y / w - target.y0) * alpha;
521
+ target.y0 += dy;
522
+ target.y1 += dy;
523
+ reorderNodeLinks(target);
524
+ }
525
+ if (sort === undefined) column.sort(ascendingBreadth);
526
+ resolveCollisions(column, beta);
527
+ }
528
+ }
529
+
530
+ // Reposition each node based on its outgoing (source) links.
531
+ function relaxRightToLeft(columns, alpha, beta) {
532
+ for (let n = columns.length, i = n - 2; i >= 0; --i) {
533
+ const column = columns[i];
534
+ for (const source of column) {
535
+ let y = 0;
536
+ let w = 0;
537
+ for (const {target, value} of source.sourceLinks) {
538
+ let v = value * (target.layer - source.layer);
539
+ y += sourceTop(source, target) * v;
540
+ w += v;
541
+ }
542
+ if (!(w > 0)) continue;
543
+ let dy = (y / w - source.y0) * alpha;
544
+ source.y0 += dy;
545
+ source.y1 += dy;
546
+ reorderNodeLinks(source);
547
+ }
548
+ if (sort === undefined) column.sort(ascendingBreadth);
549
+ resolveCollisions(column, beta);
550
+ }
551
+ }
552
+
553
+ function resolveCollisions(nodes, alpha) {
554
+ const i = nodes.length >> 1;
555
+ const subject = nodes[i];
556
+ resolveCollisionsBottomToTop(nodes, subject.y0 - py, i - 1, alpha);
557
+ resolveCollisionsTopToBottom(nodes, subject.y1 + py, i + 1, alpha);
558
+ resolveCollisionsBottomToTop(nodes, y1, nodes.length - 1, alpha);
559
+ resolveCollisionsTopToBottom(nodes, y0, 0, alpha);
560
+ }
561
+
562
+ // Push any overlapping nodes down.
563
+ function resolveCollisionsTopToBottom(nodes, y, i, alpha) {
564
+ for (; i < nodes.length; ++i) {
565
+ const node = nodes[i];
566
+ const dy = (y - node.y0) * alpha;
567
+ if (dy > 1e-6) node.y0 += dy, node.y1 += dy;
568
+ y = node.y1 + py;
569
+ }
570
+ }
571
+
572
+ // Push any overlapping nodes up.
573
+ function resolveCollisionsBottomToTop(nodes, y, i, alpha) {
574
+ for (; i >= 0; --i) {
575
+ const node = nodes[i];
576
+ const dy = (node.y1 - y) * alpha;
577
+ if (dy > 1e-6) node.y0 -= dy, node.y1 -= dy;
578
+ y = node.y0 - py;
579
+ }
580
+ }
581
+
582
+ function reorderNodeLinks({sourceLinks, targetLinks}) {
583
+ if (linkSort === undefined) {
584
+ for (const {source: {sourceLinks}} of targetLinks) {
585
+ sourceLinks.sort(ascendingTargetBreadth);
586
+ }
587
+ for (const {target: {targetLinks}} of sourceLinks) {
588
+ targetLinks.sort(ascendingSourceBreadth);
589
+ }
590
+ }
591
+ }
592
+
593
+ function reorderLinks(nodes) {
594
+ if (linkSort === undefined) {
595
+ for (const {sourceLinks, targetLinks} of nodes) {
596
+ sourceLinks.sort(ascendingTargetBreadth);
597
+ targetLinks.sort(ascendingSourceBreadth);
598
+ }
599
+ }
600
+ }
601
+
602
+ // Returns the target.y0 that would produce an ideal link from source to target.
603
+ function targetTop(source, target) {
604
+ let y = source.y0 - (source.sourceLinks.length - 1) * py / 2;
605
+ for (const {target: node, width} of source.sourceLinks) {
606
+ if (node === target) break;
607
+ y += width + py;
608
+ }
609
+ for (const {source: node, width} of target.targetLinks) {
610
+ if (node === source) break;
611
+ y -= width;
612
+ }
613
+ return y;
614
+ }
615
+
616
+ // Returns the source.y0 that would produce an ideal link from source to target.
617
+ function sourceTop(source, target) {
618
+ let y = target.y0 - (target.targetLinks.length - 1) * py / 2;
619
+ for (const {source: node, width} of target.targetLinks) {
620
+ if (node === source) break;
621
+ y += width + py;
622
+ }
623
+ for (const {target: node, width} of source.sourceLinks) {
624
+ if (node === target) break;
625
+ y -= width;
626
+ }
627
+ return y;
628
+ }
629
+
630
+ // Assign each circular link a routing side: 'top' or 'bottom'.
631
+ // Must run AFTER node y-positions are computed.
632
+ function selectCircularLinkTypes({links}) {
633
+ const yMid = (y0 + y1) / 2;
634
+ for (const link of links) {
635
+ if (!link.circular) continue;
636
+ // Use the average vertical position of the two endpoints.
637
+ const linkMid =
638
+ (link.source.y0 + link.source.y1 + link.target.y0 + link.target.y1) / 4;
639
+ link.circularLinkType = linkMid < yMid ? "top" : "bottom";
640
+ }
641
+ }
642
+
643
+ return sankey;
644
+ }