@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/LICENSE +27 -0
- package/README.md +199 -0
- package/dist/d3-sankey-circular-ng.js +784 -0
- package/dist/d3-sankey-circular-ng.min.js +2 -0
- package/package.json +59 -0
- package/src/align.js +23 -0
- package/src/constant.js +5 -0
- package/src/index.js +3 -0
- package/src/sankey.js +644 -0
- package/src/sankeyLinkCircular.js +97 -0
|
@@ -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
|
+
}));
|