@s-gw/s-gw 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/.codex-plugin/plugin.json +35 -0
- package/.mcp.json +16 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +197 -0
- package/TRADEMARKS.md +9 -0
- package/assets/icons/aws-ec2.png +0 -0
- package/assets/icons/lucide/bot.svg +8 -0
- package/assets/icons/lucide/monitor.svg +5 -0
- package/assets/icons/lucide/server.svg +6 -0
- package/assets/icons/lucide/terminal.svg +4 -0
- package/assets/icons/s-gw-128.png +0 -0
- package/assets/icons/s-gw-16.png +0 -0
- package/assets/icons/s-gw-180.png +0 -0
- package/assets/icons/s-gw-192.png +0 -0
- package/assets/icons/s-gw-32.png +0 -0
- package/assets/icons/s-gw-64.png +0 -0
- package/assets/icons/s-gw-menu-bar-template.png +0 -0
- package/dist/agent-context.d.ts +17 -0
- package/dist/agent-context.js +207 -0
- package/dist/agents.d.ts +64 -0
- package/dist/agents.js +763 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1385 -0
- package/dist/command-suggest.d.ts +3 -0
- package/dist/command-suggest.js +131 -0
- package/dist/console-server.d.ts +16 -0
- package/dist/console-server.js +978 -0
- package/dist/console-ui/assets/codex-DYTPdPxi.png +0 -0
- package/dist/console-ui/assets/cursor-CBrUTJD-.png +0 -0
- package/dist/console-ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/console-ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/console-ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/console-ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/console-ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/console-ui/assets/hermes-B8hNbJPm.png +0 -0
- package/dist/console-ui/assets/index-BxUf0Sye.js +96 -0
- package/dist/console-ui/assets/index-CmTiBR_w.css +2 -0
- package/dist/console-ui/assets/omnigent-Cxa4p2Mq.png +0 -0
- package/dist/console-ui/assets/openclaw-C5wL4ZVW.png +0 -0
- package/dist/console-ui/assets/opencode-D_wFATSC.png +0 -0
- package/dist/console-ui/assets/openhands-DnrlGgev.svg +9 -0
- package/dist/console-ui/assets/s-gw-64-ByMUGQ3K.png +0 -0
- package/dist/console-ui/assets/vscode-Bdtr9eyf.png +0 -0
- package/dist/console-ui/assets/zeptoclaw-DztQW8Sw.png +0 -0
- package/dist/console-ui/index.html +13 -0
- package/dist/crypto.d.ts +6 -0
- package/dist/crypto.js +53 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +297 -0
- package/dist/gateway.d.ts +31 -0
- package/dist/gateway.js +114 -0
- package/dist/guard.d.ts +61 -0
- package/dist/guard.js +247 -0
- package/dist/install.d.ts +146 -0
- package/dist/install.js +629 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +119 -0
- package/dist/native/s-gw-core +0 -0
- package/dist/native/s-gw-keychain-helper +0 -0
- package/dist/onepassword.d.ts +48 -0
- package/dist/onepassword.js +412 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +22 -0
- package/dist/s-gw Menu Bar.app/Contents/Info.plist +28 -0
- package/dist/s-gw Menu Bar.app/Contents/MacOS/s-gw-menu-bar-helper +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/AwsEc2.png +0 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-bot.svg +8 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-monitor.svg +5 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-server.svg +6 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/Lucide-terminal.svg +4 -0
- package/dist/s-gw Menu Bar.app/Contents/Resources/MenuBarTemplate.png +0 -0
- package/dist/s-gw Menu Bar.app/Contents/_CodeSignature/CodeResources +194 -0
- package/dist/s-gw.app/Contents/Info.plist +28 -0
- package/dist/s-gw.app/Contents/MacOS/s-gw +0 -0
- package/dist/s-gw.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/s-gw.app/Contents/Resources/MenuBarTemplate.png +0 -0
- package/dist/s-gw.app/Contents/_CodeSignature/CodeResources +139 -0
- package/dist/scanner.d.ts +9 -0
- package/dist/scanner.js +437 -0
- package/dist/ssh.d.ts +31 -0
- package/dist/ssh.js +286 -0
- package/dist/store.d.ts +131 -0
- package/dist/store.js +1611 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +2 -0
- package/dist/unlock.d.ts +29 -0
- package/dist/unlock.js +274 -0
- package/dist/windows/VERSION.txt +1 -0
- package/dist/windows/s-gw-client.cmd +4 -0
- package/dist/windows/s-gw-client.ps1 +106 -0
- package/dist/windows/s-gw-credential.cmd +4 -0
- package/dist/windows/s-gw-credential.ps1 +167 -0
- package/dist/windows/s-gw-helper.cmd +4 -0
- package/dist/windows/s-gw-helper.ps1 +180 -0
- package/docs/README.md +23 -0
- package/docs/agents.md +160 -0
- package/docs/architecture.md +72 -0
- package/docs/deployment.md +447 -0
- package/docs/detection.md +44 -0
- package/docs/images/s-gw-overview.png +0 -0
- package/docs/integrations.md +195 -0
- package/docs/keychain.md +39 -0
- package/docs/onepassword.md +84 -0
- package/docs/quickstart.md +104 -0
- package/docs/threat-model.md +100 -0
- package/docs/ui/THIRD_PARTY_NOTICES.md +111 -0
- package/docs/ui/apple-touch-icon.png +0 -0
- package/docs/ui/favicon-32.png +0 -0
- package/docs/ui/local-console.html +4477 -0
- package/docs/ui/vendor/d3-sankey/d3-array.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-array.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-path.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-path.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-sankey.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-sankey.min.js +2 -0
- package/docs/ui/vendor/d3-sankey/d3-shape.LICENSE.txt +27 -0
- package/docs/ui/vendor/d3-sankey/d3-shape.min.js +2 -0
- package/docs/ui/vendor/sankeymatic/LICENSE.txt +17 -0
- package/docs/ui/vendor/sankeymatic/sankey.js +897 -0
- package/package.json +117 -0
- package/skills/s-gw/SKILL.md +19 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
d3.sankey = () => {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const sankey = {},
|
|
5
|
+
// Set up some handy constants (acting as enums)
|
|
6
|
+
// These numbers are relatively prime so each cross-product is unique
|
|
7
|
+
// (when we need that)
|
|
8
|
+
[SOURCES, TARGETS, TOP, BOTTOM, NEAREST] = [2, 3, 5, 7, 11];
|
|
9
|
+
|
|
10
|
+
// Set by inputs:
|
|
11
|
+
let nodeWidth = 9,
|
|
12
|
+
nodeHeightFactor = 0.5,
|
|
13
|
+
nodeSpacingFactor = 0.85,
|
|
14
|
+
size = { w: 1, h: 1 },
|
|
15
|
+
nodes = [],
|
|
16
|
+
flows = [],
|
|
17
|
+
rightJustifyEndpoints = false,
|
|
18
|
+
leftJustifyOrigins = false,
|
|
19
|
+
autoLayout = true,
|
|
20
|
+
attachIncompletesTo = NEAREST,
|
|
21
|
+
// Calculated:
|
|
22
|
+
stagesArr = [],
|
|
23
|
+
maximumNodeSpacing = 0,
|
|
24
|
+
actualNodeSpacing = 0,
|
|
25
|
+
maxStage = -1;
|
|
26
|
+
|
|
27
|
+
// ACCESSORS //
|
|
28
|
+
/* eslint-disable func-names */
|
|
29
|
+
sankey.nodeWidth = function (x) {
|
|
30
|
+
if (arguments.length) { nodeWidth = +x; return sankey; }
|
|
31
|
+
return nodeWidth;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
sankey.nodeHeightFactor = function (x) {
|
|
35
|
+
if (arguments.length) { nodeHeightFactor = +x; return sankey; }
|
|
36
|
+
return nodeHeightFactor;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
sankey.nodeSpacingFactor = function (x) {
|
|
40
|
+
if (arguments.length) { nodeSpacingFactor = +x; return sankey; }
|
|
41
|
+
return nodeSpacingFactor;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
sankey.nodes = function (x) {
|
|
45
|
+
if (arguments.length) { nodes = x; return sankey; }
|
|
46
|
+
return nodes;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
sankey.flows = function (x) {
|
|
50
|
+
if (arguments.length) { flows = x; return sankey; }
|
|
51
|
+
return flows;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
sankey.size = function (x) {
|
|
55
|
+
if (arguments.length) { size = x; return sankey; }
|
|
56
|
+
return size;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
sankey.rightJustifyEndpoints = function (x) {
|
|
60
|
+
if (arguments.length) { rightJustifyEndpoints = x; return sankey; }
|
|
61
|
+
return rightJustifyEndpoints;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
sankey.leftJustifyOrigins = function (x) {
|
|
65
|
+
if (arguments.length) { leftJustifyOrigins = x; return sankey; }
|
|
66
|
+
return leftJustifyOrigins;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
sankey.autoLayout = function (x) {
|
|
70
|
+
if (arguments.length) { autoLayout = x; return sankey; }
|
|
71
|
+
return autoLayout;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
sankey.attachIncompletesTo = function (x) {
|
|
75
|
+
if (arguments.length) {
|
|
76
|
+
switch (x.toLowerCase()) {
|
|
77
|
+
case 'leading': attachIncompletesTo = TOP; break;
|
|
78
|
+
case 'trailing': attachIncompletesTo = BOTTOM; break;
|
|
79
|
+
case 'nearest': attachIncompletesTo = NEAREST; break;
|
|
80
|
+
// no default
|
|
81
|
+
}
|
|
82
|
+
return sankey;
|
|
83
|
+
}
|
|
84
|
+
return attachIncompletesTo;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Getters:
|
|
88
|
+
sankey.stages = () => stagesArr;
|
|
89
|
+
|
|
90
|
+
// FUNCTIONS //
|
|
91
|
+
|
|
92
|
+
// valueSum: Add up all the 'value' keys from a list of objects:
|
|
93
|
+
function valueSum(list) { return d3.sum(list, (d) => d.value); }
|
|
94
|
+
|
|
95
|
+
// divide: Substitute MIN_VALUE if a denominator would be 0:
|
|
96
|
+
function divide(a, b) { return a / (b || Number.MIN_VALUE); }
|
|
97
|
+
|
|
98
|
+
// yCenter & yBottom: Y-position of the middle and end of a node.
|
|
99
|
+
function yCenter(n) { return n.y + n.dy / 2; }
|
|
100
|
+
function yBottom(n) { return n.y + n.dy; }
|
|
101
|
+
|
|
102
|
+
// source___/target___: return the ___ of one end of a flow:
|
|
103
|
+
function sourceTop(f) { return f.source.y + f.sy; }
|
|
104
|
+
function targetTop(f) { return f.target.y + f.ty; }
|
|
105
|
+
function sourceCenter(f) { return f.source.y + f.sy + (f.dy / 2); }
|
|
106
|
+
function targetCenter(f) { return f.target.y + f.ty + (f.dy / 2); }
|
|
107
|
+
function sourceBottom(f) { return f.source.y + f.sy + f.dy; }
|
|
108
|
+
function targetBottom(f) { return f.target.y + f.ty + f.dy; }
|
|
109
|
+
|
|
110
|
+
// Get the extreme bounds across a list of Nodes:
|
|
111
|
+
function leastY(nodeList) { return d3.min(nodeList, (n) => n.y); }
|
|
112
|
+
function greatestY(nodeList) { return d3.max(nodeList, (n) => yBottom(n)); }
|
|
113
|
+
|
|
114
|
+
// Sorting functions:
|
|
115
|
+
function bySourceOrder(a, b) { return a.sourceRow - b.sourceRow; }
|
|
116
|
+
function byTopEdges(a, b) { return a.y - b.y; }
|
|
117
|
+
|
|
118
|
+
// connectFlowsToNodes: Populate flows in & out for each node.
|
|
119
|
+
function connectFlowsToNodes() {
|
|
120
|
+
// Initialize the flow buckets:
|
|
121
|
+
nodes.forEach((n) => {
|
|
122
|
+
// Lists of flows which use this node as their target or source:
|
|
123
|
+
n.flows = { [IN]: [], [OUT]: [] };
|
|
124
|
+
// Mark these as real nodes we want to see:
|
|
125
|
+
n.isAShadow = false;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Connect each flow to its two nodes:
|
|
129
|
+
flows.forEach((f) => {
|
|
130
|
+
// When the source or target is a number, that's an index;
|
|
131
|
+
// convert it to the referenced object:
|
|
132
|
+
if (typeof f.source === 'number') { f.source = nodes[f.source]; }
|
|
133
|
+
if (typeof f.target === 'number') { f.target = nodes[f.target]; }
|
|
134
|
+
|
|
135
|
+
// Add this flow to the affected source & target:
|
|
136
|
+
f.source.flows[OUT].push(f);
|
|
137
|
+
f.target.flows[IN].push(f);
|
|
138
|
+
// By default, real flows are used when sorting/placing within a node.
|
|
139
|
+
f.useForVisiblePlacing = true;
|
|
140
|
+
// Mark these as real flows we want to see:
|
|
141
|
+
f.isAShadow = false;
|
|
142
|
+
f.hasAShadow = false;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// computeNodeValues: Compute the value of each node by summing the
|
|
147
|
+
// associated flows:
|
|
148
|
+
function computeNodeValues() {
|
|
149
|
+
nodes.forEach((n) => {
|
|
150
|
+
// Remember the totals in & out:
|
|
151
|
+
n.total = { [IN]: valueSum(n.flows[IN]), [OUT]: valueSum(n.flows[OUT]) };
|
|
152
|
+
// Each node's value will be the greater of the two (or else the
|
|
153
|
+
// smallest positive value):
|
|
154
|
+
n.value = Math.max(n.total[IN], n.total[OUT], Number.MIN_VALUE);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// allFlowStats(nodeList): provides all components necessary to make
|
|
159
|
+
// weighted-center calculations. These are used to decide where a
|
|
160
|
+
// group of nodes would ideally 'want' to be.
|
|
161
|
+
function allFlowStats(nodeList) {
|
|
162
|
+
// flowSetStats: get the total weight+value from a group of flows
|
|
163
|
+
function flowSetStats(whichFlows) {
|
|
164
|
+
// Get every flow touching one side & treat them as one list:
|
|
165
|
+
const flowList
|
|
166
|
+
= nodeList
|
|
167
|
+
.map((n) => n.flows[whichFlows])
|
|
168
|
+
.flat()
|
|
169
|
+
// Use the weighted value of a flow (this handles shadows):
|
|
170
|
+
.filter((f) => f.weightedValue > 0);
|
|
171
|
+
// If 0 flows, return enough structure to satisfy the caller:
|
|
172
|
+
if (flowList.length === 0) {
|
|
173
|
+
return { value: 0, sources: { weight: 0 }, targets: { weight: 0 } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
value: d3.sum(flowList, (f) => f.weightedValue),
|
|
178
|
+
sources: {
|
|
179
|
+
weight: d3.sum(flowList, (f) => sourceCenter(f) * f.weightedValue),
|
|
180
|
+
maxSourceStage: d3.max(flowList, (f) => f.source.stage),
|
|
181
|
+
},
|
|
182
|
+
targets: {
|
|
183
|
+
weight: d3.sum(flowList, (f) => targetCenter(f) * f.weightedValue),
|
|
184
|
+
minTargetStage: d3.min(flowList, (f) => f.target.stage),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Return the stats for the set of all flows touching these nodes:
|
|
190
|
+
return { [IN]: flowSetStats(IN), [OUT]: flowSetStats(OUT) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// placeFlowsInsideNodes(nodeList):
|
|
194
|
+
// Compute the y-offset of every flow's source and target endpoints,
|
|
195
|
+
// relative to the each node's y-position.
|
|
196
|
+
function placeFlowsInsideNodes(nodeList) {
|
|
197
|
+
// sortFlows(node, placing):
|
|
198
|
+
// Given a node & a side, reorder that group of flows as best we can.
|
|
199
|
+
// 'placing' indicates which end of the flows we're working on here:
|
|
200
|
+
// - TARGETS = we're placing the targets of n.flows[IN]
|
|
201
|
+
// - SOURCES = we're placing the sources of n.flows[OUT]
|
|
202
|
+
function sortFlows(n, placing) {
|
|
203
|
+
const dir = placing === TARGETS ? IN : OUT,
|
|
204
|
+
fStats = allFlowStats([n]),
|
|
205
|
+
[flowsToSort, totalFlowValue] = [n.flows[dir], n.total[dir]],
|
|
206
|
+
totalFlowWeight
|
|
207
|
+
= (dir === IN ? fStats[IN].sources : fStats[OUT].targets).weight,
|
|
208
|
+
// Make a Set of flow IDs we can delete from as we go:
|
|
209
|
+
flowsRemaining = new Set(flowsToSort.map((f) => f.index)),
|
|
210
|
+
// Calculate how tall the flow group is which will attach to this
|
|
211
|
+
// node (may be less than n.dy):
|
|
212
|
+
totalFlowSpan = d3.sum(
|
|
213
|
+
// Only count the space which is needed for visible flows (when
|
|
214
|
+
// the node is real) OR for flows meeting a shadow node:
|
|
215
|
+
flowsToSort.filter((f) => !f.isAShadow || n.isAShadow),
|
|
216
|
+
(f) => f.dy
|
|
217
|
+
),
|
|
218
|
+
// Attach flows to the *top* of the range, *except* when:
|
|
219
|
+
// the entire node's value is not all flowing somewhere, AND
|
|
220
|
+
// - The caller says to attach them to the bottom, OR
|
|
221
|
+
// - The caller says to use the 'nearest' end AND
|
|
222
|
+
// - the center-of-all-attached-flows is below the node's
|
|
223
|
+
// own center.
|
|
224
|
+
flowPosition
|
|
225
|
+
= totalFlowValue < n.value
|
|
226
|
+
&& (attachIncompletesTo === BOTTOM
|
|
227
|
+
|| (attachIncompletesTo === NEAREST
|
|
228
|
+
&& divide(totalFlowWeight, totalFlowValue) > yCenter(n)))
|
|
229
|
+
? BOTTOM
|
|
230
|
+
: TOP,
|
|
231
|
+
// upper/lower bounds = the range where flows may attach
|
|
232
|
+
bounds
|
|
233
|
+
= flowPosition === TOP
|
|
234
|
+
? { upper: n.y, lower: n.y + totalFlowSpan }
|
|
235
|
+
: { upper: yBottom(n) - totalFlowSpan, lower: yBottom(n) };
|
|
236
|
+
// Reminder: In SVG-land, y-axis coordinates are inverted...
|
|
237
|
+
// "upper" & "lower" are meant visually here, not numerically.
|
|
238
|
+
|
|
239
|
+
// placeFlow(f, y): Update a flow's position
|
|
240
|
+
function placeFlow(f, newTopY) {
|
|
241
|
+
// Is the flow actually in the queue? Exit if not. (This can happen
|
|
242
|
+
// when we're placing a shadow flow and offer to update the original
|
|
243
|
+
// flow's Y, but it's in some other stage.)
|
|
244
|
+
if (!flowsRemaining.has(f.index)) { return; }
|
|
245
|
+
// sy & ty (source/target y) are the vertical *offsets* at each end
|
|
246
|
+
// of a flow, determining where below the node's top edge the flow's
|
|
247
|
+
// top will meet.
|
|
248
|
+
if (placing === TARGETS) {
|
|
249
|
+
f.ty = newTopY - f.target.y;
|
|
250
|
+
} else {
|
|
251
|
+
f.sy = newTopY - f.source.y;
|
|
252
|
+
}
|
|
253
|
+
// Drop the flow we just placed from the queue:
|
|
254
|
+
flowsRemaining.delete(f.index);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// placeFlowAt(edge, fIndex):
|
|
258
|
+
// Update the bound, set this flow's offset, update the queue.
|
|
259
|
+
function placeFlowAt(edge, fIndex) {
|
|
260
|
+
const f = flows[fIndex];
|
|
261
|
+
let newY = 0;
|
|
262
|
+
if (edge === TOP) {
|
|
263
|
+
newY = bounds.upper;
|
|
264
|
+
// If this is real, move the upper bound DOWN.
|
|
265
|
+
if (f.useForVisiblePlacing || n.isAShadow) { bounds.upper += f.dy; }
|
|
266
|
+
} else { // edge === BOTTOM
|
|
267
|
+
// Make room at the bottom of the range for this flow:
|
|
268
|
+
newY = bounds.lower - f.dy;
|
|
269
|
+
// If this is real, move the lower bound UP to match:
|
|
270
|
+
if (f.useForVisiblePlacing || n.isAShadow) { bounds.lower = newY; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Put the flow where we just decided & drop it from the queue:
|
|
274
|
+
placeFlow(f, newY);
|
|
275
|
+
|
|
276
|
+
if (f.useForVisiblePlacing && f.isAShadow) {
|
|
277
|
+
// If this flow should be used for placing a real one AND is a
|
|
278
|
+
// shadow flow, then copy its new position to the true flow & drop
|
|
279
|
+
// that other flow from the queue too:
|
|
280
|
+
placeFlow(flows[f.shadowOf], newY);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// slopeData keys are the product of an 'edge' & a 'placing' value:
|
|
285
|
+
const slopeData = {
|
|
286
|
+
[TOP * TARGETS]: { f: (f) => (bounds.upper - sourceTop(f)) / f.dx, dir: -1 },
|
|
287
|
+
[TOP * SOURCES]: { f: (f) => (targetTop(f) - bounds.upper) / f.dx, dir: 1 },
|
|
288
|
+
[BOTTOM * TARGETS]: { f: (f) => (bounds.lower - sourceBottom(f)) / f.dx, dir: 1 },
|
|
289
|
+
[BOTTOM * SOURCES]: { f: (f) => (targetBottom(f) - bounds.lower) / f.dx, dir: -1 },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// placeUnhappiestFlowAt(edge):
|
|
293
|
+
// Figure out which flow is worst off (slope-wise) and place it.
|
|
294
|
+
// edge = TOP or BOTTOM
|
|
295
|
+
function placeUnhappiestFlowAt(edge) {
|
|
296
|
+
// The queue may have been drained early. Guard against that:
|
|
297
|
+
if (!flowsRemaining.size) { return; }
|
|
298
|
+
const sKey = edge * placing,
|
|
299
|
+
slopeOf = slopeData[sKey].f,
|
|
300
|
+
// flowIndex = the ID of the unhappiest flow
|
|
301
|
+
flowIndex = Array.from(flowsRemaining)
|
|
302
|
+
// Exclude flows with shadows; they'll get their position
|
|
303
|
+
// assigned when their shadow gets placed:
|
|
304
|
+
.filter((i) => !flows[i].hasAShadow)
|
|
305
|
+
.sort((a, b) => (
|
|
306
|
+
// For autolayout, use the right slopes in the correct order (asc/dsc):
|
|
307
|
+
autoLayout
|
|
308
|
+
? (slopeData[sKey].dir * (slopeOf(flows[a]) - slopeOf(flows[b]))
|
|
309
|
+
// If there is a tie, sort by x-distance (ascending):
|
|
310
|
+
|| flows[a].dx - flows[b].dx)
|
|
311
|
+
: 0)
|
|
312
|
+
// If we are using exact order (OR if there is still a tie),
|
|
313
|
+
// sort by sourceRow (which is also set for shadow flows)
|
|
314
|
+
|| flows[a].sourceRow - flows[b].sourceRow)[0];
|
|
315
|
+
// If we found a flow, place it at the correct edge:
|
|
316
|
+
if (flowIndex !== undefined) { placeFlowAt(edge, flowIndex); }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Loop through the flow set, placing them from the outside in.
|
|
320
|
+
// If there are at least 2 flows to be placed, we figure out which is
|
|
321
|
+
// best suited to occupy the top & bottom edge spots.
|
|
322
|
+
// After placing those, the remaining range is reduced & we repeat.
|
|
323
|
+
while (flowsRemaining.size > 1) {
|
|
324
|
+
// Place the least fortunate flows, then subtract their size from
|
|
325
|
+
// the available range:
|
|
326
|
+
placeUnhappiestFlowAt(TOP);
|
|
327
|
+
if (autoLayout) { placeUnhappiestFlowAt(BOTTOM); }
|
|
328
|
+
// (If using exact order, we want to place top->bottom, NOT alternate.)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// After that loop, we have 0-1 flows. If there is one, place it:
|
|
332
|
+
flowsRemaining.forEach((i) => placeFlowAt(TOP, i));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// We have the utility functions defined now; time to actually use them.
|
|
336
|
+
|
|
337
|
+
// First, update the x-distance (dx) values for all flows -- they may
|
|
338
|
+
// have moved since their initial placement, due to drags. Two notes:
|
|
339
|
+
// 1) We use the *absolute* value of the x-distance, so even when a node
|
|
340
|
+
// is dragged to the opposite side of a connected node, the ordering
|
|
341
|
+
// will remain stable.
|
|
342
|
+
// 2) Denominator dx must not be 0, so MIN_VALUE is substituted if needed.
|
|
343
|
+
flows.forEach((f) => {
|
|
344
|
+
f.dx = Math.abs(f.target.x - f.source.x) || Number.MIN_VALUE;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Gather all the distinct batches of flows we'll need to process (each
|
|
348
|
+
// node may have 0-2 batches):
|
|
349
|
+
const flowBatches = [
|
|
350
|
+
...nodeList.filter((n) => n.flows[IN].length)
|
|
351
|
+
.map((n) => (
|
|
352
|
+
{ i: n.index, len: n.flows[IN].length, placing: TARGETS }
|
|
353
|
+
)),
|
|
354
|
+
...nodeList.filter((n) => n.flows[OUT].length)
|
|
355
|
+
.map((n) => (
|
|
356
|
+
{ i: n.index, len: n.flows[OUT].length, placing: SOURCES }
|
|
357
|
+
)),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
// Sort the flow batches so that we start with those having the FEWEST
|
|
361
|
+
// flows and work upward.
|
|
362
|
+
// Reason: a 1-flow placement is certain; a 2-flow set is simple; etc.
|
|
363
|
+
// By settling easier cases first, the harder cases end up with fewer
|
|
364
|
+
// wild possibilities for how they may be arranged.
|
|
365
|
+
flowBatches.sort((a, b) => a.len - b.len)
|
|
366
|
+
// Finally: Go through every batch & sort its flows anew:
|
|
367
|
+
.forEach((fBatch) => { sortFlows(nodes[fBatch.i], fBatch.placing); });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// assignNodesToStages: Iteratively assign the stage (x-group) for each node.
|
|
371
|
+
// Nodes are assigned the maximum stage of their incoming neighbors + 1,
|
|
372
|
+
// then any nodes which can be nudged forward are.
|
|
373
|
+
function assignNodesToStages() {
|
|
374
|
+
const nodesToCheckAgain = new Set();
|
|
375
|
+
// updateNode: Set a node's stage & make sure its targets get another look.
|
|
376
|
+
function updateNode(n) {
|
|
377
|
+
n.stage = maxStage;
|
|
378
|
+
n.flows[OUT].forEach((f) => { nodesToCheckAgain.add(f.target); });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Work from left to right.
|
|
382
|
+
// Assign every node to stage 0, then keep updating the stage of every node
|
|
383
|
+
// that was a target of a known node. Repeat and fade.
|
|
384
|
+
let nodesToPlace = nodes;
|
|
385
|
+
// The maxStage check is to avoid an infinite loop when there is a cycle:
|
|
386
|
+
while (nodesToPlace.length && maxStage < nodes.length - 1) {
|
|
387
|
+
maxStage += 1;
|
|
388
|
+
nodesToPlace.forEach((n) => updateNode(n));
|
|
389
|
+
nodesToPlace = Array.from(nodesToCheckAgain);
|
|
390
|
+
nodesToCheckAgain.clear();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Pull any source nodes to the right which have room to move.
|
|
394
|
+
// First, get a COPY of the list of all nodes with targets:
|
|
395
|
+
nodes
|
|
396
|
+
.filter((n) => n.flows[OUT].length).slice()
|
|
397
|
+
.sort((a, b) => b.stage - a.stage) // Sort that by stage, descending
|
|
398
|
+
.forEach((n) => {
|
|
399
|
+
// Find n's minimum target stage and use the one right before that:
|
|
400
|
+
const maxNewStage = d3.min(n.flows[OUT], (f) => f.target.stage) - 1;
|
|
401
|
+
if (n.stage < maxNewStage) { n.stage = maxNewStage; }
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Handle layout checkboxes:
|
|
405
|
+
function setStageWhenNoFlows(direction, newStage) {
|
|
406
|
+
// For nodes with no flows going {direction}...
|
|
407
|
+
nodes.filter((n) => !n.flows[direction].length)
|
|
408
|
+
// ...set their stages to newStage:
|
|
409
|
+
.forEach((n) => { n.stage = newStage; });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Force origins to appear all the way to the left?
|
|
413
|
+
if (leftJustifyOrigins) { setStageWhenNoFlows(IN, 0); }
|
|
414
|
+
|
|
415
|
+
// Force endpoints all the way to the right?
|
|
416
|
+
if (rightJustifyEndpoints) { setStageWhenNoFlows(OUT, maxStage); }
|
|
417
|
+
|
|
418
|
+
// Now that the main nodes and flows are in place, we also fill in
|
|
419
|
+
// SHADOW nodes & flows to occupy space whenever stages are skipped.
|
|
420
|
+
// To get started, fill in the 'ds' (stage distance) for all flows:
|
|
421
|
+
flows.forEach((f) => { f.ds = f.target.stage - f.source.stage; });
|
|
422
|
+
|
|
423
|
+
// Next, operate on flows which cross more than one stage:
|
|
424
|
+
const shadowNodeNames = new Map();
|
|
425
|
+
flows
|
|
426
|
+
.filter((f) => Math.abs(f.ds) > 1)
|
|
427
|
+
.forEach((f) => {
|
|
428
|
+
const nodesForThisFlow = [f.source];
|
|
429
|
+
// Duplicate the source node as many times as needed (though only
|
|
430
|
+
// as large as this individual flow)
|
|
431
|
+
for (let i = 1; i < f.ds; i += 1) {
|
|
432
|
+
const shadowStage = f.source.stage + i,
|
|
433
|
+
// Create a custom name for the shadow which will still group
|
|
434
|
+
// multiple flows between the same 2 places.
|
|
435
|
+
newNodeName
|
|
436
|
+
= `sh_${f.source.index}_${f.target.index}_s${shadowStage}`,
|
|
437
|
+
fVal = Number(f.value);
|
|
438
|
+
let shadowNode;
|
|
439
|
+
// Have we already made a shadow node for this source/target?
|
|
440
|
+
if (shadowNodeNames.has(newNodeName)) {
|
|
441
|
+
// If so, let's add value to the node we've already made:
|
|
442
|
+
shadowNode = nodes[shadowNodeNames.get(newNodeName)];
|
|
443
|
+
shadowNode.value += fVal;
|
|
444
|
+
shadowNode.total[IN] += fVal;
|
|
445
|
+
shadowNode.total[OUT] += fVal;
|
|
446
|
+
} else {
|
|
447
|
+
// A shadow node doesn't exist, so we make a fresh one with the
|
|
448
|
+
// same sourceRow as the original flow:
|
|
449
|
+
shadowNode = {
|
|
450
|
+
index: nodes.length,
|
|
451
|
+
stage: shadowStage,
|
|
452
|
+
name: newNodeName,
|
|
453
|
+
sourceRow: f.sourceRow,
|
|
454
|
+
isAShadow: true,
|
|
455
|
+
flows: { [IN]: [], [OUT]: [] },
|
|
456
|
+
total: { [IN]: fVal, [OUT]: fVal },
|
|
457
|
+
value: fVal,
|
|
458
|
+
};
|
|
459
|
+
// Add this to the big list and to our shadow-tracking list:
|
|
460
|
+
nodes.push(shadowNode);
|
|
461
|
+
shadowNodeNames.set(newNodeName, shadowNode.index);
|
|
462
|
+
}
|
|
463
|
+
nodesForThisFlow.push(shadowNode);
|
|
464
|
+
}
|
|
465
|
+
nodesForThisFlow.push(f.target);
|
|
466
|
+
|
|
467
|
+
// Now that we have a list of all nodes along the way, add shadow
|
|
468
|
+
// flows between each pair (starting from the 2nd item in the list).
|
|
469
|
+
for (let i = 1; i < nodesForThisFlow.length; i += 1) {
|
|
470
|
+
const sourceNode = nodesForThisFlow[i - 1],
|
|
471
|
+
targetNode = nodesForThisFlow[i],
|
|
472
|
+
origSourceRow = Number(f.sourceRow),
|
|
473
|
+
// Take values from the original flow, then override some:
|
|
474
|
+
newFlow = {
|
|
475
|
+
...f,
|
|
476
|
+
source: sourceNode,
|
|
477
|
+
target: targetNode,
|
|
478
|
+
index: flows.length,
|
|
479
|
+
shadowOf: f.index,
|
|
480
|
+
isAShadow: true,
|
|
481
|
+
hasAShadow: false,
|
|
482
|
+
// Make artificial sourceRow numbers so these get prioritized
|
|
483
|
+
// *with* the original flow:
|
|
484
|
+
sourceRow: origSourceRow + i / (f.ds + 1),
|
|
485
|
+
// Should we propagate this shadow's y position to the original
|
|
486
|
+
// flow? Only at the ends of the shadow path.
|
|
487
|
+
useForVisiblePlacing:
|
|
488
|
+
sourceNode.stage === f.source.stage
|
|
489
|
+
|| targetNode.stage === f.target.stage,
|
|
490
|
+
};
|
|
491
|
+
flows.push(newFlow);
|
|
492
|
+
newFlow.source.flows[OUT].push(newFlow);
|
|
493
|
+
newFlow.target.flows[IN].push(newFlow);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Now that we're done adopting various values from original flow f,
|
|
497
|
+
// tell f itself that Things have Changed:
|
|
498
|
+
f.useForVisiblePlacing = false;
|
|
499
|
+
f.hasAShadow = true;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Set up stagesArr: one array element for each stage, containing that
|
|
504
|
+
// stage's nodes, in stage order.
|
|
505
|
+
// This can also be called when nodes' info may have been updated elsewhere
|
|
506
|
+
// & we need a fresh map generated.
|
|
507
|
+
function updateStagesArray() {
|
|
508
|
+
stagesArr = d3.groups(nodes, (d) => d.stage) // [stage, [nodes]]
|
|
509
|
+
.sort((a, b) => a[0] - b[0])
|
|
510
|
+
// Extract each stage and sort its nodes by sourceRow.
|
|
511
|
+
// (This raises shadow nodes to the same rank the original flow is at)
|
|
512
|
+
.map((d) => d[1].sort(bySourceOrder)); // [[nodes]]
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// placeNodes(iterations):
|
|
516
|
+
// Set (and then adjust) the y-position for each node and flow, based
|
|
517
|
+
// on their connections to other points in the diagram.
|
|
518
|
+
function placeNodes(iterations) {
|
|
519
|
+
// nodeSetStats(nodeList):
|
|
520
|
+
// Get the total weight+value from an assortment of Nodes.
|
|
521
|
+
// The Nodes are expected to all be in the same Stage.
|
|
522
|
+
function nodeSetStats(nodeList) {
|
|
523
|
+
const weight = d3.sum(nodeList, (n) => yCenter(n) * n.value),
|
|
524
|
+
value = valueSum(nodeList);
|
|
525
|
+
return {
|
|
526
|
+
stage: nodeList[0].stage,
|
|
527
|
+
weight: weight,
|
|
528
|
+
value: value,
|
|
529
|
+
center: divide(weight, value),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Set up the scaling factor and the initial x & y of all the Nodes:
|
|
534
|
+
function initializeNodePositions() {
|
|
535
|
+
// First, calculate the spacing values.
|
|
536
|
+
// How many nodes are in the 'busiest' stage?
|
|
537
|
+
const greatestNodeCount = d3.max(stagesArr, (s) => s.length);
|
|
538
|
+
|
|
539
|
+
let ky = 0;
|
|
540
|
+
// Special case: What if there's only one node in every stage?
|
|
541
|
+
// That calculation is very different:
|
|
542
|
+
if (greatestNodeCount === 1) {
|
|
543
|
+
[maximumNodeSpacing, actualNodeSpacing] = [0, 0];
|
|
544
|
+
ky = nodeHeightFactor
|
|
545
|
+
* d3.min(stagesArr, (s) => divide(size.h, valueSum(s)));
|
|
546
|
+
} else {
|
|
547
|
+
// What if each node in the busiest stage got 1 pixel?
|
|
548
|
+
// Figure out how many pixels would be left over.
|
|
549
|
+
// (If pixels < 2, use 2; otherwise the slider has nothing to do.)
|
|
550
|
+
const allAvailablePadding = Math.max(2, size.h - greatestNodeCount);
|
|
551
|
+
|
|
552
|
+
// A nodeHeightFactor of 0 means: 'pad as much as possible
|
|
553
|
+
// without making any node less than 1 pixel tall'.
|
|
554
|
+
// Formula for the initial spacing value when nHF = 0:
|
|
555
|
+
// allAvailablePadding / (# of spaces in the busiest stage)
|
|
556
|
+
maximumNodeSpacing
|
|
557
|
+
= ((1 - nodeHeightFactor) * allAvailablePadding)
|
|
558
|
+
/ (greatestNodeCount - 1);
|
|
559
|
+
actualNodeSpacing = maximumNodeSpacing * nodeSpacingFactor;
|
|
560
|
+
// Finally, calculate the vertical scaling factor for all
|
|
561
|
+
// nodes, given maximumNodeSpacing & the diagram's height:
|
|
562
|
+
ky = d3.min(
|
|
563
|
+
stagesArr,
|
|
564
|
+
(s) => divide(size.h - (s.length - 1) * maximumNodeSpacing, valueSum(s))
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (ky === Infinity) { ky = 1; } // This happens if all Node values are 0
|
|
568
|
+
|
|
569
|
+
// Compute all the dy & weighted values using the now-known scale
|
|
570
|
+
// of the graph:
|
|
571
|
+
flows.forEach((f) => {
|
|
572
|
+
f.dy = f.value * ky;
|
|
573
|
+
f.weightedValue = f.hasAShadow ? 0 : f.value;
|
|
574
|
+
});
|
|
575
|
+
// Also: Ensure each node has a nonzero height:
|
|
576
|
+
nodes.forEach((n) => {
|
|
577
|
+
n.dy = Math.max(n.value * ky, Number.MIN_VALUE);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Set the initial positions of all nodes within each stage.
|
|
581
|
+
// The initial stage will start with all nodes centered vertically,
|
|
582
|
+
// separated by the actualNodeSpacing.
|
|
583
|
+
// Each stage afterwards will center on its combined source nodes.
|
|
584
|
+
let targetY;
|
|
585
|
+
stagesArr.forEach((s, stageIndex) => {
|
|
586
|
+
const stageSize
|
|
587
|
+
= (valueSum(s) * ky) + (actualNodeSpacing * (s.length - 1));
|
|
588
|
+
targetY = size.h / 2; // default case = center this batch of nodes
|
|
589
|
+
// If we have any flows into the current set of nodes, we have a
|
|
590
|
+
// chicken/egg problem: We want to use weighted centers based on
|
|
591
|
+
// flows (i.e. flowSetStats), but at this point 0 flows are placed.
|
|
592
|
+
// Simpler approach: use the weighted center of nodes flowing in.
|
|
593
|
+
const allFlowsIn = s.map((n) => n.flows[IN]).flat();
|
|
594
|
+
if (allFlowsIn.length > 0) {
|
|
595
|
+
const uniqueSourceNodes = new Set(
|
|
596
|
+
allFlowsIn.map((f) => f.source)
|
|
597
|
+
// Since shadows are in every stage, don't look back more than
|
|
598
|
+
// 1 stage. (And self-loops may mean there are flows from the
|
|
599
|
+
// *same* stage, currently.)
|
|
600
|
+
.filter((n) => n.stage >= stageIndex - 1)
|
|
601
|
+
);
|
|
602
|
+
targetY = nodeSetStats(Array.from(uniqueSourceNodes)).center;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Calculate the first-node-in-this-stage's y position (while not
|
|
606
|
+
// letting it be placed where the stage will exceed either boundary):
|
|
607
|
+
let nextNodePos
|
|
608
|
+
= Math.max(
|
|
609
|
+
0,
|
|
610
|
+
Math.min(targetY - (stageSize / 2), size.h - stageSize)
|
|
611
|
+
);
|
|
612
|
+
s.forEach((n) => {
|
|
613
|
+
n.y = nextNodePos;
|
|
614
|
+
// Find the y position of the next node:
|
|
615
|
+
nextNodePos = yBottom(n) + actualNodeSpacing;
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Set up x-values too.
|
|
620
|
+
// Apply a scaling factor based on width per stage:
|
|
621
|
+
const widthPerStage = maxStage > 0 ? (size.w - nodeWidth) / maxStage : 0;
|
|
622
|
+
nodes.forEach((n) => {
|
|
623
|
+
n.x = widthPerStage * n.stage;
|
|
624
|
+
n.dx = nodeWidth;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// With nodes placed, we *also* have to provide an initial
|
|
628
|
+
// placement for all flows, so that their weights can be measured
|
|
629
|
+
// realistically in the placeNodes() routine.
|
|
630
|
+
nodes.forEach((n) => {
|
|
631
|
+
// Each flow is initially placed naively, just using the input order.
|
|
632
|
+
// Any misfires will be corrected soon by placeFlowsInsideNodes()
|
|
633
|
+
let [sy, ty] = [0, 0];
|
|
634
|
+
// Shadows touching a real node adopt the same position as their
|
|
635
|
+
// 'true' flow. (NOTE: This works because all shadows initially
|
|
636
|
+
// *follow* all real flows.):
|
|
637
|
+
n.flows[OUT].forEach((f) => {
|
|
638
|
+
if (f.isAShadow && !n.isAShadow) {
|
|
639
|
+
f.sy = flows[f.shadowOf].sy;
|
|
640
|
+
} else {
|
|
641
|
+
f.sy = sy; sy += f.dy;
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
n.flows[IN].forEach((f) => {
|
|
645
|
+
if (f.isAShadow && !n.isAShadow) {
|
|
646
|
+
f.ty = flows[f.shadowOf].ty;
|
|
647
|
+
} else {
|
|
648
|
+
f.ty = ty; ty += f.dy;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// findNodeGroupOffset(nodeList):
|
|
655
|
+
// Figure out where these Nodes want to be, and return the
|
|
656
|
+
// appropriate y-offset value.
|
|
657
|
+
function findNodeGroupOffset(nodeList) {
|
|
658
|
+
// The population of flows to test = the combination of every
|
|
659
|
+
// last flow touching this group of Nodes:
|
|
660
|
+
const fStats = allFlowStats(nodeList),
|
|
661
|
+
totalIn = fStats[IN].value,
|
|
662
|
+
totalOut = fStats[OUT].value;
|
|
663
|
+
// If there are no flows touching *either* side here, there's nothing
|
|
664
|
+
// to offset ourselves relative to, so we can exit early:
|
|
665
|
+
if (totalIn === 0 && totalOut === 0) { return 0; }
|
|
666
|
+
|
|
667
|
+
const nStats = nodeSetStats(nodeList),
|
|
668
|
+
// projectedSourceCenter =
|
|
669
|
+
// the current Node group's weighted center
|
|
670
|
+
// MINUS the weighted center of incoming Flows' targets
|
|
671
|
+
// PLUS the weighted center of incoming Flows' sources.
|
|
672
|
+
// Thought exercise:
|
|
673
|
+
// If 100% of the value of the Node group is flowing in, then this is
|
|
674
|
+
// exactly equivalent to: *the weighted center of all sources*.
|
|
675
|
+
projectedSourceCenter = divide(
|
|
676
|
+
nStats.weight - fStats[IN].targets.weight + fStats[IN].sources.weight,
|
|
677
|
+
nStats.value
|
|
678
|
+
),
|
|
679
|
+
// projectedTargetCenter = the same idea in the other direction:
|
|
680
|
+
// current Node group's weighted center
|
|
681
|
+
// - outgoing weights' center
|
|
682
|
+
// + final center of those weights
|
|
683
|
+
projectedTargetCenter = divide(
|
|
684
|
+
nStats.weight - fStats[OUT].sources.weight + fStats[OUT].targets.weight,
|
|
685
|
+
nStats.value
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
// Time to do the positioning calculations.
|
|
689
|
+
let goalY = 0;
|
|
690
|
+
if (totalOut === 0) {
|
|
691
|
+
// If we have only in-flows, it's simple:
|
|
692
|
+
// Center the current group relative only to its sources.
|
|
693
|
+
goalY = projectedSourceCenter;
|
|
694
|
+
} else if (totalIn === 0) {
|
|
695
|
+
// Only out-flows? Center this group on its targets:
|
|
696
|
+
goalY = projectedTargetCenter;
|
|
697
|
+
} else {
|
|
698
|
+
// There are flows both in & out. Find the slope between the centers:
|
|
699
|
+
const startStage = fStats[IN].sources.maxSourceStage,
|
|
700
|
+
endStage = fStats[OUT].targets.minTargetStage,
|
|
701
|
+
stageDistance = endStage - startStage,
|
|
702
|
+
slopeBetweenCenters
|
|
703
|
+
= stageDistance !== 0 // Avoid divide-by-0 error
|
|
704
|
+
? (projectedTargetCenter - projectedSourceCenter) / stageDistance
|
|
705
|
+
: 0;
|
|
706
|
+
// Where along that line should this current group be centered?
|
|
707
|
+
goalY
|
|
708
|
+
= projectedSourceCenter
|
|
709
|
+
+ (nStats.stage - startStage) * slopeBetweenCenters;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// We have a goal Y value! Return the offset from the current center:
|
|
713
|
+
return goalY - nStats.center;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// updateStageCentering(stage):
|
|
717
|
+
// Make sure nodes are spaced far enough apart from each other,
|
|
718
|
+
// AND, after some have been nudged apart, put those
|
|
719
|
+
// now-locked-together groups of nodes in the best available
|
|
720
|
+
// position given their group's *overall* connections in & out.
|
|
721
|
+
function updateStageCentering(s) {
|
|
722
|
+
// enforceValidNodePositions():
|
|
723
|
+
// Make sure this stage doesn't extend past either the top or
|
|
724
|
+
// bottom, and preserve the required spacing between nodes.
|
|
725
|
+
function enforceValidNodePositions() {
|
|
726
|
+
// Nudge down any nodes which are past the top:
|
|
727
|
+
let yPos = 0; // = the current available y closest to the top
|
|
728
|
+
s.forEach((n) => {
|
|
729
|
+
// If this node's top is above yPos, nudge the node down:
|
|
730
|
+
if (n.y < yPos) { n.y = yPos; }
|
|
731
|
+
// Set yPos to the next available y toward the bottom:
|
|
732
|
+
yPos = yBottom(n) + actualNodeSpacing;
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ... if we've gone *past* the bottom, bump nodes back up.
|
|
736
|
+
yPos = size.h; // = the current available y closest to the bottom
|
|
737
|
+
s.slice().reverse().forEach((n) => {
|
|
738
|
+
// if this node's bottom is below yPos, nudge it up:
|
|
739
|
+
if (yBottom(n) > yPos) { n.y = yPos - n.dy; }
|
|
740
|
+
// Set yPos to the next available y toward the top:
|
|
741
|
+
yPos = n.y - actualNodeSpacing;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// nodesAreAdjacent: Given two nodes *in height order*, is the top of n2
|
|
746
|
+
// bumping up against n1's bottom edge?
|
|
747
|
+
function nodesAreAdjacent(n1, n2) {
|
|
748
|
+
// Is the bottom of the 1st node + the node spacing essentially
|
|
749
|
+
// the same as the 2nd node's top? (i.e. within a tenth of a 'pixel')
|
|
750
|
+
return (n2.y - actualNodeSpacing - yBottom(n1)) < 0.1;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function centerNeighborGroups() {
|
|
754
|
+
// First, Gather groups of neighbors. This loop produces arrays
|
|
755
|
+
// of 1 or more nodes which need to be nudged together.
|
|
756
|
+
const neighborGroups = [];
|
|
757
|
+
s.forEach((n, i) => {
|
|
758
|
+
// Can we include this node as a neighbor of its predecessor?
|
|
759
|
+
if (i > 0 && nodesAreAdjacent(s[i - 1], n)) {
|
|
760
|
+
// Yes? Then append it to the 'current' group:
|
|
761
|
+
const lastGroup = neighborGroups.length - 1;
|
|
762
|
+
neighborGroups[lastGroup].push(n);
|
|
763
|
+
} else {
|
|
764
|
+
// No? Start a new group:
|
|
765
|
+
neighborGroups.push([n]);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// At this point we *may* have node groups which need nudges.
|
|
770
|
+
// For each multi-node group, find the weighted center of its
|
|
771
|
+
// sources/targets, and place that group's center along that
|
|
772
|
+
// line:
|
|
773
|
+
neighborGroups.filter((g) => g.length > 1)
|
|
774
|
+
.forEach((nodeGroup) => {
|
|
775
|
+
// Apply the offset to the entire node group:
|
|
776
|
+
const yOffset = findNodeGroupOffset(nodeGroup);
|
|
777
|
+
nodeGroup.forEach((n) => { n.y += yOffset; });
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// First, sort this stage's nodes based on either their current
|
|
782
|
+
// positions or on the order they appeared in the data:
|
|
783
|
+
s.sort(autoLayout ? byTopEdges : bySourceOrder);
|
|
784
|
+
|
|
785
|
+
// Make sure any overlapping nodes preserve the required spacing.
|
|
786
|
+
// Run the first nudge of all to see what bumps against each other:
|
|
787
|
+
enforceValidNodePositions();
|
|
788
|
+
|
|
789
|
+
// Look for sets of neighbors and center them as best we can:
|
|
790
|
+
centerNeighborGroups();
|
|
791
|
+
// Make sure we're still on the canvas:
|
|
792
|
+
enforceValidNodePositions();
|
|
793
|
+
|
|
794
|
+
// Since we may have just created more neighbors, iterate 1 more time:
|
|
795
|
+
centerNeighborGroups();
|
|
796
|
+
enforceValidNodePositions();
|
|
797
|
+
// We could keep doing more rounds! But have to stop somewhere.
|
|
798
|
+
// Someday I hope to update this to notice when we've either:
|
|
799
|
+
// 1) stopped bumping into more nodes, or else
|
|
800
|
+
// 2) reached the maximum group (all nodes in 1 neighbor group)
|
|
801
|
+
// For now, this will do.
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// processStages(stageList, factor):
|
|
805
|
+
// Iterate over a list of stages in the given order, moving Nodes
|
|
806
|
+
// and Flows around according to the given factor (which proceeds
|
|
807
|
+
// from 0.99 downwards as the iterations continue).
|
|
808
|
+
function processStages(stageList, factor) {
|
|
809
|
+
stageList.forEach((s) => {
|
|
810
|
+
// Move each node to its ideal vertical position:
|
|
811
|
+
s.forEach((n) => { n.y += findNodeGroupOffset([n]) * factor; });
|
|
812
|
+
// Update this stage's node positions to incorporate their proximity
|
|
813
|
+
// & required spacing *now*, since they'll be used as the basis for
|
|
814
|
+
// weights in the very next stage:
|
|
815
|
+
updateStageCentering(s);
|
|
816
|
+
// Update the flow sorting too; same reason:
|
|
817
|
+
placeFlowsInsideNodes(s);
|
|
818
|
+
});
|
|
819
|
+
// At the end of each round, do a proper final flow placement
|
|
820
|
+
// across the whole diagram. (Some locally-optimized flow choices
|
|
821
|
+
// don't work across the whole and need this resolution step
|
|
822
|
+
// before doing more balancing).
|
|
823
|
+
placeFlowsInsideNodes(nodes);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// reCenterDiagram:
|
|
827
|
+
// If (the vertical size of the space occupied by the nodes)
|
|
828
|
+
// < (the total diagram's Height),
|
|
829
|
+
// then offset ALL Nodes' y positions to center the diagram:
|
|
830
|
+
function reCenterDiagram() {
|
|
831
|
+
const minY = leastY(nodes),
|
|
832
|
+
yH = greatestY(nodes) - minY;
|
|
833
|
+
if (yH < size.h) {
|
|
834
|
+
const yOffset = (size.h / 2) - (minY + (yH / 2));
|
|
835
|
+
nodes.forEach((n) => { n.y += yOffset; });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Enough preamble. Lay out the nodes:
|
|
840
|
+
|
|
841
|
+
initializeNodePositions();
|
|
842
|
+
// Resolve all collisions/spacing & place all flows to start:
|
|
843
|
+
stagesArr.forEach((s) => { updateStageCentering(s); });
|
|
844
|
+
placeFlowsInsideNodes(nodes);
|
|
845
|
+
|
|
846
|
+
let [alpha, counter] = [1, 0];
|
|
847
|
+
while (counter < iterations) {
|
|
848
|
+
counter += 1;
|
|
849
|
+
// Make each round of moves progressively weaker:
|
|
850
|
+
alpha *= 0.99;
|
|
851
|
+
// Run through stages left-to-right, then right-to-left:
|
|
852
|
+
processStages(stagesArr, alpha);
|
|
853
|
+
processStages(stagesArr.slice().reverse(), alpha);
|
|
854
|
+
reCenterDiagram();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// After the last layout adjustment, remember these node coordinates
|
|
858
|
+
// (for reference when the user is dragging nodes):
|
|
859
|
+
nodes.forEach((n) => {
|
|
860
|
+
n.origPos = { x: n.x, y: n.y };
|
|
861
|
+
n.lastPos = { x: n.x, y: n.y };
|
|
862
|
+
n.move = [0, 0];
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// setup() = define the *skeleton* of the diagram -- which nodes link to
|
|
867
|
+
// which, and in which stages -- but no specific positions yet:
|
|
868
|
+
sankey.setup = () => {
|
|
869
|
+
connectFlowsToNodes();
|
|
870
|
+
computeNodeValues();
|
|
871
|
+
assignNodesToStages();
|
|
872
|
+
updateStagesArray();
|
|
873
|
+
return sankey;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// layout() = Given a complete skeleton, use the given total width/height and
|
|
877
|
+
// set the exact positions of all nodes and flows:
|
|
878
|
+
sankey.layout = (iterations) => {
|
|
879
|
+
// In case anything's changed since setup, re-generate our map:
|
|
880
|
+
updateStagesArray();
|
|
881
|
+
// Iterate over the structure several times to make the layout nice:
|
|
882
|
+
placeNodes(iterations);
|
|
883
|
+
return sankey;
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// relayout() = Given a complete diagram with some new node positions,
|
|
887
|
+
// calculate where the flows must now start/end:
|
|
888
|
+
sankey.relayout = () => {
|
|
889
|
+
placeFlowsInsideNodes(nodes);
|
|
890
|
+
return sankey;
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
return sankey;
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// Make the linter happy about imported objects:
|
|
897
|
+
/* global d3 IN OUT */
|