@progress/kendo-charts 2.4.0-dev.202405201104 → 2.4.0-dev.202405211537

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.
@@ -5,7 +5,7 @@ import { Node, resolveNodeOptions } from './node';
5
5
  import { Link, resolveLinkOptions } from './link';
6
6
  import { Label, resolveLabelOptions } from './label';
7
7
  import { Title } from './title';
8
- import { BOTTOM, LEFT, RIGHT, TOP } from '../common/constants';
8
+ import { BLACK, BOTTOM, LEFT, RIGHT, TOP } from '../common/constants';
9
9
  import { Box, rectToBox } from '../core';
10
10
  import { Legend } from './legend';
11
11
  import { defined } from '../drawing-utils';
@@ -25,6 +25,7 @@ export class Sankey extends Observable {
25
25
  if (options && options.data) {
26
26
  this._redraw();
27
27
  this._initResizeObserver();
28
+ this._initNavigation(element);
28
29
  }
29
30
  }
30
31
 
@@ -32,13 +33,24 @@ export class Sankey extends Observable {
32
33
  this.unbind();
33
34
  this._destroySurface();
34
35
  this._destroyResizeObserver();
36
+
37
+ if (this.element) {
38
+ this.element.removeEventListener('keydown', this._keydownHandler);
39
+ this.element.removeEventListener('focus', this._focusHandler);
40
+ this.element.removeEventListener('mousedown', this._onDownHandler);
41
+ this.element.removeEventListener('touchstart', this._onDownHandler);
42
+ this.element.removeEventListener('pointerdown', this._onDownHandler);
43
+ }
44
+
45
+ this._focusState = null;
46
+
47
+ this.element = null;
35
48
  }
36
49
 
37
50
  _initElement(element) {
38
51
  this.element = element;
39
52
  addClass(element, [ "k-chart", "k-sankey" ]);
40
53
  element.setAttribute('role', 'graphics-document');
41
- element.tabIndex = element.getAttribute("tabindex") || 0;
42
54
 
43
55
  const { title } = this.options;
44
56
 
@@ -59,6 +71,31 @@ export class Sankey extends Observable {
59
71
  }
60
72
  }
61
73
 
74
+ _initNavigation(element) {
75
+ element.tabIndex = element.getAttribute("tabindex") || 0;
76
+
77
+ if (this.options.disableKeyboardNavigation) {
78
+ return;
79
+ }
80
+
81
+ this._keydownHandler = this._keydown.bind(this);
82
+ this._focusHandler = this._focus.bind(this);
83
+ this._blurHandler = this._blur.bind(this);
84
+ this._onDownHandler = this._onDown.bind(this);
85
+
86
+ element.addEventListener('keydown', this._keydownHandler);
87
+ element.addEventListener('focus', this._focusHandler);
88
+ element.addEventListener('blur', this._blurHandler);
89
+ element.addEventListener('mousedown', this._onDownHandler);
90
+ element.addEventListener('touchstart', this._onDownHandler);
91
+ element.addEventListener('pointerdown', this._onDownHandler);
92
+
93
+ this._focusState = {
94
+ node: this.columns[0][0],
95
+ link: null
96
+ };
97
+ }
98
+
62
99
  _initResizeObserver() {
63
100
  const observer = new ResizeObserver((entries) => {
64
101
  entries.forEach(entry => {
@@ -220,16 +257,219 @@ export class Sankey extends Observable {
220
257
 
221
258
  _click(ev) {
222
259
  const element = ev.element;
260
+ const dataItem = element.dataItem;
223
261
  const isLink = element.type === LINK;
224
262
  const isNode = element.type === NODE;
263
+ const focusState = this._focusState || {};
225
264
 
226
265
  if (isNode) {
266
+ const focusedNodeClicked = !focusState.link && this.sameNode(focusState.node, dataItem);
267
+
268
+ if (!focusedNodeClicked) {
269
+ this._focusState = { node: dataItem, link: null };
270
+ this._focusNode({ highlight: false });
271
+ }
272
+
227
273
  this.trigger('nodeClick', ev);
228
274
  } else if (isLink) {
275
+ const link = {
276
+ sourceId: dataItem.source.id,
277
+ targetId: dataItem.target.id,
278
+ value: dataItem.value
279
+ };
280
+ const focusedLinkClicked = this.sameLink(focusState.link, link);
281
+
282
+ if (!focusedLinkClicked) {
283
+ this._focusState = { node: dataItem.source, link: link };
284
+ this._focusLink({ highlight: false });
285
+ }
286
+
229
287
  this.trigger('linkClick', ev);
230
288
  }
231
289
  }
232
290
 
291
+ sameNode(node1, node2) {
292
+ return node1 && node2 && node1.id === node2.id;
293
+ }
294
+
295
+ sameLink(link1, link2) {
296
+ return link1 && link2 && link1.sourceId === link2.sourceId && link1.targetId === link2.targetId;
297
+ }
298
+
299
+ _focusNode(options) {
300
+ this._cleanFocusHighlight();
301
+
302
+ const nodeData = this._focusState.node;
303
+ const node = this.models.map.get(nodeData.id);
304
+ node.focus(options);
305
+ }
306
+
307
+ _focusLink(options) {
308
+ this._cleanFocusHighlight();
309
+
310
+ const linkData = this._focusState.link;
311
+ const link = this.models.map.get(`${linkData.sourceId}-${linkData.targetId}`);
312
+ link.focus(options);
313
+ }
314
+
315
+ _focusNextNode(direction = 1) {
316
+ const current = this._focusState.node;
317
+
318
+ const columnIndex = this.columns.findIndex(column => column.find(n => n.id === current.id));
319
+ const columnNodes = this.columns[columnIndex];
320
+ const nodeIndex = columnNodes.findIndex(n => n.id === current.id);
321
+
322
+ const nextNode = columnNodes[nodeIndex + direction];
323
+ if (nextNode) {
324
+ this._focusState.node = nextNode;
325
+ this._focusNode();
326
+ }
327
+ }
328
+
329
+ _focusNextLink(direction = 1) {
330
+ const node = this._focusState.node;
331
+ const link = this._focusState.link;
332
+
333
+ const sourceLinkIndex = node.sourceLinks.findIndex(l => l.sourceId === link.sourceId && l.targetId === link.targetId);
334
+ const targetLinkIndex = node.targetLinks.findIndex(l => l.sourceId === link.sourceId && l.targetId === link.targetId);
335
+
336
+ if (sourceLinkIndex !== -1) {
337
+ const nextLink = node.sourceLinks[sourceLinkIndex + direction];
338
+
339
+ if (nextLink) {
340
+ this._focusState.link = nextLink;
341
+ this._focusLink();
342
+ }
343
+ } else if (targetLinkIndex !== -1) {
344
+ const nextLink = node.targetLinks[targetLinkIndex + direction];
345
+
346
+ if (nextLink) {
347
+ this._focusState.link = nextLink;
348
+ this._focusLink();
349
+ }
350
+ }
351
+ }
352
+
353
+ _focusSourceNode() {
354
+ const linkData = this._focusState.link;
355
+ const sourceNode = this.models.map.get(linkData.sourceId);
356
+ this._focusState = { node: sourceNode.options.node, link: null };
357
+ this._focusNode();
358
+ }
359
+
360
+ _focusTargetNode() {
361
+ const linkData = this._focusState.link;
362
+ const targetNode = this.models.map.get(linkData.targetId);
363
+ this._focusState = { node: targetNode.options.node, link: null };
364
+ this._focusNode();
365
+ }
366
+
367
+ _focusSourceLink() {
368
+ const nodeData = this._focusState.node;
369
+ const sourceLinks = nodeData.sourceLinks;
370
+ const linkData = sourceLinks[0];
371
+ if (linkData) {
372
+ this._focusState.link = linkData;
373
+ this._focusLink();
374
+ }
375
+ }
376
+
377
+ _focusTargetLink() {
378
+ const nodeData = this._focusState.node;
379
+ const targetLinks = nodeData.targetLinks;
380
+ const linkData = targetLinks[0];
381
+ if (linkData) {
382
+ this._focusState.link = linkData;
383
+ this._focusLink();
384
+ }
385
+ }
386
+
387
+ _focus() {
388
+ if (!this._skipFocusHighlight) {
389
+ if (this._focusState.link) {
390
+ this._focusLink();
391
+ } else {
392
+ this._focusNode();
393
+ }
394
+ }
395
+
396
+ this._skipFocusHighlight = false;
397
+ }
398
+
399
+ _blur() {
400
+ this._cleanFocusHighlight();
401
+ }
402
+
403
+ _onDown() {
404
+ if (!this._hasFocus()) {
405
+ this._skipFocusHighlight = true;
406
+ }
407
+ }
408
+
409
+ _hasFocus() {
410
+ return this.element.ownerDocument.activeElement === this.element;
411
+ }
412
+
413
+ _cleanFocusHighlight() {
414
+ this.models.nodes.forEach(node => node.blur());
415
+ this.models.links.forEach(link => link.blur());
416
+ }
417
+
418
+ _keydown(ev) {
419
+ const handler = this['on' + ev.key];
420
+
421
+ if (handler) {
422
+ handler.call(this, ev);
423
+ }
424
+ }
425
+
426
+ onEscape(ev) {
427
+ ev.preventDefault();
428
+
429
+ this._focusState = { node: this.columns[0][0], link: null };
430
+ this._focusNode();
431
+ }
432
+
433
+ onArrowDown(ev) {
434
+ ev.preventDefault();
435
+
436
+ if (this._focusState.link) {
437
+ this._focusNextLink(1);
438
+ } else {
439
+ this._focusNextNode(1);
440
+ }
441
+ }
442
+
443
+ onArrowUp(ev) {
444
+ ev.preventDefault();
445
+
446
+ if (this._focusState.link) {
447
+ this._focusNextLink(-1);
448
+ } else {
449
+ this._focusNextNode(-1);
450
+ }
451
+ }
452
+
453
+ onArrowLeft(ev) {
454
+ ev.preventDefault();
455
+
456
+ if (this._focusState.link) {
457
+ this._focusSourceNode();
458
+ } else {
459
+ this._focusTargetLink();
460
+ }
461
+ }
462
+
463
+ onArrowRight(ev) {
464
+ ev.preventDefault();
465
+
466
+ if (this._focusState.link) {
467
+ this._focusTargetNode();
468
+ } else {
469
+ this._focusSourceLink();
470
+ }
471
+ }
472
+
233
473
  highlightLinks(node, highlight) {
234
474
  if (node) {
235
475
  this.setLinksInactivityOpacity(highlight.inactiveOpacity);
@@ -317,10 +557,13 @@ export class Sankey extends Observable {
317
557
  }
318
558
 
319
559
  calculateSankey(calcOptions, sankeyOptions) {
320
- const { title, legend, data, nodes, labels, nodeColors, disableAutoLayout } = sankeyOptions;
560
+ const { title, legend, data, nodes, labels, nodeColors, disableAutoLayout, disableKeyboardNavigation } = sankeyOptions;
321
561
  const autoLayout = !disableAutoLayout;
562
+ const padding = disableKeyboardNavigation ? 0 : highlightOptions.width / 2;
322
563
 
323
564
  const sankeyBox = new Box(0, 0, calcOptions.width, calcOptions.height);
565
+ sankeyBox.unpad(padding);
566
+
324
567
  const titleBox = this.titleBox(title, sankeyBox);
325
568
 
326
569
  let legendArea = sankeyBox.clone();
@@ -360,7 +603,7 @@ export class Sankey extends Observable {
360
603
  const { nodes: calculatedNodes, circularLinks } = calculateSankey(Object.assign({}, calcOptions, {offsetX: 0, offsetY: 0, width: sankeyBox.width(), height: sankeyBox.height()}));
361
604
  if (circularLinks) {
362
605
  console.warn('Circular links detected. Kendo Sankey diagram does not support circular links.');
363
- return { sankey: { nodes: [], links: [], circularLinks }, legendBox, titleBox };
606
+ return { sankey: { nodes: [], links: [], columns: [[]], circularLinks }, legendBox, titleBox };
364
607
  }
365
608
 
366
609
  const box = new Box();
@@ -444,12 +687,18 @@ export class Sankey extends Observable {
444
687
  const sankeyOptions = options || this.options;
445
688
  const sankeyContext = context || this;
446
689
 
447
- const { data, labels: labelOptions, nodes: nodesOptions, links: linkOptions, nodeColors, title, legend } = sankeyOptions;
690
+ const { data, labels: labelOptions, nodes: nodesOptions, links: linkOptions, nodeColors, title, legend, disableKeyboardNavigation } = sankeyOptions;
448
691
  const { width, height } = sankeyContext.size;
449
692
 
450
693
  const calcOptions = Object.assign({}, data, {width, height, nodesOptions, title, legend});
451
694
  const { sankey, titleBox, legendBox } = this.calculateSankey(calcOptions, sankeyOptions);
452
- const { nodes, links } = sankey;
695
+ const { nodes, links, columns } = sankey;
696
+
697
+ sankeyContext.columns = columns.map(column => {
698
+ const newColumn = column.slice();
699
+ newColumn.sort((a, b) => a.y0 - b.y0);
700
+ return newColumn;
701
+ });
453
702
 
454
703
  const visual = new drawing.Group({
455
704
  clip: drawing.Path.fromRect(new geometry.Rect([0, 0], [width, height]))
@@ -468,8 +717,19 @@ export class Sankey extends Observable {
468
717
  const visualNodes = new Map();
469
718
  sankeyContext.nodesVisuals = visualNodes;
470
719
 
720
+ const models = {
721
+ nodes: [],
722
+ links: [],
723
+ map: new Map()
724
+ };
725
+ sankeyContext.models = models;
726
+
727
+ const focusHighlights = [];
728
+
471
729
  nodes.forEach((node, i) => {
472
730
  const nodeOps = resolveNodeOptions(node, nodesOptions, nodeColors, i);
731
+ nodeOps.root = () => sankeyContext.element;
732
+ nodeOps.navigatable = disableKeyboardNavigation !== true;
473
733
 
474
734
  const nodeInstance = new Node(nodeOps);
475
735
  const nodeVisual = nodeInstance.exportVisual();
@@ -486,7 +746,16 @@ export class Sankey extends Observable {
486
746
  targetLinks: node.targetLinks.map(link => ({ sourceId: link.sourceId, targetId: link.targetId, value: link.value }))});
487
747
  visualNodes.set(node.id, nodeVisual);
488
748
 
749
+ models.nodes.push(nodeInstance);
750
+ models.map.set(node.id, nodeInstance);
751
+
489
752
  visual.append(nodeVisual);
753
+
754
+ nodeInstance.createFocusHighlight();
755
+
756
+ if (nodeInstance._highlight) {
757
+ focusHighlights.push(nodeInstance._highlight);
758
+ }
490
759
  });
491
760
 
492
761
  const sortedLinks = links.slice().sort((a, b) => b.value - a.value);
@@ -499,6 +768,8 @@ export class Sankey extends Observable {
499
768
  const sourceNode = visualNodes.get(source.id);
500
769
  const targetNode = visualNodes.get(target.id);
501
770
  const linkOps = resolveLinkOptions(link, linkOptions, sourceNode, targetNode);
771
+ linkOps.root = () => sankeyContext.element;
772
+ linkOps.navigatable = disableKeyboardNavigation !== true;
502
773
  const linkInstance = new Link(linkOps);
503
774
  const linkVisual = linkInstance.exportVisual();
504
775
 
@@ -514,6 +785,15 @@ export class Sankey extends Observable {
514
785
  sourceNode.links.push(linkVisual);
515
786
  targetNode.links.push(linkVisual);
516
787
 
788
+ models.links.push(linkInstance);
789
+ models.map.set(`${source.id}-${target.id}`, linkInstance);
790
+
791
+ linkInstance.createFocusHighlight();
792
+
793
+ if (linkInstance._highlight) {
794
+ focusHighlights.push(linkInstance._highlight);
795
+ }
796
+
517
797
  visual.append(linkVisual);
518
798
  });
519
799
 
@@ -534,6 +814,12 @@ export class Sankey extends Observable {
534
814
  visual.append(legendVisual);
535
815
  }
536
816
 
817
+ if (focusHighlights.length !== 0) {
818
+ const focusHighlight = new drawing.Group();
819
+ focusHighlight.append(...focusHighlights);
820
+ visual.append(focusHighlight);
821
+ }
822
+
537
823
  return visual;
538
824
  }
539
825
 
@@ -556,6 +842,12 @@ export class Sankey extends Observable {
556
842
  }
557
843
  }
558
844
 
845
+ const highlightOptions = {
846
+ opacity: 1,
847
+ width: 2,
848
+ color: BLACK
849
+ };
850
+
559
851
  setDefaultOptions(Sankey, {
560
852
  title: {
561
853
  position: TOP, // 'top', 'bottom'
@@ -583,7 +875,11 @@ setDefaultOptions(Sankey, {
583
875
  padding: 16,
584
876
  opacity: 1,
585
877
  align: 'stretch', // 'left', 'right', 'stretch'
586
- offset: { left: 0, top: 0 }
878
+ offset: { left: 0, top: 0 },
879
+ focusHighlight: Object.assign({}, highlightOptions),
880
+ labels: {
881
+ ariaTemplate: ({ node }) => node.label.text
882
+ }
587
883
  },
588
884
  links: {
589
885
  colorType: 'static', // 'source', 'target', 'static'
@@ -591,6 +887,10 @@ setDefaultOptions(Sankey, {
591
887
  highlight: {
592
888
  opacity: 0.8,
593
889
  inactiveOpacity: 0.2
890
+ },
891
+ focusHighlight: Object.assign({}, highlightOptions),
892
+ labels: {
893
+ ariaTemplate: ({ link }) => `${link.source.label.text} to ${link.target.label.text}`
594
894
  }
595
895
  },
596
896
  tooltip: {