chartjs-chart-sankey 0.14.3 → 0.15.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,10 +1,10 @@
1
1
  /*!
2
- * chartjs-chart-sankey v0.14.3
2
+ * chartjs-chart-sankey v0.15.0
3
3
  * https://chartjs-chart-sankey.pages.dev/
4
4
  * (c) 2026 Jukka Kurkela
5
5
  * Released under the MIT license
6
6
  */
7
- import { DatasetController, Element } from 'chart.js';
7
+ import { DatasetController, Chart, Element } from 'chart.js';
8
8
  import { toFont, valueOrDefault, getHoverColor, color } from 'chart.js/helpers';
9
9
 
10
10
  const defined = (x)=>x !== undefined;
@@ -36,6 +36,99 @@ function validateSizeValue(size) {
36
36
  return size;
37
37
  }
38
38
 
39
+ function isPatternOrGradient(value) {
40
+ const type = Object.prototype.toString.call(value);
41
+ return type === '[object CanvasPattern]' || type === '[object CanvasGradient]';
42
+ }
43
+ function resolveNodeLabelOption(option, node) {
44
+ if (typeof option === 'function') {
45
+ return option(node);
46
+ }
47
+ if (option && typeof option === 'object' && !isPatternOrGradient(option)) {
48
+ return option[node.key];
49
+ }
50
+ return option;
51
+ }
52
+ function addRoundedRectPath(ctx, x, y, width, height, radius) {
53
+ const r = Math.max(0, Math.min(radius, width / 2, height / 2));
54
+ ctx.beginPath();
55
+ ctx.moveTo(x + r, y);
56
+ ctx.lineTo(x + width - r, y);
57
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
58
+ ctx.lineTo(x + width, y + height - r);
59
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
60
+ ctx.lineTo(x + r, y + height);
61
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
62
+ ctx.lineTo(x, y + r);
63
+ ctx.quadraticCurveTo(x, y, x + r, y);
64
+ ctx.closePath();
65
+ }
66
+ function resolvePosition(position, autoPosition) {
67
+ return position === 'auto' ? autoPosition : position;
68
+ }
69
+ function getTextPosition(position, options, totalTextHeight) {
70
+ const { borderWidth, height, padding, width, x, y } = options;
71
+ const text = {
72
+ align: 'center',
73
+ x: x + width / 2,
74
+ y: y + height / 2
75
+ };
76
+ if (position === 'left') {
77
+ text.align = 'right';
78
+ text.x = x - borderWidth - padding;
79
+ } else if (position === 'right') {
80
+ text.align = 'left';
81
+ text.x = x + width + borderWidth + padding;
82
+ } else if (position === 'top') {
83
+ text.y = y - padding - totalTextHeight / 2;
84
+ } else if (position === 'bottom') {
85
+ text.y = y + height + padding + totalTextHeight / 2;
86
+ }
87
+ return text;
88
+ }
89
+ function getBackgroundX(textAlign, textX, textWidth, backgroundWidth, padding) {
90
+ if (textAlign === 'left') return textX - padding;
91
+ if (textAlign === 'right') return textX - textWidth - padding;
92
+ return textX - backgroundWidth / 2;
93
+ }
94
+ function drawBackground(ctx, color, x, y, width, height, borderRadius) {
95
+ ctx.save();
96
+ ctx.fillStyle = color;
97
+ if (borderRadius > 0) {
98
+ addRoundedRectPath(ctx, x, y, width, height, borderRadius);
99
+ ctx.fill();
100
+ } else {
101
+ ctx.fillRect(x, y, width, height);
102
+ }
103
+ ctx.restore();
104
+ }
105
+ function drawLabel(ctx, label, options) {
106
+ const lines = toTextLines(label);
107
+ if (!lines.length) return;
108
+ const { backgroundColor, borderRadius, color, font, lineOffset, padding } = options;
109
+ const position = resolvePosition(options.position, options.autoPosition);
110
+ const textHeight = Number(font.lineHeight);
111
+ ctx.font = font.string;
112
+ const textWidth = Math.max(...lines.map((line)=>ctx.measureText(line).width));
113
+ const totalTextHeight = textHeight * lines.length;
114
+ const text = getTextPosition(position, options, totalTextHeight);
115
+ ctx.textAlign = text.align;
116
+ ctx.textBaseline = 'middle';
117
+ const backgroundWidth = textWidth + padding * 2;
118
+ const backgroundHeight = totalTextHeight + padding * 2;
119
+ const backgroundX = getBackgroundX(text.align, text.x, textWidth, backgroundWidth, padding);
120
+ const firstLineY = lines.length === 1 ? text.y : text.y - totalTextHeight / 2 + lineOffset;
121
+ const textCenterY = firstLineY + (lines.length - 1) * textHeight / 2;
122
+ const backgroundY = textCenterY - backgroundHeight / 2;
123
+ if (backgroundColor !== undefined) {
124
+ drawBackground(ctx, backgroundColor, backgroundX, backgroundY, backgroundWidth, backgroundHeight, borderRadius);
125
+ }
126
+ ctx.fillStyle = color;
127
+ for(let i = 0; i < lines.length; i++){
128
+ ctx.fillText(lines[i], text.x, firstLineY + i * textHeight);
129
+ }
130
+ }
131
+
39
132
  const flowSort = (a, b)=>{
40
133
  if (b.flow === a.flow) return a.index - b.index;
41
134
  return b.flow - a.flow;
@@ -123,6 +216,12 @@ function buildNodesFromData(data, { size, priority, column }) {
123
216
  }
124
217
 
125
218
  const SMALL_VALUE = 1e-6;
219
+ function nodeX$1(node) {
220
+ return node.x ?? 0;
221
+ }
222
+ function nodeY$1(node) {
223
+ return node.y ?? 0;
224
+ }
126
225
  const getAllKeysForward = (nodes, visited = new Set())=>{
127
226
  const keys = [];
128
227
  for (const node of nodes){
@@ -180,7 +279,7 @@ function calculateX(nodeMap, data, mode) {
180
279
  x++;
181
280
  }
182
281
  }
183
- const maxX = allNodes.reduce((max, node)=>Math.max(max, node.x), 0);
282
+ const maxX = allNodes.reduce((max, node)=>Math.max(max, nodeX$1(node)), 0);
184
283
  if (mode === 'edge') {
185
284
  const from = new Set(data.map((dataPoint)=>dataPoint.from));
186
285
  allKeys.filter((key)=>!from.has(key)).forEach((key)=>{
@@ -220,20 +319,26 @@ function processFrom(node, y) {
220
319
  }
221
320
  y = Math.max(n.y + n.out, y);
222
321
  }
223
- return node.y + node.size;
322
+ return nodeY$1(node) + node.size;
224
323
  }
324
+ const returnsToNearerColumn = (current, next)=>Boolean(next && nodeX$1(next) < nodeX$1(current));
225
325
  function processTo(node, y) {
226
326
  if (!node.to.length) return y;
227
327
  node.to.sort(flowByNodeCount('to'));
228
- for (const flow of node.to){
328
+ for(let i = 0; i < node.to.length; i++){
329
+ const flow = node.to[i];
229
330
  const n = flow.node;
230
331
  if (!defined(n.y)) {
231
332
  n.y = y;
232
333
  processTo(n, y ? y + SMALL_VALUE : 0);
233
334
  }
234
- y = Math.max(n.y + Math.max(n.in, n.out), y);
335
+ if (returnsToNearerColumn(n, node.to[i + 1]?.node)) {
336
+ y += flow.flow;
337
+ } else {
338
+ y = Math.max(n.y + Math.max(n.in, n.out), y);
339
+ }
235
340
  }
236
- return node.y + node.size;
341
+ return nodeY$1(node) + node.size;
237
342
  }
238
343
  function setOrGetY(node, value) {
239
344
  if (defined(node.y)) {
@@ -247,9 +352,9 @@ function processRest(nodeArray, maxX) {
247
352
  const rightNodes = nodeArray.filter((node)=>node.x === maxX);
248
353
  const leftToDo = leftNodes.filter((node)=>!defined(node.y));
249
354
  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;
355
+ const centerToDo = nodeArray.filter((node)=>nodeX$1(node) > 0 && nodeX$1(node) < maxX && !defined(node.y));
356
+ let leftY = leftNodes.reduce((acc, cur)=>Math.max(acc, nodeY$1(cur) + cur.out || 0), 0) + SMALL_VALUE;
357
+ let rightY = rightNodes.reduce((acc, cur)=>Math.max(acc, nodeY$1(cur) + cur.in || 0), 0) + SMALL_VALUE;
253
358
  let centerY = 0;
254
359
  if (leftY >= rightY) {
255
360
  leftToDo.forEach((node)=>{
@@ -270,7 +375,7 @@ function processRest(nodeArray, maxX) {
270
375
  });
271
376
  }
272
377
  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);
378
+ let y = nodeArray.filter((n)=>nodeX$1(n) === nodeX$1(node) && defined(n.y)).reduce((acc, cur)=>Math.max(acc, nodeY$1(cur) + Math.max(cur.in, cur.out)), 0);
274
379
  y = setOrGetY(node, y);
275
380
  y = Math.max(y + node.in, processFrom(node, y));
276
381
  y = Math.max(y + node.out, processTo(node, y));
@@ -281,25 +386,29 @@ function processRest(nodeArray, maxX) {
281
386
  const fixTop = (nodeArray, maxX)=>{
282
387
  let maxY = 0;
283
388
  for(let x = 0; x <= maxX; x++){
284
- const nodes = nodeArray.filter((n)=>n.x === x).sort((a, b)=>a.y - b.y);
389
+ const nodes = nodeArray.filter((n)=>nodeX$1(n) === x).sort((a, b)=>nodeY$1(a) - nodeY$1(b));
285
390
  let minY = 0;
286
391
  for (const node of nodes){
287
- if (node.y < minY) node.y = minY;
288
- minY = node.y + node.size;
392
+ if (nodeY$1(node) < minY) node.y = minY;
393
+ minY = nodeY$1(node) + node.size;
289
394
  }
290
395
  maxY = Math.max(maxY, minY);
291
396
  }
292
397
  return maxY;
293
398
  };
294
399
  const findStartNode = (nodeArray, maxX)=>{
295
- const size = [
400
+ const sorted = [
296
401
  ...nodeArray
297
- ].sort((a, b)=>a.size - b.size).pop().size;
402
+ ].sort((a, b)=>a.size - b.size);
403
+ const largest = sorted[sorted.length - 1];
404
+ const size = largest.size;
298
405
  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();
406
+ const first = biggest[0];
407
+ if (biggest.length === 1) return first;
408
+ biggest.sort((a, b)=>nodeX$1(a) - nodeX$1(b));
409
+ if (nodeX$1(first) === 0) return first;
410
+ const last = biggest[biggest.length - 1];
411
+ if (nodeX$1(last) === maxX) return last;
303
412
  const mid = Math.floor(biggest.length / 2);
304
413
  return biggest[mid];
305
414
  };
@@ -317,8 +426,11 @@ function calculateYUsingPriority(nodeArray, maxX) {
317
426
  let nextYStart = 0;
318
427
  for(let x = 0; x <= maxX; x++){
319
428
  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;
429
+ const nodes = nodeArray.filter((node)=>nodeX$1(node) === x).sort((a, b)=>(a.priority ?? 0) - (b.priority ?? 0));
430
+ if (nodes.length) {
431
+ const nextX = nodeArray.reduce((next, node)=>nodeX$1(node) > x ? Math.min(next, nodeX$1(node)) : next, Infinity);
432
+ nextYStart = nodes[0].to.filter((to)=>nodeX$1(to.node) > nextX).reduce((acc, cur)=>acc + cur.flow, 0) || 0;
433
+ }
322
434
  for (const node of nodes){
323
435
  node.y = y;
324
436
  y += Math.max(node.out, node.in);
@@ -328,9 +440,9 @@ function calculateYUsingPriority(nodeArray, maxX) {
328
440
  return maxY;
329
441
  }
330
442
  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;
443
+ if (nodeX$1(a) !== nodeX$1(b)) return nodeX$1(a) - nodeX$1(b);
444
+ if (nodeY$1(a) === nodeY$1(b)) return a.size - b.size;
445
+ return nodeY$1(a) - nodeY$1(b);
334
446
  };
335
447
  function addPadding(nodeArray, padding) {
336
448
  let maxY = 0;
@@ -338,31 +450,33 @@ const nodeByXYSize = (a, b)=>{
338
450
  const grid = [];
339
451
  const getColIndex = (x)=>{
340
452
  if (!columnXs.has(x)) {
341
- columnXs.set(x, grid.length);
453
+ const index = grid.length;
454
+ columnXs.set(x, index);
342
455
  grid.push([]);
456
+ return index;
343
457
  }
344
- return columnXs.get(x);
458
+ return columnXs.get(x) ?? 0;
345
459
  };
346
460
  nodeArray.sort(nodeByXYSize);
347
461
  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);
462
+ const colIdx = getColIndex(nodeX$1(node));
463
+ const column = grid[colIdx] ?? [];
464
+ if (nodeY$1(node)) {
465
+ column.push(nodeY$1(node));
352
466
  let paddings = column.length;
353
467
  if (node.in) {
354
468
  for(let col = 0; col < colIdx; col++){
355
- const otherColumn = grid[col];
469
+ const otherColumn = grid[col] ?? [];
356
470
  for(let row = 0; row < otherColumn.length; row++){
357
- if (otherColumn[row] > node.y) break;
471
+ if (otherColumn[row] > nodeY$1(node)) break;
358
472
  paddings = Math.max(row + 1, paddings);
359
473
  }
360
474
  }
361
- while(column.length < paddings)column.push(node.y);
475
+ while(column.length < paddings)column.push(nodeY$1(node));
362
476
  }
363
- node.y += paddings * padding;
477
+ node.y = nodeY$1(node) + paddings * padding;
364
478
  }
365
- maxY = Math.max(maxY, node.y + Math.max(node.in, node.out));
479
+ maxY = Math.max(maxY, nodeY$1(node) + Math.max(node.in, node.out));
366
480
  }
367
481
  return maxY;
368
482
  }
@@ -373,7 +487,7 @@ function sortFlows(nodeArray) {
373
487
  const overlapTo = nodeSize < node.out;
374
488
  let addY = 0;
375
489
  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)=>{
490
+ node.from.sort((a, b)=>nodeY$1(a.node) + a.node.out / 2 - (nodeY$1(b.node) + b.node.out / 2)).forEach((flow, idx)=>{
377
491
  if (overlapFrom) {
378
492
  flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
379
493
  } else {
@@ -383,7 +497,7 @@ function sortFlows(nodeArray) {
383
497
  });
384
498
  addY = 0;
385
499
  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)=>{
500
+ node.to.sort((a, b)=>nodeY$1(a.node) + a.node.in / 2 - (nodeY$1(b.node) + b.node.in / 2)).forEach((flow, idx)=>{
387
501
  if (overlapTo) {
388
502
  flow.addY = idx * (nodeSize - flow.flow) / (len - 1);
389
503
  } else {
@@ -397,7 +511,7 @@ function layout(nodes, data, { priority, height, nodePadding, modeX }) {
397
511
  const nodeArray = [
398
512
  ...nodes.values()
399
513
  ];
400
- const maxX = calculateX(nodes, data, modeX);
514
+ const maxX = calculateX(nodes, data, modeX ?? 'edge');
401
515
  const maxY = priority ? calculateYUsingPriority(nodeArray, maxX) : calculateY(nodeArray, maxX);
402
516
  const padding = maxY / height * nodePadding;
403
517
  const maxYWithPadding = addPadding(nodeArray, padding);
@@ -408,6 +522,21 @@ function layout(nodes, data, { priority, height, nodePadding, modeX }) {
408
522
  };
409
523
  }
410
524
 
525
+ function nodeX(node) {
526
+ return node.x ?? 0;
527
+ }
528
+ function nodeY(node) {
529
+ return node.y ?? 0;
530
+ }
531
+ function getNodeSize(node, size) {
532
+ return Math[size](node.in || node.out, node.out || node.in);
533
+ }
534
+ function getAutoLabelPosition(x, y, chartArea, orientation) {
535
+ if (orientation === 'vertical') {
536
+ return y < (chartArea.top + chartArea.bottom) / 2 ? 'bottom' : 'top';
537
+ }
538
+ return x < (chartArea.left + chartArea.right) / 2 ? 'right' : 'left';
539
+ }
411
540
  function getAddY(arr, key, index) {
412
541
  for (const item of arr){
413
542
  if (item.key === key && item.index === index) {
@@ -416,17 +545,113 @@ function getAddY(arr, key, index) {
416
545
  }
417
546
  return 0;
418
547
  }
548
+ function parseFlow(from, to, fromY, toY, flow, index, xScale, yScale, orientation) {
549
+ if (orientation === 'vertical') {
550
+ return {
551
+ _custom: {
552
+ flow,
553
+ from,
554
+ height: xScale.parse(flow, index),
555
+ to,
556
+ x: xScale.parse(toY, index),
557
+ y: yScale.parse(nodeX(to), index)
558
+ },
559
+ x: xScale.parse(fromY, index),
560
+ y: yScale.parse(nodeX(from), index)
561
+ };
562
+ }
563
+ return {
564
+ _custom: {
565
+ flow,
566
+ from,
567
+ height: yScale.parse(flow, index),
568
+ to,
569
+ x: xScale.parse(nodeX(to), index),
570
+ y: yScale.parse(toY, index)
571
+ },
572
+ x: xScale.parse(nodeX(from), index),
573
+ y: yScale.parse(fromY, index)
574
+ };
575
+ }
576
+ function getFlowElementProperties(parsed, xScale, yScale, maxColumn, nodeWidth, columnPadding, borderSpace, orientation) {
577
+ const custom = parsed._custom;
578
+ const x = xScale.getPixelForValue(parsed.x);
579
+ const y = yScale.getPixelForValue(parsed.y);
580
+ if (orientation === 'vertical') {
581
+ return {
582
+ flow: custom.flow,
583
+ from: custom.from,
584
+ height: 0,
585
+ to: custom.to,
586
+ width: Math.abs(xScale.getPixelForValue(parsed.x + custom.height) - x),
587
+ x,
588
+ x2: xScale.getPixelForValue(custom.x),
589
+ y: getColumnPixel(yScale, parsed.y, maxColumn, columnPadding) + nodeWidth + borderSpace,
590
+ y2: getColumnPixel(yScale, custom.y, maxColumn, columnPadding) - borderSpace
591
+ };
592
+ }
593
+ return {
594
+ flow: custom.flow,
595
+ from: custom.from,
596
+ height: Math.abs(yScale.getPixelForValue(parsed.y + custom.height) - y),
597
+ to: custom.to,
598
+ width: 0,
599
+ x: getColumnPixel(xScale, parsed.x, maxColumn, columnPadding) + nodeWidth + borderSpace,
600
+ x2: getColumnPixel(xScale, custom.x, maxColumn, columnPadding) - borderSpace,
601
+ y,
602
+ y2: yScale.getPixelForValue(custom.y)
603
+ };
604
+ }
605
+ function getColumnPixel(scale, value, maxColumn, padding) {
606
+ const pixel = scale.getPixelForValue(value);
607
+ return maxColumn ? pixel - value / maxColumn * padding : pixel;
608
+ }
609
+ function getColumnPadding(nodeWidth, orientation, chart) {
610
+ const trailingSpace = orientation === 'vertical' ? chart.height - chart.chartArea.bottom : chart.width - chart.chartArea.right;
611
+ return Math.max(0, nodeWidth + 3 - trailingSpace);
612
+ }
613
+ function getNodeRect(node, size, xScale, yScale, maxColumn, nodeWidth, columnPadding, orientation) {
614
+ if (orientation === 'vertical') {
615
+ const x = xScale.getPixelForValue(nodeY(node));
616
+ return {
617
+ height: nodeWidth,
618
+ width: Math.abs(xScale.getPixelForValue(nodeY(node) + size) - x),
619
+ x,
620
+ y: getColumnPixel(yScale, nodeX(node), maxColumn, columnPadding)
621
+ };
622
+ }
623
+ const y = yScale.getPixelForValue(nodeY(node));
624
+ return {
625
+ height: Math.abs(yScale.getPixelForValue(nodeY(node) + size) - y),
626
+ width: nodeWidth,
627
+ x: getColumnPixel(xScale, nodeX(node), maxColumn, columnPadding),
628
+ y
629
+ };
630
+ }
631
+ function resolveNodeLabelStyle(options, node) {
632
+ const { backgroundColor, borderRadius = 0, color, display, font, padding = 4, position } = options.nodeLabels ?? {};
633
+ return {
634
+ backgroundColor: resolveNodeLabelOption(backgroundColor, node),
635
+ borderRadius,
636
+ color: resolveNodeLabelOption(color, node) ?? options.color ?? 'black',
637
+ display: resolveNodeLabelOption(display, node) ?? true,
638
+ font,
639
+ padding,
640
+ position: resolveNodeLabelOption(position, node) ?? 'auto'
641
+ };
642
+ }
419
643
  class SankeyController extends DatasetController {
420
644
  parseObjectData(meta, data, start, count) {
421
645
  const sankeyData = getParsedData(data, this.options.parsing);
422
646
  const { xScale, yScale } = meta;
423
647
  const parsed = [];
424
648
  const nodes = buildNodesFromData(sankeyData, this.options);
649
+ const orientation = this.options.orientation ?? 'horizontal';
425
650
  this._nodes = nodes;
426
651
  const { maxX, maxY } = layout(nodes, sankeyData, {
427
- height: this.chart.canvas.height,
652
+ height: orientation === 'vertical' ? this.chart.canvas.width : this.chart.canvas.height,
428
653
  modeX: this.options.modeX,
429
- nodePadding: this.options.nodePadding,
654
+ nodePadding: this.options.nodePadding ?? 10,
430
655
  priority: !!this.options.priority
431
656
  });
432
657
  this._maxX = maxX;
@@ -437,26 +662,17 @@ class SankeyController extends DatasetController {
437
662
  const from = nodes.get(dataPoint.from);
438
663
  const to = nodes.get(dataPoint.to);
439
664
  if (!from || !to) continue;
440
- const fromY = (from.y ?? 0) + getAddY(from.to, dataPoint.to, i);
441
- const toY = (to.y ?? 0) + getAddY(to.from, dataPoint.from, i);
442
- parsed.push({
443
- _custom: {
444
- flow: dataPoint.flow,
445
- from,
446
- height: yScale.parse(dataPoint.flow, i),
447
- to,
448
- x: xScale.parse(to.x, i),
449
- y: yScale.parse(toY, i)
450
- },
451
- x: xScale.parse(from.x, i),
452
- y: yScale.parse(fromY, i)
453
- });
665
+ const fromY = nodeY(from) + getAddY(from.to, dataPoint.to, i);
666
+ const toY = nodeY(to) + getAddY(to.from, dataPoint.from, i);
667
+ parsed.push(parseFlow(from, to, fromY, toY, dataPoint.flow, i, xScale, yScale, orientation));
454
668
  }
455
669
  return parsed.slice(start, start + count);
456
670
  }
457
671
  getMinMax(scale) {
672
+ const vertical = this.options.orientation === 'vertical';
673
+ const columnScale = vertical ? this._cachedMeta.yScale : this._cachedMeta.xScale;
458
674
  return {
459
- max: scale === this._cachedMeta.xScale ? this._maxX : this._maxY,
675
+ max: scale === columnScale ? this._maxX : this._maxY,
460
676
  min: 0
461
677
  };
462
678
  }
@@ -469,79 +685,65 @@ class SankeyController extends DatasetController {
469
685
  if (!xScale || !yScale) return;
470
686
  const firstOpts = this.resolveDataElementOptions(start, mode);
471
687
  const sharedOptions = this.getSharedOptions(firstOpts);
472
- const { borderWidth, nodeWidth = 10 } = this.options;
688
+ const { borderWidth, nodeWidth = 10, orientation = 'horizontal' } = this.options;
689
+ const columnPadding = getColumnPadding(nodeWidth, orientation, this.chart);
473
690
  const borderSpace = borderWidth ? borderWidth / 2 + 0.5 : 0;
474
691
  for(let i = start; i < start + count; i++){
475
692
  const parsed = this.getParsed(i);
476
- const custom = parsed._custom;
477
- const y = yScale.getPixelForValue(parsed.y);
478
693
  this.updateElement(elems[i], i, {
479
- from: custom.from,
480
- height: Math.abs(yScale.getPixelForValue(parsed.y + custom.height) - y),
481
694
  options: this.resolveDataElementOptions(i, mode),
482
695
  progress: mode === 'reset' ? 0 : 1,
483
- to: custom.to,
484
- x: xScale.getPixelForValue(parsed.x) + nodeWidth + borderSpace,
485
- x2: xScale.getPixelForValue(custom.x) - borderSpace,
486
- y,
487
- y2: yScale.getPixelForValue(custom.y)
696
+ ...getFlowElementProperties(parsed, xScale, yScale, this._maxX, nodeWidth, columnPadding, borderSpace, orientation)
488
697
  }, mode);
489
698
  }
490
- this.updateSharedOptions(sharedOptions, mode, firstOpts);
699
+ if (sharedOptions) {
700
+ this.updateSharedOptions(sharedOptions, mode, firstOpts);
701
+ }
491
702
  }
492
703
  _drawLabels() {
493
704
  const ctx = this.chart.ctx;
494
705
  const options = this.options;
495
706
  const nodes = this._nodes || new Map();
496
707
  const size = validateSizeValue(options.size);
497
- const borderWidth = options.borderWidth ?? 1;
498
- const nodeWidth = options.nodeWidth ?? 10;
499
708
  const labels = options.labels;
709
+ const { borderWidth = 1, nodeWidth = 10, orientation = 'horizontal' } = options;
710
+ const columnPadding = getColumnPadding(nodeWidth, orientation, this.chart);
711
+ const defaultFont = options.font ?? this.chart.options.font ?? Chart.defaults.font;
500
712
  const { xScale, yScale } = this._cachedMeta;
501
713
  if (!xScale || !yScale) return;
502
714
  ctx.save();
503
715
  const chartArea = this.chart.chartArea;
504
716
  for (const node of nodes.values()){
505
- const x = xScale.getPixelForValue(node.x);
506
- const y = yScale.getPixelForValue(node.y);
507
- const max = Math[size](node.in || node.out, node.out || node.in);
508
- const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
717
+ const max = getNodeSize(node, size);
718
+ const { height, width, x, y } = getNodeRect(node, max, xScale, yScale, this._maxX, nodeWidth, columnPadding, orientation);
509
719
  const label = labels?.[node.key] ?? node.key;
510
- let textX = x;
511
- ctx.fillStyle = options.color ?? 'black';
512
- ctx.textBaseline = 'middle';
513
- if (x < chartArea.width / 2) {
514
- ctx.textAlign = 'left';
515
- textX += nodeWidth + borderWidth + 4;
516
- } else {
517
- ctx.textAlign = 'right';
518
- textX -= borderWidth + 4;
720
+ const labelStyle = resolveNodeLabelStyle(options, node);
721
+ if (labelStyle.display) {
722
+ const font = toFont(labelStyle.font ?? defaultFont);
723
+ drawLabel(ctx, label, {
724
+ autoPosition: getAutoLabelPosition(x, y, chartArea, orientation),
725
+ backgroundColor: labelStyle.backgroundColor,
726
+ borderRadius: labelStyle.borderRadius,
727
+ borderWidth,
728
+ color: labelStyle.color,
729
+ font,
730
+ height,
731
+ lineOffset: valueOrDefault(options.padding, font.lineHeight / 2),
732
+ padding: labelStyle.padding,
733
+ position: labelStyle.position,
734
+ width,
735
+ x,
736
+ y
737
+ });
519
738
  }
520
- this._drawLabel(label, y, height, ctx, textX);
521
739
  }
522
740
  ctx.restore();
523
741
  }
524
- _drawLabel(label, y, height, ctx, textX) {
525
- const font = toFont(this.options.font, this.chart.options.font);
526
- const lines = toTextLines(label);
527
- const lineCount = lines.length;
528
- const middle = y + height / 2;
529
- const textHeight = font.lineHeight;
530
- const padding = valueOrDefault(this.options.padding, textHeight / 2);
531
- ctx.font = font.string;
532
- if (lineCount > 1) {
533
- const top = middle - textHeight * lineCount / 2 + padding;
534
- for(let i = 0; i < lineCount; i++){
535
- ctx.fillText(lines[i], textX, top + i * textHeight);
536
- }
537
- } else {
538
- ctx.fillText(label, textX, middle);
539
- }
540
- }
541
742
  _drawNodes() {
542
743
  const ctx = this.chart.ctx;
543
744
  const nodes = this._nodes || new Map();
544
- const { borderColor, borderWidth = 0, nodeWidth = 10, size } = this.options;
745
+ const { borderColor, borderWidth = 0, nodeWidth = 10, orientation = 'horizontal', size } = this.options;
746
+ const columnPadding = getColumnPadding(nodeWidth, orientation, this.chart);
545
747
  const sizeMethod = validateSizeValue(size);
546
748
  const { xScale, yScale } = this._cachedMeta;
547
749
  ctx.save();
@@ -551,14 +753,13 @@ class SankeyController extends DatasetController {
551
753
  }
552
754
  for (const node of nodes.values()){
553
755
  ctx.fillStyle = node.color ?? 'black';
554
- const x = xScale.getPixelForValue(node.x);
555
- const y = yScale.getPixelForValue(node.y);
756
+ if (!xScale || !yScale) return;
556
757
  const max = Math[sizeMethod](node.in || node.out, node.out || node.in);
557
- const height = Math.abs(yScale.getPixelForValue(node.y + max) - y);
758
+ const { height, width, x, y } = getNodeRect(node, max, xScale, yScale, this._maxX, nodeWidth, columnPadding, orientation);
558
759
  if (borderWidth) {
559
- ctx.strokeRect(x, y, nodeWidth, height);
760
+ ctx.strokeRect(x, y, width, height);
560
761
  }
561
- ctx.fillRect(x, y, nodeWidth, height);
762
+ ctx.fillRect(x, y, width, height);
562
763
  }
563
764
  ctx.restore();
564
765
  }
@@ -568,13 +769,19 @@ class SankeyController extends DatasetController {
568
769
  const active = [];
569
770
  for(let i = 0, ilen = data.length; i < ilen; ++i){
570
771
  const flow = data[i];
571
- flow.from.color = flow.options.colorFrom;
772
+ if (!flow.from || !flow.to) {
773
+ continue;
774
+ }
775
+ flow.from.color = flow.options.colorFrom;
572
776
  flow.to.color = flow.options.colorTo;
573
777
  if (flow.active) {
574
778
  active.push(flow);
575
779
  }
576
780
  }
577
781
  for (const flow of active){
782
+ if (!flow.from || !flow.to) {
783
+ continue;
784
+ }
578
785
  flow.from.color = flow.options.colorFrom;
579
786
  flow.to.color = flow.options.colorTo;
580
787
  }
@@ -584,8 +791,19 @@ class SankeyController extends DatasetController {
584
791
  }
585
792
  this._drawLabels();
586
793
  }
794
+ constructor(...args){
795
+ super(...args), this._nodes = new Map(), this._maxX = 0, this._maxY = 0;
796
+ }
587
797
  }
588
798
  SankeyController.id = 'sankey';
799
+ SankeyController.descriptors = {
800
+ _indexable: false,
801
+ _scriptable: true,
802
+ nodeLabels: {
803
+ _indexable: false,
804
+ _scriptable: false
805
+ }
806
+ };
589
807
  SankeyController.defaults = {
590
808
  animations: {
591
809
  colors: {
@@ -601,13 +819,14 @@ SankeyController.defaults = {
601
819
  'y',
602
820
  'x2',
603
821
  'y2',
604
- 'height'
822
+ 'height',
823
+ 'width'
605
824
  ],
606
825
  type: 'number'
607
826
  },
608
827
  progress: {
609
- delay: (ctx)=>ctx.type === 'data' ? ctx.parsed.x * 500 + ctx.dataIndex * 20 : undefined,
610
- duration: (ctx)=>ctx.type === 'data' ? (ctx.parsed._custom.x - ctx.parsed.x) * 200 : undefined,
828
+ delay: (ctx)=>ctx.type === 'data' ? ctx.parsed[ctx.dataset.orientation === 'vertical' ? 'y' : 'x'] * 500 + ctx.dataIndex * 20 : undefined,
829
+ duration: (ctx)=>ctx.type === 'data' ? (ctx.parsed._custom[ctx.dataset.orientation === 'vertical' ? 'y' : 'x'] - ctx.parsed[ctx.dataset.orientation === 'vertical' ? 'y' : 'x']) * 200 : undefined,
611
830
  easing: 'linear'
612
831
  }
613
832
  },
@@ -618,6 +837,7 @@ SankeyController.defaults = {
618
837
  modeX: 'edge',
619
838
  nodePadding: 10,
620
839
  nodeWidth: 10,
840
+ orientation: 'horizontal',
621
841
  transitions: {
622
842
  hide: {
623
843
  animations: {
@@ -674,7 +894,7 @@ SankeyController.overrides = {
674
894
  callbacks: {
675
895
  label (context) {
676
896
  const parsedCustom = context.parsed._custom;
677
- return parsedCustom.from.key + ' -> ' + parsedCustom.to.key + ': ' + parsedCustom.flow;
897
+ return `${parsedCustom.from.key} -> ${parsedCustom.to.key}: ${parsedCustom.flow}`;
678
898
  },
679
899
  title () {
680
900
  return '';
@@ -701,7 +921,25 @@ SankeyController.overrides = {
701
921
  }
702
922
  };
703
923
 
704
- const controlPoints = (x, y, x2, y2)=>x < x2 ? {
924
+ const controlPoints = (x, y, x2, y2, orientation)=>orientation === 'vertical' ? y < y2 ? {
925
+ cp1: {
926
+ x,
927
+ y: y + (y2 - y) / 3 * 2
928
+ },
929
+ cp2: {
930
+ x: x2,
931
+ y: y + (y2 - y) / 3
932
+ }
933
+ } : {
934
+ cp1: {
935
+ x: 0,
936
+ y: y - (y - y2) / 3
937
+ },
938
+ cp2: {
939
+ x: 0,
940
+ y: y2 + (y - y2) / 3
941
+ }
942
+ } : x < x2 ? {
705
943
  cp1: {
706
944
  x: x + (x2 - x) / 3 * 2,
707
945
  y
@@ -726,14 +964,17 @@ const pointInLine = (p1, p2, t)=>({
726
964
  });
727
965
  const applyAlpha = (original, alpha)=>color(original).alpha(alpha).rgbString();
728
966
  const getColorOption = (option, alpha)=>typeof option === 'string' ? applyAlpha(option, alpha) : option;
729
- function setStyle(ctx, { x, x2, options }) {
967
+ const getHoverColorOption = (option)=>typeof option === 'string' ? getHoverColor(option) : option;
968
+ function setStyle(ctx, { x, x2, y, y2, options }) {
730
969
  let fill = 'black';
731
- if (options.colorMode === 'from') {
970
+ if (options.flowColor !== null) {
971
+ fill = options.flowColor;
972
+ } else if (options.colorMode === 'from') {
732
973
  fill = getColorOption(options.colorFrom, options.alpha);
733
974
  } else if (options.colorMode === 'to') {
734
975
  fill = getColorOption(options.colorTo, options.alpha);
735
976
  } else if (typeof options.colorFrom === 'string' && typeof options.colorTo === 'string') {
736
- fill = ctx.createLinearGradient(x, 0, x2, 0);
977
+ fill = options.orientation === 'vertical' ? ctx.createLinearGradient(0, y, 0, y2) : ctx.createLinearGradient(x, 0, x2, 0);
737
978
  fill.addColorStop(0, applyAlpha(options.colorFrom, options.alpha));
738
979
  fill.addColorStop(1, applyAlpha(options.colorTo, options.alpha));
739
980
  }
@@ -741,44 +982,104 @@ function setStyle(ctx, { x, x2, options }) {
741
982
  ctx.strokeStyle = fill;
742
983
  ctx.lineWidth = 0.5;
743
984
  }
985
+ function clipFlow(ctx, { height, width, x, x2, y, y2 }, progress, orientation) {
986
+ ctx.beginPath();
987
+ if (orientation === 'vertical') {
988
+ ctx.rect(Math.min(x, x2), y, Math.abs(x2 - x) + width + 1, (y2 - y) * progress + 1);
989
+ } else {
990
+ ctx.rect(x, Math.min(y, y2), (x2 - x) * progress + 1, Math.abs(y2 - y) + height + 1);
991
+ }
992
+ ctx.clip();
993
+ }
994
+ function drawFlowPath(ctx, { height, width, x, x2, y, y2 }, { cp1, cp2 }, orientation) {
995
+ ctx.beginPath();
996
+ ctx.moveTo(x, y);
997
+ ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, x2, y2);
998
+ if (orientation === 'vertical') {
999
+ ctx.lineTo(x2 + width, y2);
1000
+ ctx.bezierCurveTo(cp2.x + width, cp2.y, cp1.x + width, cp1.y, x + width, y);
1001
+ } else {
1002
+ ctx.lineTo(x2, y2 + height);
1003
+ ctx.bezierCurveTo(cp2.x, cp2.y + height, cp1.x, cp1.y + height, x, y + height);
1004
+ }
1005
+ ctx.lineTo(x, y);
1006
+ ctx.stroke();
1007
+ ctx.closePath();
1008
+ ctx.fill();
1009
+ }
1010
+ function getLabelRect({ height, width, x, x2, y, y2 }, orientation) {
1011
+ if (orientation === 'vertical') {
1012
+ return {
1013
+ height: y2 - y,
1014
+ width: Math.abs(x2 - x) + width,
1015
+ x: Math.min(x, x2),
1016
+ y
1017
+ };
1018
+ }
1019
+ return {
1020
+ height: Math.abs(y2 - y) + height,
1021
+ width: x2 - x,
1022
+ x,
1023
+ y: Math.min(y, y2)
1024
+ };
1025
+ }
744
1026
  class Flow extends Element {
745
1027
  draw(ctx) {
746
- const { x, x2, y, y2, height, progress } = this;
747
- const { cp1, cp2 } = controlPoints(x, y, x2, y2);
1028
+ const { x, x2, y, y2, height, progress, width } = this;
1029
+ const orientation = this.options.orientation;
1030
+ const controls = controlPoints(x, y, x2, y2, orientation);
1031
+ const geometry = {
1032
+ height,
1033
+ width,
1034
+ x,
1035
+ x2,
1036
+ y,
1037
+ y2
1038
+ };
748
1039
  if (progress === 0) {
749
1040
  return;
750
1041
  }
751
1042
  ctx.save();
752
1043
  if (progress < 1) {
753
- ctx.beginPath();
754
- ctx.rect(x, Math.min(y, y2), (x2 - x) * progress + 1, Math.abs(y2 - y) + height + 1);
755
- ctx.clip();
1044
+ clipFlow(ctx, geometry, progress, orientation);
756
1045
  }
757
1046
  setStyle(ctx, this);
758
- ctx.beginPath();
759
- ctx.moveTo(x, y);
760
- ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, x2, y2);
761
- ctx.lineTo(x2, y2 + height);
762
- ctx.bezierCurveTo(cp2.x, cp2.y + height, cp1.x, cp1.y + height, x, y + height);
763
- ctx.lineTo(x, y);
764
- ctx.stroke();
765
- ctx.closePath();
766
- ctx.fill();
1047
+ drawFlowPath(ctx, geometry, controls, orientation);
1048
+ const labels = this.options.flowLabels;
1049
+ if (labels.display) {
1050
+ const font = toFont(labels.font ?? Chart.defaults.font);
1051
+ const labelRect = getLabelRect(geometry, orientation);
1052
+ drawLabel(ctx, `${this.flow}`, {
1053
+ autoPosition: 'center',
1054
+ backgroundColor: labels.backgroundColor,
1055
+ borderRadius: labels.borderRadius,
1056
+ borderWidth: 0,
1057
+ color: labels.color,
1058
+ font,
1059
+ height: labelRect.height,
1060
+ lineOffset: font.lineHeight / 2,
1061
+ padding: labels.padding,
1062
+ position: labels.position,
1063
+ width: labelRect.width,
1064
+ x: labelRect.x,
1065
+ y: labelRect.y
1066
+ });
1067
+ }
767
1068
  ctx.restore();
768
1069
  }
769
1070
  inRange(mouseX, mouseY, useFinalPosition) {
770
- const { x, y, x2, y2, height } = this.getProps([
1071
+ const { x, y, x2, y2, height, width } = this.getProps([
771
1072
  'x',
772
1073
  'y',
773
1074
  'x2',
774
1075
  'y2',
775
- 'height'
1076
+ 'height',
1077
+ 'width'
776
1078
  ], useFinalPosition);
777
- if (mouseX < x || mouseX > x2) {
778
- return false;
779
- }
780
- const { cp1, cp2 } = controlPoints(x, y, x2, y2);
781
- const t = (mouseX - x) / (x2 - x);
1079
+ const vertical = this.options.orientation === 'vertical';
1080
+ if (vertical ? mouseY < y || mouseY > y2 : mouseX < x || mouseX > x2) return false;
1081
+ const { cp1, cp2 } = controlPoints(x, y, x2, y2, this.options.orientation);
1082
+ const t = vertical ? (mouseY - y) / (y2 - y) : (mouseX - x) / (x2 - x);
782
1083
  const p1 = {
783
1084
  x,
784
1085
  y
@@ -792,15 +1093,18 @@ class Flow extends Element {
792
1093
  const c = pointInLine(cp2, p2, t);
793
1094
  const d = pointInLine(a, b, t);
794
1095
  const e = pointInLine(b, c, t);
795
- const topY = pointInLine(d, e, t).y;
796
- return mouseY >= topY && mouseY <= topY + height;
1096
+ const edge = pointInLine(d, e, t);
1097
+ return vertical ? mouseX >= edge.x && mouseX <= edge.x + width : mouseY >= edge.y && mouseY <= edge.y + height;
797
1098
  }
798
1099
  inXRange(mouseX, useFinalPosition) {
799
- const { x, x2 } = this.getProps([
1100
+ const { x, x2, width } = this.getProps([
800
1101
  'x',
801
- 'x2'
1102
+ 'x2',
1103
+ 'width'
802
1104
  ], useFinalPosition);
803
- return mouseX >= x && mouseX <= x2;
1105
+ const min = Math.min(x, x2);
1106
+ const max = Math.max(x, x2) + (this.options.orientation === 'vertical' ? width : 0);
1107
+ return mouseX >= min && mouseX <= max;
804
1108
  }
805
1109
  inYRange(mouseY, useFinalPosition) {
806
1110
  const { y, y2, height } = this.getProps([
@@ -809,30 +1113,34 @@ class Flow extends Element {
809
1113
  'height'
810
1114
  ], useFinalPosition);
811
1115
  const minY = Math.min(y, y2);
812
- const maxY = Math.max(y, y2) + height;
1116
+ const maxY = Math.max(y, y2) + (this.options.orientation === 'vertical' ? 0 : height);
813
1117
  return mouseY >= minY && mouseY <= maxY;
814
1118
  }
815
1119
  getCenterPoint(useFinalPosition) {
816
- const { x, y, x2, y2, height } = this.getProps([
1120
+ const { x, y, x2, y2, height, width } = this.getProps([
817
1121
  'x',
818
1122
  'y',
819
1123
  'x2',
820
1124
  'y2',
821
- 'height'
1125
+ 'height',
1126
+ 'width'
822
1127
  ], useFinalPosition);
1128
+ const vertical = this.options.orientation === 'vertical';
823
1129
  return {
824
- x: (x + x2) / 2,
825
- y: (y + y2 + height) / 2
1130
+ x: (x + x2 + (vertical ? width : 0)) / 2,
1131
+ y: (y + y2 + (vertical ? 0 : height)) / 2
826
1132
  };
827
1133
  }
828
- tooltipPosition(useFinalPosition) {
1134
+ tooltipPosition(useFinalPosition = false) {
829
1135
  return this.getCenterPoint(useFinalPosition);
830
1136
  }
831
1137
  getRange(axis) {
832
- return axis === 'x' ? this.width / 2 : this.height / 2;
1138
+ const vertical = this.options.orientation === 'vertical';
1139
+ if (axis === 'x') return vertical ? this.width / 2 : 0;
1140
+ return vertical ? 0 : this.height / 2;
833
1141
  }
834
1142
  constructor(cfg){
835
- super();
1143
+ super(), this.flow = 0, this.x2 = 0, this.y2 = 0, this.width = 0, this.height = 0, this.progress = 1;
836
1144
  if (cfg) {
837
1145
  Object.assign(this, cfg);
838
1146
  }
@@ -844,11 +1152,23 @@ Flow.defaults = {
844
1152
  colorFrom: 'red',
845
1153
  colorMode: 'gradient',
846
1154
  colorTo: 'green',
847
- hoverColorFrom: (_ctx, options)=>getHoverColor(options.colorFrom),
848
- hoverColorTo: (_ctx, options)=>getHoverColor(options.colorTo)
1155
+ flowColor: null,
1156
+ flowLabels: {
1157
+ borderRadius: 0,
1158
+ color: 'black',
1159
+ display: false,
1160
+ padding: 4,
1161
+ position: 'center'
1162
+ },
1163
+ hoverColorFrom: (_ctx, options)=>getHoverColorOption(options.colorFrom),
1164
+ hoverColorTo: (_ctx, options)=>getHoverColorOption(options.colorTo),
1165
+ orientation: 'horizontal'
849
1166
  };
850
1167
  Flow.descriptors = {
851
- _scriptable: true
1168
+ _scriptable: true,
1169
+ flowLabels: {
1170
+ _scriptable: true
1171
+ }
852
1172
  };
853
1173
 
854
1174
  export { Flow, SankeyController };