chartjs-chart-sankey 0.13.0 → 0.14.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.
@@ -1,942 +1,853 @@
1
1
  /*!
2
- * chartjs-chart-sankey v0.13.0
2
+ * chartjs-chart-sankey v0.14.0
3
3
  * https://github.com/kurkle/chartjs-chart-sankey#readme
4
4
  * (c) 2024 Jukka Kurkela
5
5
  * Released under the MIT license
6
6
  */
7
7
  import { DatasetController, Element } from 'chart.js';
8
- import { isArray, isNullOrUndef, valueOrDefault, toFont, getHoverColor, color } from 'chart.js/helpers';
8
+ import { toFont, valueOrDefault, getHoverColor, color } from 'chart.js/helpers';
9
9
 
10
- /**
11
- * @param {string | Array<string>} raw
12
- * @return {Array<string>}
13
- */
10
+ const defined = (x)=>x !== undefined;
14
11
  function toTextLines(raw) {
15
- const lines = [];
16
- const inputs = isArray(raw) ? raw : isNullOrUndef(raw) ? [] : [raw];
17
-
18
- while (inputs.length) {
19
- const input = inputs.pop();
20
- if (typeof input === 'string') {
21
- lines.unshift.apply(lines, input.split('\n'));
22
- } else if (Array.isArray(input)) {
23
- inputs.push.apply(inputs, input);
24
- } else if (!isNullOrUndef(inputs)) {
25
- lines.unshift('' + input);
26
- }
27
- }
28
-
29
- return lines;
12
+ if (!raw) return [];
13
+ const lines = [];
14
+ const inputs = Array.isArray(raw) ? raw : [
15
+ raw
16
+ ];
17
+ while(inputs.length){
18
+ const input = inputs.pop();
19
+ if (typeof input === 'string') {
20
+ lines.unshift(...input.split('\n'));
21
+ } else if (Array.isArray(input)) {
22
+ inputs.push(...input);
23
+ } else if (input) {
24
+ lines.unshift(`${input}`);
25
+ }
26
+ }
27
+ return lines;
30
28
  }
31
-
32
- /**
33
- * @param {any} size
34
- * @return {'min' | 'max'}
35
- */
36
29
  function validateSizeValue(size) {
37
- if (!size || ['min', 'max'].indexOf(size) === -1) {
38
- return 'max';
39
- }
40
- return size;
30
+ if (!size || [
31
+ 'min',
32
+ 'max'
33
+ ].indexOf(size) === -1) {
34
+ return 'max';
35
+ }
36
+ return size;
41
37
  }
42
38
 
43
- /**
44
- * @param x {any}
45
- * @return {boolean}
46
- */
47
- const defined = x => x !== undefined;
48
-
49
- /**
50
- * @param {Map<string, SankeyNode>} nodes
51
- * @param {Array<SankeyDataPoint>} data
52
- * @return {number}
53
- */
54
- function calculateX(nodes, data) {
55
- const to = new Set(data.map(dataPoint => dataPoint.to));
56
- const from = new Set(data.map(dataPoint => dataPoint.from));
57
- const keys = new Set([...nodes.keys()]);
58
- let x = 0;
59
- while (keys.size) {
60
- const column = nextColumn([...keys], to);
61
- for (const key of column) {
62
- const node = nodes.get(key);
63
- if (!defined(node.x)) {
64
- node.x = x;
65
- }
66
- keys.delete(key);
67
- }
68
- if (keys.size) {
69
- to.clear();
70
- data.filter(flow => keys.has(flow.from)).forEach(flow => to.add(flow.to));
71
- x++;
72
- }
73
- }
74
- [...nodes.keys()]
75
- .filter(key => !from.has(key))
76
- .forEach(key => {
77
- const node = nodes.get(key);
78
- // Only move the node to right edge, if it's column is not defined
79
- if (!node.column) {
80
- node.x = x;
81
- }
82
- });
83
-
84
- return [...nodes.values()]
85
- .reduce((max, node) => Math.max(max, node.x), 0);
39
+ const flowSort = (a, b)=>{
40
+ if (b.flow === a.flow) return a.index - b.index;
41
+ return b.flow - a.flow;
42
+ };
43
+ const setSizes = (nodes, size)=>{
44
+ const sizeMethod = validateSizeValue(size);
45
+ for (const node of nodes.values()){
46
+ node.from.sort(flowSort);
47
+ node.to.sort(flowSort);
48
+ node.size = Math[sizeMethod](node.in || node.out, node.out || node.in);
49
+ }
50
+ };
51
+ const setPriorities = (nodes, priority)=>{
52
+ if (!priority) return;
53
+ for (const node of nodes.values()){
54
+ if (node.key in priority) {
55
+ node.priority = priority[node.key];
56
+ }
57
+ }
58
+ };
59
+ const setColumns = (nodes, column)=>{
60
+ if (!column) return;
61
+ for (const node of nodes.values()){
62
+ if (node.key in column) {
63
+ node.column = true;
64
+ node.x = column[node.key];
65
+ }
66
+ }
67
+ };
68
+ const getParsedData = (data, parsing)=>{
69
+ const { from: fromKey = 'from', to: toKey = 'to', flow: flowKey = 'flow' } = parsing;
70
+ return data.map(({ [fromKey]: from, [toKey]: to, [flowKey]: flow })=>({
71
+ from,
72
+ to,
73
+ flow
74
+ }));
75
+ };
76
+ function buildNodesFromData(data, { size, priority, column }) {
77
+ const nodes = new Map();
78
+ for(let i = 0; i < data.length; i++){
79
+ const { from, to, flow } = data[i];
80
+ const fromNode = nodes.get(from) ?? {
81
+ key: from,
82
+ in: 0,
83
+ out: 0,
84
+ size: 0,
85
+ from: [],
86
+ to: []
87
+ };
88
+ const toNode = (from === to ? fromNode : nodes.get(to)) ?? {
89
+ key: to,
90
+ in: 0,
91
+ out: 0,
92
+ size: 0,
93
+ from: [],
94
+ to: []
95
+ };
96
+ fromNode.out += flow;
97
+ fromNode.to.push({
98
+ key: to,
99
+ flow: flow,
100
+ index: i,
101
+ node: toNode,
102
+ addY: 0
103
+ });
104
+ if (fromNode.to.length === 1) {
105
+ nodes.set(from, fromNode);
106
+ }
107
+ toNode.in += flow;
108
+ toNode.from.push({
109
+ key: from,
110
+ flow: flow,
111
+ index: i,
112
+ node: fromNode,
113
+ addY: 0
114
+ });
115
+ if (toNode.from.length === 1) {
116
+ nodes.set(to, toNode);
117
+ }
118
+ }
119
+ setSizes(nodes, size);
120
+ setPriorities(nodes, priority);
121
+ setColumns(nodes, column);
122
+ return nodes;
86
123
  }
87
124
 
88
- /**
89
- * @param {Array<string>} keys
90
- * @param {Set<string>} to
91
- * @return {Array<string>}
92
- */
93
- function nextColumn(keys, to) {
94
- const columnsNotInTo = keys.filter(key => !to.has(key));
95
- return columnsNotInTo.length ? columnsNotInTo : keys.slice(0, 1);
125
+ const SMALL_VALUE = 1e-6;
126
+ const getAllKeysForward = (nodes, visited = new Set())=>{
127
+ const keys = [];
128
+ for (const node of nodes){
129
+ if (visited.has(node.key)) continue;
130
+ visited.add(node.key);
131
+ keys.push(node.key, ...getAllKeysForward(node.to.map((to)=>to.node), visited));
132
+ }
133
+ return keys;
134
+ };
135
+ const startColumn = (data, nodes)=>{
136
+ const startNodes = nodes.filter((node)=>node.from.length === 0);
137
+ const column = startNodes.map((node)=>node.key);
138
+ const startRef = getAllKeysForward(startNodes);
139
+ const referencedNodes = new Set(startRef);
140
+ for (const point of data){
141
+ if (!referencedNodes.has(point.from) && !referencedNodes.has(point.to)) {
142
+ column.push(point.from);
143
+ referencedNodes.add(point.from);
144
+ }
145
+ referencedNodes.add(point.to);
146
+ }
147
+ return column;
148
+ };
149
+ const nextColumn = (dataWithoutDirectLoops, remainingKeys)=>{
150
+ const remainingTo = new Set(dataWithoutDirectLoops.filter((flow)=>remainingKeys.has(flow.from)).map((flow)=>flow.to));
151
+ const remainingKeyArray = [
152
+ ...remainingKeys
153
+ ];
154
+ const columnsNotInTo = remainingKeyArray.filter((key)=>!remainingTo.has(key));
155
+ return columnsNotInTo.length ? columnsNotInTo : remainingKeyArray.slice(0, 1);
156
+ };
157
+ function calculateX(nodeMap, data, mode) {
158
+ const dataWithoutDirectLoops = data.filter((dp)=>dp.from !== dp.to);
159
+ const allKeys = [
160
+ ...nodeMap.keys()
161
+ ];
162
+ const allNodes = [
163
+ ...nodeMap.values()
164
+ ];
165
+ const keysToPlace = new Set(allKeys);
166
+ let x = 0;
167
+ while(keysToPlace.size){
168
+ const column = x === 0 ? startColumn(data, allNodes) : nextColumn(dataWithoutDirectLoops, keysToPlace);
169
+ if (!column.length) {
170
+ throw new Error('Fatal error: Unable to place nodes to columns. Please report this issue.');
171
+ }
172
+ for (const key of column){
173
+ const node = nodeMap.get(key);
174
+ if (node && !defined(node.x)) {
175
+ node.x = x;
176
+ }
177
+ keysToPlace.delete(key);
178
+ }
179
+ if (keysToPlace.size) {
180
+ x++;
181
+ }
182
+ }
183
+ const maxX = allNodes.reduce((max, node)=>Math.max(max, node.x), 0);
184
+ if (mode === 'edge') {
185
+ const from = new Set(data.map((dataPoint)=>dataPoint.from));
186
+ allKeys.filter((key)=>!from.has(key)).forEach((key)=>{
187
+ const node = nodeMap.get(key);
188
+ if (node && !node.column) {
189
+ node.x = maxX;
190
+ }
191
+ });
192
+ }
193
+ return maxX;
96
194
  }
97
-
98
- /**
99
- * @param {SankeyNode} a
100
- * @param {SankeyNode} b
101
- * @return {number}
102
- */
103
- const nodeByXY = (a, b) => a.x !== b.x ? a.x - b.x : a.y - b.y;
104
-
105
195
  let prevCountId = -1;
106
196
  function getCountId() {
107
- prevCountId = prevCountId < 100 ? prevCountId + 1 : 0;
108
- return prevCountId;
197
+ prevCountId = prevCountId < 100 ? prevCountId + 1 : 0;
198
+ return prevCountId;
109
199
  }
110
-
111
- /**
112
- * @param {Array<FromToElement>} list
113
- * @param {string} prop
114
- * @return {number}
115
- */
116
200
  function nodeCount(list, prop, countId = getCountId()) {
117
- let count = 0;
118
- for (const elem of list) {
119
- if (elem.node._visited === countId) {
120
- continue;
121
- }
122
- elem.node._visited = countId;
123
- count += elem.node[prop].length + nodeCount(elem.node[prop], prop, countId);
124
- }
125
- return count;
201
+ let count = 0;
202
+ for (const elem of list){
203
+ if (elem.node._visited === countId) {
204
+ continue;
205
+ }
206
+ elem.node._visited = countId;
207
+ count += elem.node[prop].length + nodeCount(elem.node[prop], prop, countId);
208
+ }
209
+ return count;
126
210
  }
127
-
128
- /**
129
- * @param {string} prop
130
- * @return {function(FromToElement, FromToElement): number}
131
- */
132
- const flowByNodeCount = (prop) => (a, b) => (nodeCount(a.node[prop], prop) - nodeCount(b.node[prop], prop)) || (a.node[prop].length - b.node[prop].length);
133
-
134
- /**
135
- * @param {SankeyNode} node
136
- * @param {number} y
137
- * @return {number}
138
- */
211
+ const flowByNodeCount = (prop)=>(a, b)=>nodeCount(a.node[prop], prop) - nodeCount(b.node[prop], prop) || a.node[prop].length - b.node[prop].length;
139
212
  function processFrom(node, y) {
140
- node.from.sort(flowByNodeCount('from'));
141
- for (const flow of node.from) {
142
- const n = flow.node;
143
- if (!defined(n.y)) {
144
- n.y = y;
145
- processFrom(n, y);
146
- }
147
- y = Math.max(n.y + n.out, y);
148
- }
149
- return y;
213
+ if (!node.from.length) return y;
214
+ node.from.sort(flowByNodeCount('from'));
215
+ for (const flow of node.from){
216
+ const n = flow.node;
217
+ if (!defined(n.y)) {
218
+ n.y = y;
219
+ processFrom(n, y ? y + SMALL_VALUE : 0);
220
+ }
221
+ y = Math.max(n.y + n.out, y);
222
+ }
223
+ return node.y + node.size;
150
224
  }
151
-
152
- /**
153
- * @param {SankeyNode} node
154
- * @param {number} y
155
- * @return {number}
156
- */
157
225
  function processTo(node, y) {
158
- node.to.sort(flowByNodeCount('to'));
159
- for (const flow of node.to) {
160
- const n = flow.node;
161
- if (!defined(n.y)) {
162
- n.y = y;
163
- processTo(n, y);
164
- }
165
- const size = Math.max(n.in, n.out);
166
- y = Math.max(n.y + size, y);
167
- }
168
- return y;
226
+ if (!node.to.length) return y;
227
+ node.to.sort(flowByNodeCount('to'));
228
+ for (const flow of node.to){
229
+ const n = flow.node;
230
+ if (!defined(n.y)) {
231
+ n.y = y;
232
+ processTo(n, y ? y + SMALL_VALUE : 0);
233
+ }
234
+ y = Math.max(n.y + Math.max(n.in, n.out), y);
235
+ }
236
+ return node.y + node.size;
169
237
  }
170
-
171
- /**
172
- * @param {SankeyNode} node
173
- * @param {number} value
174
- * @return {number}
175
- */
176
238
  function setOrGetY(node, value) {
177
- if (defined(node.y)) {
178
- return node.y;
179
- }
180
- node.y = value;
181
- return value;
239
+ if (defined(node.y)) {
240
+ return node.y;
241
+ }
242
+ node.y = value;
243
+ return value;
182
244
  }
183
-
184
- /**
185
- * @param {Array<SankeyNode>} nodeArray
186
- * @param {number} maxX
187
- * @return {number}
188
- */
189
245
  function processRest(nodeArray, maxX) {
190
- const leftNodes = nodeArray.filter(node => node.x === 0);
191
- const rightNodes = nodeArray.filter(node => node.x === maxX);
192
- const leftToDo = leftNodes.filter(node => !defined(node.y));
193
- const rightToDo = rightNodes.filter(node => !defined(node.y));
194
- const centerToDo = nodeArray.filter(node => node.x > 0 && node.x < maxX && !defined(node.y));
195
-
196
- let leftY = leftNodes.reduce((acc, cur) => Math.max(acc, (cur.y + cur.out) || 0), 0);
197
- let rightY = rightNodes.reduce((acc, cur) => Math.max(acc, (cur.y + cur.in) || 0), 0);
198
- let centerY = 0;
199
-
200
- if (leftY >= rightY) {
201
- leftToDo.forEach(node => {
202
- leftY = setOrGetY(node, leftY);
203
- leftY = Math.max(leftY + node.out, processTo(node, leftY));
204
- });
205
-
206
- rightToDo.forEach(node => {
207
- rightY = setOrGetY(node, rightY);
208
- rightY = Math.max(rightY + node.in, processTo(node, rightY));
209
- });
210
- } else {
211
- rightToDo.forEach(node => {
212
- rightY = setOrGetY(node, rightY);
213
- rightY = Math.max(rightY + node.in, processTo(node, rightY));
214
- });
215
-
216
- leftToDo.forEach(node => {
217
- leftY = setOrGetY(node, leftY);
218
- leftY = Math.max(leftY + node.out, processTo(node, leftY));
246
+ const leftNodes = nodeArray.filter((node)=>node.x === 0);
247
+ const rightNodes = nodeArray.filter((node)=>node.x === maxX);
248
+ const leftToDo = leftNodes.filter((node)=>!defined(node.y));
249
+ const rightToDo = rightNodes.filter((node)=>!defined(node.y));
250
+ const centerToDo = nodeArray.filter((node)=>node.x > 0 && node.x < maxX && !defined(node.y));
251
+ let leftY = leftNodes.reduce((acc, cur)=>Math.max(acc, cur.y + cur.out || 0), 0) + SMALL_VALUE;
252
+ let rightY = rightNodes.reduce((acc, cur)=>Math.max(acc, cur.y + cur.in || 0), 0) + SMALL_VALUE;
253
+ let centerY = 0;
254
+ if (leftY >= rightY) {
255
+ leftToDo.forEach((node)=>{
256
+ leftY = setOrGetY(node, leftY);
257
+ leftY = Math.max(leftY + node.out, processTo(node, leftY));
258
+ });
259
+ rightToDo.forEach((node)=>{
260
+ rightY = setOrGetY(node, rightY);
261
+ rightY = Math.max(rightY + node.in, processFrom(node, rightY));
262
+ });
263
+ } else {
264
+ leftToDo.forEach((node)=>{
265
+ leftY = setOrGetY(node, leftY);
266
+ });
267
+ rightToDo.forEach((node)=>{
268
+ rightY = setOrGetY(node, rightY);
269
+ rightY = Math.max(rightY + node.in, processFrom(node, rightY));
270
+ });
271
+ }
272
+ centerToDo.forEach((node)=>{
273
+ let y = nodeArray.filter((n)=>n.x === node.x && defined(n.y)).reduce((acc, cur)=>Math.max(acc, cur.y + Math.max(cur.in, cur.out)), 0);
274
+ y = setOrGetY(node, y);
275
+ y = Math.max(y + node.in, processFrom(node, y));
276
+ y = Math.max(y + node.out, processTo(node, y));
277
+ centerY = Math.max(centerY, y);
219
278
  });
220
- }
221
- centerToDo.forEach(node => {
222
- let y = nodeArray.filter(n => n.x === node.x && defined(n.y))
223
- .reduce((acc, cur) => Math.max(acc, cur.y + Math.max(cur.in, cur.out)), 0);
224
- y = setOrGetY(node, y);
225
- y = Math.max(y + node.in, processFrom(node, y));
226
- y = Math.max(y + node.out, processTo(node, y));
227
- centerY = Math.max(centerY, y);
228
- });
229
-
230
- return Math.max(leftY, rightY, centerY);
279
+ return Math.max(leftY, rightY, centerY);
231
280
  }
232
-
233
- /**
234
- * @param {Array<SankeyNode>} nodeArray
235
- * @param {number} maxX
236
- * @return {number}
237
- */
281
+ const fixTop = (nodeArray, maxX)=>{
282
+ let maxY = 0;
283
+ for(let x = 0; x <= maxX; x++){
284
+ const nodes = nodeArray.filter((n)=>n.x === x).sort((a, b)=>a.y - b.y);
285
+ let minY = 0;
286
+ for (const node of nodes){
287
+ if (node.y < minY) node.y = minY;
288
+ minY = node.y + node.size;
289
+ }
290
+ maxY = Math.max(maxY, minY);
291
+ }
292
+ return maxY;
293
+ };
294
+ const findStartNode = (nodeArray, maxX)=>{
295
+ const size = [
296
+ ...nodeArray
297
+ ].sort((a, b)=>a.size - b.size).pop().size;
298
+ const biggest = nodeArray.filter((n)=>n.size === size);
299
+ if (biggest.length === 1) return biggest[0];
300
+ biggest.sort((a, b)=>a.x - b.x);
301
+ if (biggest[0].x === 0) return biggest[0];
302
+ if (biggest[biggest.length - 1].x === maxX) return biggest.pop();
303
+ const mid = Math.floor(biggest.length / 2);
304
+ return biggest[mid];
305
+ };
238
306
  function calculateY(nodeArray, maxX) {
239
- nodeArray.sort((a, b) => Math.max(b.in, b.out) - Math.max(a.in, a.out));
240
- const start = nodeArray[0];
241
- start.y = 0;
242
- const left = processFrom(start, 0);
243
- const right = processTo(start, 0);
244
- const rest = processRest(nodeArray, maxX);
245
- return Math.max(left, right, rest);
307
+ if (!nodeArray.length) return 0;
308
+ const start = findStartNode(nodeArray, maxX);
309
+ start.y = 0;
310
+ processFrom(start, 0);
311
+ processTo(start, 0);
312
+ processRest(nodeArray, maxX);
313
+ return fixTop(nodeArray, maxX);
246
314
  }
247
-
248
- /**
249
- * @param {Array<SankeyNode>} nodeArray
250
- * @param {number} maxX
251
- * @return {number}
252
- */
253
315
  function calculateYUsingPriority(nodeArray, maxX) {
254
- let maxY = 0;
255
- let nextYStart = 0;
256
- for (let x = 0; x <= maxX; x++) {
257
- let y = nextYStart;
258
- const nodes = nodeArray.filter(node => node.x === x).sort((a, b) => a.priority - b.priority);
259
- nextYStart = nodes.length ? nodes[0].to.filter(to => to.node.x > x + 1).reduce((acc, cur) => acc + cur.flow, 0) || 0 : 0;
260
- for (const node of nodes) {
261
- node.y = y;
262
- y += Math.max(node.out, node.in);
263
- }
264
- maxY = Math.max(y, maxY);
265
- }
266
- return maxY;
316
+ let maxY = 0;
317
+ let nextYStart = 0;
318
+ for(let x = 0; x <= maxX; x++){
319
+ let y = nextYStart;
320
+ const nodes = nodeArray.filter((node)=>node.x === x).sort((a, b)=>(a.priority ?? 0) - (b.priority ?? 0));
321
+ nextYStart = nodes.length ? nodes[0].to.filter((to)=>to.node.x > x + 1).reduce((acc, cur)=>acc + cur.flow, 0) || 0 : 0;
322
+ for (const node of nodes){
323
+ node.y = y;
324
+ y += Math.max(node.out, node.in);
325
+ }
326
+ maxY = Math.max(y, maxY);
327
+ }
328
+ return maxY;
267
329
  }
268
-
269
- /**
270
- * @param {Array<SankeyNode>} nodeArray
271
- * @param {number} padding
272
- * @return {number}
273
- */
274
- function addPadding(nodeArray, padding) {
275
- let i = 1;
276
- let x = 0;
277
- let prev = 0;
278
- let maxY = 0;
279
- const rows = [];
280
- nodeArray.sort(nodeByXY);
281
- for (const node of nodeArray) {
282
- if (node.y) {
283
- if (node.x === 0) {
284
- rows.push(node.y);
285
- } else {
286
- if (x !== node.x) {
287
- x = node.x;
288
- prev = 0;
330
+ const nodeByXYSize = (a, b)=>{
331
+ if (a.x !== b.x) return a.x - b.x;
332
+ if (a.y === b.y) return a.size - b.size;
333
+ return a.y - b.y;
334
+ };
335
+ function addPadding(nodeArray, padding) {
336
+ let maxY = 0;
337
+ const columnXs = new Map();
338
+ const grid = [];
339
+ const getColIndex = (x)=>{
340
+ if (!columnXs.has(x)) {
341
+ columnXs.set(x, grid.length);
342
+ grid.push([]);
289
343
  }
290
-
291
- for (i = prev + 1; i < rows.length; i++) {
292
- if (rows[i] > node.y) {
293
- break;
294
- }
295
- }
296
- prev = i;
297
- }
298
- node.y += i * padding;
299
- i++;
300
- }
301
- maxY = Math.max(maxY, node.y + Math.max(node.in, node.out));
302
- }
303
- return maxY;
344
+ return columnXs.get(x);
345
+ };
346
+ nodeArray.sort(nodeByXYSize);
347
+ for (const node of nodeArray){
348
+ const colIdx = getColIndex(node.x);
349
+ const column = grid[colIdx];
350
+ if (node.y) {
351
+ column.push(node.y);
352
+ let paddings = column.length;
353
+ if (node.in) {
354
+ for(let col = 0; col < colIdx; col++){
355
+ const otherColumn = grid[col];
356
+ for(let row = 0; row < otherColumn.length; row++){
357
+ if (otherColumn[row] > node.y) break;
358
+ paddings = Math.max(row + 1, paddings);
359
+ }
360
+ }
361
+ while(column.length < paddings)column.push(node.y);
362
+ }
363
+ node.y += paddings * padding;
364
+ }
365
+ maxY = Math.max(maxY, node.y + Math.max(node.in, node.out));
366
+ }
367
+ return maxY;
304
368
  }
305
-
306
- /**
307
- * @param {Array<SankeyNode>} nodeArray
308
- * @param {'min' | 'max'} size
309
- */
310
- function sortFlows(nodeArray, size) {
311
- nodeArray.forEach((node) => {
312
- const nodeSize = Math[size](node.in || node.out, node.out || node.in);
313
- const overlapFrom = nodeSize < node.in;
314
- const overlapTo = nodeSize < node.out;
315
- let addY = 0;
316
- let len = node.from.length;
317
- node.from.sort((a, b) => (a.node.y + a.node.out / 2) - (b.node.y + b.node.out / 2)).forEach((flow, idx) => {
318
- if (overlapFrom) {
319
- flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
320
- } else {
321
- flow.addY = addY;
322
- addY += flow.flow;
323
- }
324
- });
325
- addY = 0;
326
- len = node.to.length;
327
- node.to.sort((a, b) => (a.node.y + a.node.in / 2) - (b.node.y + b.node.in / 2)).forEach((flow, idx) => {
328
- if (overlapTo) {
329
- flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
330
- } else {
331
- flow.addY = addY;
332
- addY += flow.flow;
333
- }
369
+ function sortFlows(nodeArray) {
370
+ nodeArray.forEach((node)=>{
371
+ const nodeSize = node.size;
372
+ const overlapFrom = nodeSize < node.in;
373
+ const overlapTo = nodeSize < node.out;
374
+ let addY = 0;
375
+ let len = node.from.length;
376
+ node.from.sort((a, b)=>a.node.y + a.node.out / 2 - (b.node.y + b.node.out / 2)).forEach((flow, idx)=>{
377
+ if (overlapFrom) {
378
+ flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
379
+ } else {
380
+ flow.addY = addY;
381
+ addY += flow.flow;
382
+ }
383
+ });
384
+ addY = 0;
385
+ len = node.to.length;
386
+ node.to.sort((a, b)=>a.node.y + a.node.in / 2 - (b.node.y + b.node.in / 2)).forEach((flow, idx)=>{
387
+ if (overlapTo) {
388
+ flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
389
+ } else {
390
+ flow.addY = addY;
391
+ addY += flow.flow;
392
+ }
393
+ });
334
394
  });
335
- });
336
- }
337
-
338
- /**
339
- * @param {Map<string, SankeyNode>} nodes
340
- * @param {Array<SankeyDataPoint>} data
341
- * @param {boolean} priority
342
- * @param {'min' | 'max'} size
343
- * @return {{maxY: number, maxX: number}}
344
- */
345
- function layout(nodes, data, priority, size) {
346
- const nodeArray = [...nodes.values()];
347
- const maxX = calculateX(nodes, data);
348
- const maxY = priority ? calculateYUsingPriority(nodeArray, maxX) : calculateY(nodeArray, maxX);
349
- const padding = maxY * 0.03; // rows;
350
- const maxYWithPadding = addPadding(nodeArray, padding);
351
- sortFlows(nodeArray, size);
352
- return {maxX, maxY: maxYWithPadding};
353
395
  }
354
-
355
- /**
356
- * @param {Array<SankeyDataPoint>} data Array of raw data elements
357
- * @return {Map<string, SankeyNode>}
358
- */
359
- function buildNodesFromRawData(data) {
360
- const nodes = new Map();
361
- for (let i = 0; i < data.length; i++) {
362
- const {from, to, flow} = data[i];
363
-
364
- if (!nodes.has(from)) {
365
- nodes.set(from, {
366
- key: from,
367
- in: 0,
368
- out: flow,
369
- from: [],
370
- to: [{key: to, flow: flow, index: i}],
371
- });
372
- } else {
373
- const node = nodes.get(from);
374
- node.out += flow;
375
- node.to.push({key: to, flow: flow, index: i});
376
- }
377
- if (!nodes.has(to)) {
378
- nodes.set(to, {
379
- key: to,
380
- in: flow,
381
- out: 0,
382
- from: [{key: from, flow: flow, index: i}],
383
- to: [],
384
- });
385
- } else {
386
- const node = nodes.get(to);
387
- node.in += flow;
388
- node.from.push({key: from, flow: flow, index: i});
389
- }
390
- }
391
-
392
- const flowSort = (a, b) => b.flow - a.flow;
393
-
394
- [...nodes.values()].forEach(node => {
395
- node.from = node.from.sort(flowSort);
396
- node.from.forEach(x => {
397
- x.node = nodes.get(x.key);
398
- });
399
-
400
- node.to = node.to.sort(flowSort);
401
- node.to.forEach(x => {
402
- x.node = nodes.get(x.key);
403
- });
404
- });
405
-
406
- return nodes;
396
+ function layout(nodes, data, { priority, height, nodePadding, modeX }) {
397
+ const nodeArray = [
398
+ ...nodes.values()
399
+ ];
400
+ const maxX = calculateX(nodes, data, modeX);
401
+ const maxY = priority ? calculateYUsingPriority(nodeArray, maxX) : calculateY(nodeArray, maxX);
402
+ const padding = maxY / height * nodePadding;
403
+ const maxYWithPadding = addPadding(nodeArray, padding);
404
+ sortFlows(nodeArray);
405
+ return {
406
+ maxX,
407
+ maxY: maxYWithPadding
408
+ };
407
409
  }
408
410
 
409
- /**
410
- * @param {Array<FromToElement>} arr
411
- * @param {string} key
412
- * @param {number} index
413
- * @return {number}
414
- */
415
411
  function getAddY(arr, key, index) {
416
- for (const item of arr) {
417
- if (item.key === key && item.index === index) {
418
- return item.addY;
412
+ for (const item of arr){
413
+ if (item.key === key && item.index === index) {
414
+ return item.addY;
415
+ }
419
416
  }
420
- }
421
- return 0;
417
+ return 0;
422
418
  }
423
-
424
419
  class SankeyController extends DatasetController {
425
- /**
426
- * @param {ChartMeta<Flow, Element>} meta
427
- * @param {Array<SankeyDataPoint>} data Array of original data elements
428
- * @param {number} start
429
- * @param {number} count
430
- * @return {Array<SankeyParsedData>}
431
- */
432
- parseObjectData(meta, data, start, count) {
433
- const {from: fromKey = 'from', to: toKey = 'to', flow: flowKey = 'flow'} = this.options.parsing;
434
- const sankeyData = data.map(({[fromKey]: from, [toKey]: to, [flowKey]: flow}) => ({from, to, flow}));
435
- const {xScale, yScale} = meta;
436
- const parsed = []; /* Array<SankeyParsedData> */
437
- const nodes = this._nodes = buildNodesFromRawData(sankeyData);
438
- /* getDataset() => SankeyControllerDatasetOptions */
439
- const {column, priority, size} = this.getDataset();
440
- if (priority) {
441
- for (const node of nodes.values()) {
442
- if (node.key in priority) {
443
- node.priority = priority[node.key];
420
+ parseObjectData(meta, data, start, count) {
421
+ const sankeyData = getParsedData(data, this.options.parsing);
422
+ const { xScale, yScale } = meta;
423
+ const parsed = [];
424
+ const nodes = this._nodes = buildNodesFromData(sankeyData, this.options);
425
+ const { maxX, maxY } = layout(nodes, sankeyData, {
426
+ priority: !!this.options.priority,
427
+ height: this.chart.canvas.height,
428
+ nodePadding: this.options.nodePadding,
429
+ modeX: this.options.modeX
430
+ });
431
+ this._maxX = maxX;
432
+ this._maxY = maxY;
433
+ if (!xScale || !yScale) return [];
434
+ for(let i = 0, ilen = sankeyData.length; i < ilen; ++i){
435
+ const dataPoint = sankeyData[i];
436
+ const from = nodes.get(dataPoint.from);
437
+ const to = nodes.get(dataPoint.to);
438
+ if (!from || !to) continue;
439
+ const fromY = (from.y ?? 0) + getAddY(from.to, dataPoint.to, i);
440
+ const toY = (to.y ?? 0) + getAddY(to.from, dataPoint.from, i);
441
+ parsed.push({
442
+ x: xScale.parse(from.x, i),
443
+ y: yScale.parse(fromY, i),
444
+ _custom: {
445
+ from,
446
+ to,
447
+ x: xScale.parse(to.x, i),
448
+ y: yScale.parse(toY, i),
449
+ height: yScale.parse(dataPoint.flow, i),
450
+ flow: dataPoint.flow
451
+ }
452
+ });
444
453
  }
445
- }
454
+ return parsed.slice(start, start + count);
446
455
  }
447
- if (column) {
448
- for (const node of nodes.values()) {
449
- if (node.key in column) {
450
- node.column = true;
451
- node.x = column[node.key];
456
+ getMinMax(scale) {
457
+ return {
458
+ min: 0,
459
+ max: scale === this._cachedMeta.xScale ? this._maxX : this._maxY
460
+ };
461
+ }
462
+ update(mode) {
463
+ const { data } = this._cachedMeta;
464
+ this.updateElements(data, 0, data.length, mode);
465
+ }
466
+ updateElements(elems, start, count, mode) {
467
+ const { xScale, yScale } = this._cachedMeta;
468
+ if (!xScale || !yScale) return;
469
+ const firstOpts = this.resolveDataElementOptions(start, mode);
470
+ const sharedOptions = this.getSharedOptions(firstOpts);
471
+ const { borderWidth, nodeWidth = 10 } = this.options;
472
+ const borderSpace = borderWidth ? borderWidth / 2 + 0.5 : 0;
473
+ for(let i = start; i < start + count; i++){
474
+ const parsed = this.getParsed(i);
475
+ const custom = parsed._custom;
476
+ const y = yScale.getPixelForValue(parsed.y);
477
+ this.updateElement(elems[i], i, {
478
+ x: xScale.getPixelForValue(parsed.x) + nodeWidth + borderSpace,
479
+ y,
480
+ x2: xScale.getPixelForValue(custom.x) - borderSpace,
481
+ y2: yScale.getPixelForValue(custom.y),
482
+ from: custom.from,
483
+ to: custom.to,
484
+ progress: mode === 'reset' ? 0 : 1,
485
+ height: Math.abs(yScale.getPixelForValue(parsed.y + custom.height) - y),
486
+ options: this.resolveDataElementOptions(i, mode)
487
+ }, mode);
452
488
  }
453
- }
489
+ this.updateSharedOptions(sharedOptions, mode, firstOpts);
454
490
  }
455
-
456
- const {maxX, maxY} = layout(nodes, sankeyData, !!priority, validateSizeValue(size));
457
-
458
- this._maxX = maxX;
459
- this._maxY = maxY;
460
-
461
- for (let i = 0, ilen = sankeyData.length; i < ilen; ++i) {
462
- const dataPoint = sankeyData[i];
463
- const from = nodes.get(dataPoint.from);
464
- const to = nodes.get(dataPoint.to);
465
- const fromY = from.y + getAddY(from.to, dataPoint.to, i);
466
- const toY = to.y + getAddY(to.from, dataPoint.from, i);
467
- parsed.push({
468
- x: xScale.parse(from.x, i),
469
- y: yScale.parse(fromY, i),
470
- _custom: {
471
- from,
472
- to,
473
- x: xScale.parse(to.x, i),
474
- y: yScale.parse(toY, i),
475
- height: yScale.parse(dataPoint.flow, i),
476
- }
477
- });
478
- }
479
- return parsed.slice(start, start + count);
480
- }
481
-
482
- getMinMax(scale) {
483
- return {
484
- min: 0,
485
- max: scale === this._cachedMeta.xScale ? this._maxX : this._maxY
486
- };
487
- }
488
-
489
- update(mode) {
490
- const {data} = this._cachedMeta;
491
-
492
- this.updateElements(data, 0, data.length, mode);
493
- }
494
-
495
- /**
496
- * @param {Array<Flow>} elems
497
- * @param {number} start
498
- * @param {number} count
499
- * @param {"resize" | "reset" | "none" | "hide" | "show" | "normal" | "active"} mode
500
- */
501
- updateElements(elems, start, count, mode) {
502
- const {xScale, yScale} = this._cachedMeta;
503
- const firstOpts = this.resolveDataElementOptions(start, mode);
504
- const sharedOptions = this.getSharedOptions(mode, elems[start], firstOpts);
505
- const dataset = this.getDataset();
506
- const borderWidth = valueOrDefault(dataset.borderWidth, 1) / 2 + 0.5;
507
- const nodeWidth = valueOrDefault(dataset.nodeWidth, 10);
508
-
509
- for (let i = start; i < start + count; i++) {
510
- /* getParsed(idx: number) => SankeyParsedData */
511
- const parsed = this.getParsed(i);
512
- const custom = parsed._custom;
513
- const y = yScale.getPixelForValue(parsed.y);
514
- this.updateElement(
515
- elems[i],
516
- i,
517
- {
518
- x: xScale.getPixelForValue(parsed.x) + nodeWidth + borderWidth,
519
- y,
520
- x2: xScale.getPixelForValue(custom.x) - borderWidth,
521
- y2: yScale.getPixelForValue(custom.y),
522
- from: custom.from,
523
- to: custom.to,
524
- progress: mode === 'reset' ? 0 : 1,
525
- height: Math.abs(yScale.getPixelForValue(parsed.y + custom.height) - y),
526
- options: this.resolveDataElementOptions(i, mode)
527
- },
528
- mode);
491
+ _drawLabels() {
492
+ const ctx = this.chart.ctx;
493
+ const options = this.options;
494
+ const nodes = this._nodes || new Map();
495
+ const size = validateSizeValue(options.size);
496
+ const borderWidth = options.borderWidth ?? 1;
497
+ const nodeWidth = options.nodeWidth ?? 10;
498
+ const labels = options.labels;
499
+ const { xScale, yScale } = this._cachedMeta;
500
+ if (!xScale || !yScale) return;
501
+ ctx.save();
502
+ const chartArea = this.chart.chartArea;
503
+ for (const node of nodes.values()){
504
+ const x = xScale.getPixelForValue(node.x);
505
+ const y = yScale.getPixelForValue(node.y);
506
+ const max = Math[size](node.in || node.out, node.out || node.in);
507
+ const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
508
+ const label = labels?.[node.key] ?? node.key;
509
+ let textX = x;
510
+ ctx.fillStyle = options.color ?? 'black';
511
+ ctx.textBaseline = 'middle';
512
+ if (x < chartArea.width / 2) {
513
+ ctx.textAlign = 'left';
514
+ textX += nodeWidth + borderWidth + 4;
515
+ } else {
516
+ ctx.textAlign = 'right';
517
+ textX -= borderWidth + 4;
518
+ }
519
+ this._drawLabel(label, y, height, ctx, textX);
520
+ }
521
+ ctx.restore();
529
522
  }
530
-
531
- this.updateSharedOptions(sharedOptions, mode);
532
- }
533
-
534
- _drawLabels() {
535
- const ctx = this._ctx;
536
- const nodes = this._nodes || new Map();
537
- const dataset = this.getDataset(); /* SankeyControllerDatasetOptions */
538
- const size = validateSizeValue(dataset.size);
539
- const borderWidth = valueOrDefault(dataset.borderWidth, 1);
540
- const nodeWidth = valueOrDefault(dataset.nodeWidth, 10);
541
- const labels = dataset.labels;
542
- const {xScale, yScale} = this._cachedMeta;
543
-
544
- ctx.save();
545
- const chartArea = this.chart.chartArea;
546
- for (const node of nodes.values()) {
547
- const x = xScale.getPixelForValue(node.x);
548
- const y = yScale.getPixelForValue(node.y);
549
-
550
- const max = Math[size](node.in || node.out, node.out || node.in);
551
- const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
552
- const label = labels && labels[node.key] || node.key;
553
- let textX = x;
554
- ctx.fillStyle = dataset.color || 'black';
555
- ctx.textBaseline = 'middle';
556
- if (x < chartArea.width / 2) {
557
- ctx.textAlign = 'left';
558
- textX += nodeWidth + borderWidth + 4;
559
- } else {
560
- ctx.textAlign = 'right';
561
- textX -= borderWidth + 4;
562
- }
563
- this._drawLabel(label, y, height, ctx, textX);
564
- }
565
- ctx.restore();
566
- }
567
-
568
- /**
569
- * @param {string} label
570
- * @param {number} y
571
- * @param {number} height
572
- * @param {CanvasRenderingContext2D} ctx
573
- * @param {number} textX
574
- * @private
575
- */
576
- _drawLabel(label, y, height, ctx, textX) {
577
- const font = toFont(this.options.font, this.chart.options.font);
578
- const lines = isNullOrUndef(label) ? [] : toTextLines(label);
579
- const linesLength = lines.length;
580
- const middle = y + height / 2;
581
- const textHeight = font.lineHeight;
582
- const padding = valueOrDefault(this.options.padding, textHeight / 2);
583
-
584
- ctx.font = font.string;
585
-
586
- if (linesLength > 1) {
587
- const top = middle - (textHeight * linesLength / 2) + padding;
588
- for (let i = 0; i < linesLength; i++) {
589
- ctx.fillText(lines[i], textX, top + (i * textHeight));
590
- }
591
- } else {
592
- ctx.fillText(label, textX, middle);
523
+ _drawLabel(label, y, height, ctx, textX) {
524
+ const font = toFont(this.options.font, this.chart.options.font);
525
+ const lines = toTextLines(label);
526
+ const lineCount = lines.length;
527
+ const middle = y + height / 2;
528
+ const textHeight = font.lineHeight;
529
+ const padding = valueOrDefault(this.options.padding, textHeight / 2);
530
+ ctx.font = font.string;
531
+ if (lineCount > 1) {
532
+ const top = middle - textHeight * lineCount / 2 + padding;
533
+ for(let i = 0; i < lineCount; i++){
534
+ ctx.fillText(lines[i], textX, top + i * textHeight);
535
+ }
536
+ } else {
537
+ ctx.fillText(label, textX, middle);
538
+ }
593
539
  }
594
- }
595
-
596
- _drawNodes() {
597
- const ctx = this._ctx;
598
- const nodes = this._nodes || new Map();
599
- const dataset = this.getDataset(); /* SankeyControllerDatasetOptions */
600
- const size = validateSizeValue(dataset.size);
601
- const {xScale, yScale} = this._cachedMeta;
602
- const borderWidth = valueOrDefault(dataset.borderWidth, 1);
603
- const nodeWidth = valueOrDefault(dataset.nodeWidth, 10);
604
-
605
- ctx.save();
606
- ctx.strokeStyle = dataset.borderColor || 'black';
607
- ctx.lineWidth = borderWidth;
608
-
609
- for (const node of nodes.values()) {
610
- ctx.fillStyle = node.color;
611
- const x = xScale.getPixelForValue(node.x);
612
- const y = yScale.getPixelForValue(node.y);
613
-
614
- const max = Math[size](node.in || node.out, node.out || node.in);
615
- const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
616
- if (borderWidth) {
617
- ctx.strokeRect(x, y, nodeWidth, height);
618
- }
619
- ctx.fillRect(x, y, nodeWidth, height);
620
- }
621
- ctx.restore();
622
- }
623
-
624
- /**
625
- * That's where the drawing process happens
626
- */
627
- draw() {
628
- const ctx = this._ctx;
629
- const data = this.getMeta().data || []; /* Array<Flow> */
630
-
631
- // Set node colors
632
- const active = [];
633
- for (let i = 0, ilen = data.length; i < ilen; ++i) {
634
- const flow = data[i]; /* Flow at index i */
635
- flow.from.color = flow.options.colorFrom;
636
- flow.to.color = flow.options.colorTo;
637
- if (flow.active) {
638
- active.push(flow);
639
- }
640
- }
641
- // Make sure nodes connected to hovered flows are using hover colors.
642
- for (const flow of active) {
643
- flow.from.color = flow.options.colorFrom;
644
- flow.to.color = flow.options.colorTo;
540
+ _drawNodes() {
541
+ const ctx = this.chart.ctx;
542
+ const nodes = this._nodes || new Map();
543
+ const { borderColor, borderWidth = 0, nodeWidth = 10, size } = this.options;
544
+ const sizeMethod = validateSizeValue(size);
545
+ const { xScale, yScale } = this._cachedMeta;
546
+ ctx.save();
547
+ if (borderColor && borderWidth) {
548
+ ctx.strokeStyle = borderColor;
549
+ ctx.lineWidth = borderWidth;
550
+ }
551
+ for (const node of nodes.values()){
552
+ ctx.fillStyle = node.color ?? 'black';
553
+ const x = xScale.getPixelForValue(node.x);
554
+ const y = yScale.getPixelForValue(node.y);
555
+ const max = Math[sizeMethod](node.in || node.out, node.out || node.in);
556
+ const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
557
+ if (borderWidth) {
558
+ ctx.strokeRect(x, y, nodeWidth, height);
559
+ }
560
+ ctx.fillRect(x, y, nodeWidth, height);
561
+ }
562
+ ctx.restore();
645
563
  }
646
-
647
- /* draw SankeyNodes on the canvas */
648
- this._drawNodes();
649
-
650
- /* draw Flow elements on the canvas */
651
- for (let i = 0, ilen = data.length; i < ilen; ++i) {
652
- data[i].draw(ctx);
564
+ draw() {
565
+ const ctx = this.chart.ctx;
566
+ const data = this.getMeta().data ?? [];
567
+ const active = [];
568
+ for(let i = 0, ilen = data.length; i < ilen; ++i){
569
+ const flow = data[i];
570
+ flow.from.color = flow.options.colorFrom;
571
+ flow.to.color = flow.options.colorTo;
572
+ if (flow.active) {
573
+ active.push(flow);
574
+ }
575
+ }
576
+ for (const flow of active){
577
+ flow.from.color = flow.options.colorFrom;
578
+ flow.to.color = flow.options.colorTo;
579
+ }
580
+ this._drawNodes();
581
+ for(let i = 0, ilen = data.length; i < ilen; ++i){
582
+ data[i].draw(ctx);
583
+ }
584
+ this._drawLabels();
653
585
  }
654
-
655
- /* draw labels (for SankeyNodes) on the canvas */
656
- this._drawLabels();
657
- }
658
586
  }
659
-
660
587
  SankeyController.id = 'sankey';
661
-
662
588
  SankeyController.defaults = {
663
- dataElementType: 'flow',
664
- animations: {
665
- numbers: {
666
- type: 'number',
667
- properties: ['x', 'y', 'x2', 'y2', 'height']
668
- },
669
- progress: {
670
- easing: 'linear',
671
- duration: (ctx) => ctx.type === 'data' ? (ctx.parsed._custom.x - ctx.parsed.x) * 200 : undefined,
672
- delay: (ctx) => ctx.type === 'data' ? ctx.parsed.x * 500 + ctx.dataIndex * 20 : undefined,
673
- },
674
- colors: {
675
- type: 'color',
676
- properties: ['colorFrom', 'colorTo'],
677
- },
678
- },
679
- transitions: {
680
- hide: {
681
- animations: {
589
+ dataElementType: 'flow',
590
+ animations: {
591
+ numbers: {
592
+ type: 'number',
593
+ properties: [
594
+ 'x',
595
+ 'y',
596
+ 'x2',
597
+ 'y2',
598
+ 'height'
599
+ ]
600
+ },
601
+ progress: {
602
+ easing: 'linear',
603
+ duration: (ctx)=>ctx.type === 'data' ? (ctx.parsed._custom.x - ctx.parsed.x) * 200 : undefined,
604
+ delay: (ctx)=>ctx.type === 'data' ? ctx.parsed.x * 500 + ctx.dataIndex * 20 : undefined
605
+ },
682
606
  colors: {
683
- type: 'color',
684
- properties: ['colorFrom', 'colorTo'],
685
- to: 'transparent'
607
+ type: 'color',
608
+ properties: [
609
+ 'colorFrom',
610
+ 'colorTo'
611
+ ]
686
612
  }
687
- }
688
613
  },
689
- show: {
690
- animations: {
691
- colors: {
692
- type: 'color',
693
- properties: ['colorFrom', 'colorTo'],
694
- from: 'transparent'
614
+ color: 'black',
615
+ borderColor: 'black',
616
+ borderWidth: 1,
617
+ modeX: 'edge',
618
+ nodeWidth: 10,
619
+ nodePadding: 10,
620
+ transitions: {
621
+ hide: {
622
+ animations: {
623
+ colors: {
624
+ type: 'color',
625
+ properties: [
626
+ 'colorFrom',
627
+ 'colorTo'
628
+ ],
629
+ to: 'transparent'
630
+ }
631
+ }
632
+ },
633
+ show: {
634
+ animations: {
635
+ colors: {
636
+ type: 'color',
637
+ properties: [
638
+ 'colorFrom',
639
+ 'colorTo'
640
+ ],
641
+ from: 'transparent'
642
+ }
643
+ }
695
644
  }
696
- }
697
645
  }
698
- }
699
646
  };
700
-
701
647
  SankeyController.overrides = {
702
- interaction: {
703
- mode: 'nearest',
704
- intersect: true
705
- },
706
- datasets: {
707
- clip: false,
708
- parsing: true
709
- },
710
- plugins: {
711
- tooltip: {
712
- callbacks: {
713
- title() {
714
- return '';
715
- },
716
- label(context) {
717
- const item = context.dataset.data[context.dataIndex];
718
- return item.from + ' -> ' + item.to + ': ' + item.flow;
719
- }
720
- },
648
+ interaction: {
649
+ mode: 'nearest',
650
+ intersect: true
721
651
  },
722
- legend: {
723
- display: false,
724
- },
725
- },
726
- scales: {
727
- x: {
728
- type: 'linear',
729
- bounds: 'data',
730
- display: false,
731
- min: 0,
732
- offset: false,
652
+ datasets: {
653
+ clip: false,
654
+ parsing: {
655
+ from: 'from',
656
+ to: 'to',
657
+ flow: 'flow'
658
+ }
733
659
  },
734
- y: {
735
- type: 'linear',
736
- bounds: 'data',
737
- display: false,
738
- min: 0,
739
- reverse: true,
740
- offset: false,
660
+ plugins: {
661
+ tooltip: {
662
+ callbacks: {
663
+ title () {
664
+ return '';
665
+ },
666
+ label (context) {
667
+ const parsedCustom = context.parsed._custom;
668
+ return parsedCustom.from.key + ' -> ' + parsedCustom.to.key + ': ' + parsedCustom.flow;
669
+ }
670
+ }
671
+ },
672
+ legend: {
673
+ display: false
674
+ }
741
675
  },
742
- },
743
- layout: {
744
- padding: {
745
- top: 3,
746
- left: 3,
747
- right: 13,
748
- bottom: 3,
676
+ scales: {
677
+ x: {
678
+ type: 'linear',
679
+ bounds: 'data',
680
+ display: false,
681
+ min: 0,
682
+ offset: false
683
+ },
684
+ y: {
685
+ type: 'linear',
686
+ bounds: 'data',
687
+ display: false,
688
+ min: 0,
689
+ reverse: true,
690
+ offset: false
691
+ }
749
692
  },
750
- },
693
+ layout: {
694
+ padding: {
695
+ top: 3,
696
+ left: 3,
697
+ right: 13,
698
+ bottom: 3
699
+ }
700
+ }
751
701
  };
752
702
 
753
- /**
754
- * @typedef {{x: number, y: number}} ControlPoint
755
- * @typedef {{cp1: ControlPoint, cp2: ControlPoint}} ControlPoints
756
- *
757
- * @param {number} x
758
- * @param {number} y
759
- * @param {number} x2
760
- * @param {number} y2
761
- * @return {ControlPoints}
762
- */
763
- const controlPoints = (x, y, x2, y2) => x < x2
764
- ? {
765
- cp1: {x: x + (x2 - x) / 3 * 2, y},
766
- cp2: {x: x + (x2 - x) / 3, y: y2}
767
- }
768
- : {
769
- cp1: {x: x - (x - x2) / 3, y: 0},
770
- cp2: {x: x2 + (x - x2) / 3, y: 0}
771
- };
772
-
773
- /**
774
- *
775
- * @param {ControlPoint} p1
776
- * @param {ControlPoint} p2
777
- * @param {number} t
778
- * @return {ControlPoint}
779
- */
780
- const pointInLine = (p1, p2, t) => ({x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y)});
781
-
782
- /**
783
- * @param {CanvasRenderingContext2D} ctx
784
- * @param {Flow} flow
785
- */
786
- function setStyle(ctx, {x, x2, options}) {
787
- let fill;
788
-
789
- if (options.colorMode === 'from') {
790
- /**
791
- * @todo remove the alpha and use tha alpha provided in colorFrom / colorTo in next major version
792
- */
793
- fill = color(options.colorFrom).alpha(options.alpha).rgbString();
794
- } else if (options.colorMode === 'to') {
795
- fill = color(options.colorTo).alpha(options.alpha).rgbString();
796
- } else {
797
- fill = ctx.createLinearGradient(x, 0, x2, 0);
798
- fill.addColorStop(0, color(options.colorFrom).alpha(options.alpha).rgbString());
799
- fill.addColorStop(1, color(options.colorTo).alpha(options.alpha).rgbString());
800
- }
801
-
802
- ctx.fillStyle = fill;
803
- ctx.strokeStyle = fill;
804
- ctx.lineWidth = 0.5;
703
+ const controlPoints = (x, y, x2, y2)=>x < x2 ? {
704
+ cp1: {
705
+ x: x + (x2 - x) / 3 * 2,
706
+ y
707
+ },
708
+ cp2: {
709
+ x: x + (x2 - x) / 3,
710
+ y: y2
711
+ }
712
+ } : {
713
+ cp1: {
714
+ x: x - (x - x2) / 3,
715
+ y: 0
716
+ },
717
+ cp2: {
718
+ x: x2 + (x - x2) / 3,
719
+ y: 0
720
+ }
721
+ };
722
+ const pointInLine = (p1, p2, t)=>({
723
+ x: p1.x + t * (p2.x - p1.x),
724
+ y: p1.y + t * (p2.y - p1.y)
725
+ });
726
+ const applyAlpha = (original, alpha)=>color(original).alpha(alpha).rgbString();
727
+ const getColorOption = (option, alpha)=>typeof option === 'string' ? applyAlpha(option, alpha) : option;
728
+ function setStyle(ctx, { x, x2, options }) {
729
+ let fill = 'black';
730
+ if (options.colorMode === 'from') {
731
+ fill = getColorOption(options.colorFrom, options.alpha);
732
+ } else if (options.colorMode === 'to') {
733
+ fill = getColorOption(options.colorTo, options.alpha);
734
+ } else if (typeof options.colorFrom === 'string' && typeof options.colorTo === 'string') {
735
+ fill = ctx.createLinearGradient(x, 0, x2, 0);
736
+ fill.addColorStop(0, applyAlpha(options.colorFrom, options.alpha));
737
+ fill.addColorStop(1, applyAlpha(options.colorTo, options.alpha));
738
+ }
739
+ ctx.fillStyle = fill;
740
+ ctx.strokeStyle = fill;
741
+ ctx.lineWidth = 0.5;
805
742
  }
806
-
807
743
  class Flow extends Element {
808
-
809
- /**
810
- * @param {FlowConfig} cfg
811
- */
812
- constructor(cfg) {
813
- super();
814
-
815
- this.options = undefined;
816
- this.x = undefined;
817
- this.y = undefined;
818
- this.x2 = undefined;
819
- this.y2 = undefined;
820
- this.height = undefined;
821
-
822
- if (cfg) {
823
- Object.assign(this, cfg);
744
+ draw(ctx) {
745
+ const { x, x2, y, y2, height, progress } = this;
746
+ const { cp1, cp2 } = controlPoints(x, y, x2, y2);
747
+ if (progress === 0) {
748
+ return;
749
+ }
750
+ ctx.save();
751
+ if (progress < 1) {
752
+ ctx.beginPath();
753
+ ctx.rect(x, Math.min(y, y2), (x2 - x) * progress + 1, Math.abs(y2 - y) + height + 1);
754
+ ctx.clip();
755
+ }
756
+ setStyle(ctx, this);
757
+ ctx.beginPath();
758
+ ctx.moveTo(x, y);
759
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, x2, y2);
760
+ ctx.lineTo(x2, y2 + height);
761
+ ctx.bezierCurveTo(cp2.x, cp2.y + height, cp1.x, cp1.y + height, x, y + height);
762
+ ctx.lineTo(x, y);
763
+ ctx.stroke();
764
+ ctx.closePath();
765
+ ctx.fill();
766
+ ctx.restore();
824
767
  }
825
- }
826
-
827
- /**
828
- * @param {CanvasRenderingContext2D} ctx
829
- */
830
- draw(ctx) {
831
- const me = this;
832
- const {x, x2, y, y2, height, progress} = me;
833
- const {cp1, cp2} = controlPoints(x, y, x2, y2);
834
-
835
- if (progress === 0) {
836
- return;
768
+ inRange(mouseX, mouseY, useFinalPosition) {
769
+ const { x, y, x2, y2, height } = this.getProps([
770
+ 'x',
771
+ 'y',
772
+ 'x2',
773
+ 'y2',
774
+ 'height'
775
+ ], useFinalPosition);
776
+ if (mouseX < x || mouseX > x2) {
777
+ return false;
778
+ }
779
+ const { cp1, cp2 } = controlPoints(x, y, x2, y2);
780
+ const t = (mouseX - x) / (x2 - x);
781
+ const p1 = {
782
+ x,
783
+ y
784
+ };
785
+ const p2 = {
786
+ x: x2,
787
+ y: y2
788
+ };
789
+ const a = pointInLine(p1, cp1, t);
790
+ const b = pointInLine(cp1, cp2, t);
791
+ const c = pointInLine(cp2, p2, t);
792
+ const d = pointInLine(a, b, t);
793
+ const e = pointInLine(b, c, t);
794
+ const topY = pointInLine(d, e, t).y;
795
+ return mouseY >= topY && mouseY <= topY + height;
837
796
  }
838
- ctx.save();
839
- if (progress < 1) {
840
- ctx.beginPath();
841
- ctx.rect(x, Math.min(y, y2), (x2 - x) * progress + 1, Math.abs(y2 - y) + height + 1);
842
- ctx.clip();
797
+ inXRange(mouseX, useFinalPosition) {
798
+ const { x, x2 } = this.getProps([
799
+ 'x',
800
+ 'x2'
801
+ ], useFinalPosition);
802
+ return mouseX >= x && mouseX <= x2;
803
+ }
804
+ inYRange(mouseY, useFinalPosition) {
805
+ const { y, y2, height } = this.getProps([
806
+ 'y',
807
+ 'y2',
808
+ 'height'
809
+ ], useFinalPosition);
810
+ const minY = Math.min(y, y2);
811
+ const maxY = Math.max(y, y2) + height;
812
+ return mouseY >= minY && mouseY <= maxY;
813
+ }
814
+ getCenterPoint(useFinalPosition) {
815
+ const { x, y, x2, y2, height } = this.getProps([
816
+ 'x',
817
+ 'y',
818
+ 'x2',
819
+ 'y2',
820
+ 'height'
821
+ ], useFinalPosition);
822
+ return {
823
+ x: (x + x2) / 2,
824
+ y: (y + y2 + height) / 2
825
+ };
826
+ }
827
+ tooltipPosition(useFinalPosition) {
828
+ return this.getCenterPoint(useFinalPosition);
829
+ }
830
+ getRange(axis) {
831
+ return axis === 'x' ? this.width / 2 : this.height / 2;
832
+ }
833
+ constructor(cfg){
834
+ super();
835
+ if (cfg) {
836
+ Object.assign(this, cfg);
837
+ }
843
838
  }
844
-
845
- setStyle(ctx, me);
846
-
847
- ctx.beginPath();
848
- ctx.moveTo(x, y);
849
- ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, x2, y2);
850
- ctx.lineTo(x2, y2 + height);
851
- ctx.bezierCurveTo(cp2.x, cp2.y + height, cp1.x, cp1.y + height, x, y + height);
852
- ctx.lineTo(x, y);
853
- ctx.stroke();
854
- ctx.closePath();
855
-
856
- ctx.fill();
857
-
858
- ctx.restore();
859
- }
860
-
861
- /**
862
- * @param {number} mouseX
863
- * @param {number} mouseY
864
- * @param {boolean} useFinalPosition
865
- * @return {boolean}
866
- */
867
- inRange(mouseX, mouseY, useFinalPosition) {
868
- const {x, y, x2, y2, height} = this.getProps(['x', 'y', 'x2', 'y2', 'height'], useFinalPosition);
869
- if (mouseX < x || mouseX > x2) {
870
- return false;
871
- }
872
- const {cp1, cp2} = controlPoints(x, y, x2, y2);
873
- const t = (mouseX - x) / (x2 - x);
874
- const p1 = {x, y};
875
- const p2 = {x: x2, y: y2};
876
- const a = pointInLine(p1, cp1, t);
877
- const b = pointInLine(cp1, cp2, t);
878
- const c = pointInLine(cp2, p2, t);
879
- const d = pointInLine(a, b, t);
880
- const e = pointInLine(b, c, t);
881
- const topY = pointInLine(d, e, t).y;
882
- return mouseY >= topY && mouseY <= topY + height;
883
- }
884
-
885
- /**
886
- * @param {number} mouseX
887
- * @param {boolean} useFinalPosition
888
- * @return {boolean}
889
- */
890
- inXRange(mouseX, useFinalPosition) {
891
- const {x, x2} = this.getProps(['x', 'x2'], useFinalPosition);
892
- return mouseX >= x && mouseX <= x2;
893
- }
894
-
895
- /**
896
- * @param {number} mouseY
897
- * @param {boolean} useFinalPosition
898
- * @return {boolean}
899
- */
900
- inYRange(mouseY, useFinalPosition) {
901
- const {y, y2, height} = this.getProps(['y', 'y2', 'height'], useFinalPosition);
902
- const minY = Math.min(y, y2);
903
- const maxY = Math.max(y, y2) + height;
904
- return mouseY >= minY && mouseY <= maxY;
905
- }
906
-
907
- /**
908
- * @param {boolean} useFinalPosition
909
- * @return {{x: number, y:number}}
910
- */
911
- getCenterPoint(useFinalPosition) {
912
- const {x, y, x2, y2, height} = this.getProps(['x', 'y', 'x2', 'y2', 'height'], useFinalPosition);
913
- return {
914
- x: (x + x2) / 2,
915
- y: (y + y2 + height) / 2
916
- };
917
- }
918
-
919
- tooltipPosition(useFinalPosition) {
920
- return this.getCenterPoint(useFinalPosition);
921
- }
922
-
923
- /**
924
- * @param {"x" | "y"} axis
925
- * @return {number}
926
- */
927
- getRange(axis) {
928
- return axis === 'x' ? this.width / 2 : this.height / 2;
929
- }
930
839
  }
931
-
932
840
  Flow.id = 'flow';
933
841
  Flow.defaults = {
934
- colorFrom: 'red',
935
- colorTo: 'green',
936
- colorMode: 'gradient',
937
- alpha: 0.5,
938
- hoverColorFrom: (ctx, options) => getHoverColor(options.colorFrom),
939
- hoverColorTo: (ctx, options) => getHoverColor(options.colorTo)
842
+ colorFrom: 'red',
843
+ colorTo: 'green',
844
+ colorMode: 'gradient',
845
+ alpha: 0.5,
846
+ hoverColorFrom: (_ctx, options)=>getHoverColor(options.colorFrom),
847
+ hoverColorTo: (_ctx, options)=>getHoverColor(options.colorTo)
848
+ };
849
+ Flow.descriptors = {
850
+ _scriptable: true
940
851
  };
941
852
 
942
853
  export { Flow, SankeyController };