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