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