UNPKG

@progress/kendo-charts

Version:

Kendo UI platform-independent Charts library

955 lines (782 loc) 30.8 kB
import { geometry, drawing } from '@progress/kendo-drawing'; import { deepExtend, addClass, Observable, setDefaultOptions } from '../common'; import { calculateSankey, crossesValue } from './calculation'; import { Node, resolveNodeOptions } from './node'; import { Link, resolveLinkOptions } from './link'; import { Label, resolveLabelOptions } from './label'; import { Title } from './title'; import { BLACK, BOTTOM, LEFT, RIGHT, TOP } from '../common/constants'; import { Box, rectToBox } from '../core'; import { Legend } from './legend'; import { defined } from '../drawing-utils'; const LINK = 'link'; const NODE = 'node'; const toRtl = sankey => { const { nodes, links } = sankey; const startX = Math.min(...nodes.map(node => node.x0)); const endX = Math.max(...nodes.map(node => node.x1)); const width = endX - startX; nodes.forEach(node => { const x0 = width - (node.x1 - 2 * startX); const x1 = width - (node.x0 - 2 * startX); node.x0 = x0; node.x1 = x1; }); links.forEach(link => { const x0 = width - (link.x1 - 2 * startX); const x1 = width - (link.x0 - 2 * startX); link.x1 = x0; link.x0 = x1; }); }; export class Sankey extends Observable { constructor(element, options, theme) { super(); this._initTheme(theme); this._setOptions(options); this._initElement(element); this._initSurface(); if (options && options.data) { this._redraw(); this._initResizeObserver(); this._initNavigation(element); } } destroy() { this.unbind(); this._destroySurface(); this._destroyResizeObserver(); if (this.element) { this.element.removeEventListener('keydown', this._keydownHandler); this.element.removeEventListener('focus', this._focusHandler); this.element.removeEventListener('mousedown', this._onDownHandler); this.element.removeEventListener('touchstart', this._onDownHandler); this.element.removeEventListener('pointerdown', this._onDownHandler); } this._focusState = null; this.element = null; } _initElement(element) { this.element = element; addClass(element, [ "k-chart", "k-sankey" ]); element.setAttribute('role', 'graphics-document'); const { title } = this.options; if (title.text) { element.setAttribute('aria-label', title.text); } if (title.description) { element.setAttribute("aria-roledescription", title.description); } } _initSurface() { if (!this.surface) { this._destroySurface(); this._initSurfaceElement(); this.surface = this._createSurface(); } } _initNavigation(element) { element.tabIndex = element.getAttribute("tabindex") || 0; if (this.options.disableKeyboardNavigation) { return; } this._keydownHandler = this._keydown.bind(this); this._focusHandler = this._focus.bind(this); this._blurHandler = this._blur.bind(this); this._onDownHandler = this._onDown.bind(this); element.addEventListener('keydown', this._keydownHandler); element.addEventListener('focus', this._focusHandler); element.addEventListener('blur', this._blurHandler); element.addEventListener('mousedown', this._onDownHandler); element.addEventListener('touchstart', this._onDownHandler); element.addEventListener('pointerdown', this._onDownHandler); this._focusState = { node: this.firstFocusableNode(), link: null }; } firstFocusableNode() { return this.columns[0][0]; } _initResizeObserver() { const observer = new ResizeObserver((entries) => { entries.forEach(entry => { const { width, height } = entry.contentRect; if (entry.target !== this.element || (this.size && this.size.width === width && this.size.height === height)) { return; } this.size = { width, height }; this.surface.setSize(this.size); this.resize = true; this._redraw(); }); }); this._resizeObserver = observer; observer.observe(this.element); } _createSurface() { return drawing.Surface.create(this.surfaceElement, { mouseenter: this._mouseenter.bind(this), mouseleave: this._mouseleave.bind(this), mousemove: this._mousemove.bind(this), click: this._click.bind(this) }); } _initTheme(theme) { let currentTheme = theme || this.theme || {}; this.theme = currentTheme; this.options = deepExtend({}, currentTheme, this.options); } setLinksOpacity(opacity) { this.linksVisuals.forEach(link => { this.setOpacity(link, opacity, link.linkOptions.opacity); }); } setLinksInactivityOpacity(inactiveOpacity) { this.linksVisuals.forEach(link => { this.setOpacity(link, inactiveOpacity, link.linkOptions.highlight.inactiveOpacity); }); } setOpacity(link, opacity, linkValue) { link.options.set('stroke', Object.assign({}, link.options.stroke, {opacity: defined(linkValue) ? linkValue : opacity})); } trigger(name, ev) { let dataItem = ev.element.dataItem; const targetType = ev.element.type; const event = Object.assign({}, ev, {type: name, targetType, dataItem: dataItem}); return super.trigger(name, event); } _mouseenter(ev) { const element = ev.element; const isLink = element.type === LINK; const isNode = element.type === NODE; const isLegendItem = Boolean(element.chartElement && element.chartElement.options.node); if ((isLink && this.trigger('linkEnter', ev)) || (isNode && this.trigger('nodeEnter', ev))) { return; } const { highlight } = this.options.links; if (isLink) { this.setLinksInactivityOpacity(highlight.inactiveOpacity); this.setOpacity(element, highlight.opacity, element.linkOptions.highlight.opacity); } else if (isNode) { this.highlightLinks(element, highlight); } else if (isLegendItem) { const nodeVisual = this.nodesVisuals.get(element.chartElement.options.node.id); this.highlightLinks(nodeVisual, highlight); } } _mouseleave(ev) { const element = ev.element; const isLink = element.type === LINK; const isNode = element.type === NODE; const isLegendItem = Boolean(element.chartElement && element.chartElement.options.node); const target = ev.originalEvent.relatedTarget; if (isLink && target && target.nodeName === 'text') { return; } if (isLink || isNode) { if (this.tooltipTimeOut) { clearTimeout(this.tooltipTimeOut); this.tooltipTimeOut = null; } this.tooltipShown = false; this.trigger('tooltipHide', ev); } if ((isLink && this.trigger('linkLeave', ev)) || (isNode && this.trigger('nodeLeave', ev))) { return; } if (isLink || isNode || isLegendItem) { this.linksVisuals.forEach(link => { this.setOpacity(link, this.options.links.opacity, link.linkOptions.opacity); }); } } _mousemove(ev) { const { followPointer, delay } = this.options.tooltip; const element = ev.element; const tooltipElType = element.type; if ((tooltipElType !== LINK && tooltipElType !== NODE) || (this.tooltipShown && !followPointer)) { return; } const mouseEvent = ev.originalEvent; const rect = this.element.getBoundingClientRect(); const isLeft = mouseEvent.clientX - rect.left < rect.width / 2; const isTop = mouseEvent.clientY - rect.top < rect.height / 2; ev.tooltipData = { popupOffset: { left: mouseEvent.pageX, top: mouseEvent.pageY }, popupAlign: { horizontal: isLeft ? 'left' : 'right', vertical: isTop ? 'top' : 'bottom' } }; if (tooltipElType === NODE) { const { sourceLinks, targetLinks } = element.dataItem; const links = targetLinks.length ? targetLinks : sourceLinks; ev.nodeValue = links.reduce((acc, link) => acc + link.value, 0); } if (this.tooltipTimeOut) { clearTimeout(this.tooltipTimeOut); this.tooltipTimeOut = null; } const nextDelay = followPointer && this.tooltipShown ? 0 : delay; this.tooltipTimeOut = setTimeout(() => { this.trigger('tooltipShow', ev); this.tooltipShown = true; this.tooltipTimeOut = null; }, nextDelay); } _click(ev) { const element = ev.element; const dataItem = element.dataItem; const isLink = element.type === LINK; const isNode = element.type === NODE; const focusState = this._focusState || {}; if (isNode) { const focusedNodeClicked = !focusState.link && this.sameNode(focusState.node, dataItem); if (!focusedNodeClicked) { this._focusState = { node: dataItem, link: null }; this._focusNode({ highlight: false }); } this.trigger('nodeClick', ev); } else if (isLink) { const link = { sourceId: dataItem.source.id, targetId: dataItem.target.id, value: dataItem.value }; const focusedLinkClicked = this.sameLink(focusState.link, link); if (!focusedLinkClicked) { this._focusState = { node: dataItem.source, link: link }; this._focusLink({ highlight: false }); } this.trigger('linkClick', ev); } } sameNode(node1, node2) { return node1 && node2 && node1.id === node2.id; } sameLink(link1, link2) { return link1 && link2 && link1.sourceId === link2.sourceId && link1.targetId === link2.targetId; } _focusNode(options) { this._cleanFocusHighlight(); const nodeData = this._focusState.node; const node = this.models.map.get(nodeData.id); node.focus(options); } _focusLink(options) { this._cleanFocusHighlight(); const linkData = this._focusState.link; const link = this.models.map.get(`${linkData.sourceId}-${linkData.targetId}`); link.focus(options); } _focusNextNode(direction = 1) { const current = this._focusState.node; const columnIndex = this.columns.findIndex(column => column.find(n => n.id === current.id)); const columnNodes = this.columns[columnIndex]; const nodeIndex = columnNodes.findIndex(n => n.id === current.id); const nextNode = columnNodes[nodeIndex + direction]; if (nextNode) { this._focusState.node = nextNode; this._focusNode(); } } _focusNextLink(direction = 1) { const node = this._focusState.node; const link = this._focusState.link; const sourceLinkIndex = node.sourceLinks.findIndex(l => l.sourceId === link.sourceId && l.targetId === link.targetId); const targetLinkIndex = node.targetLinks.findIndex(l => l.sourceId === link.sourceId && l.targetId === link.targetId); if (sourceLinkIndex !== -1) { const nextLink = node.sourceLinks[sourceLinkIndex + direction]; if (nextLink) { this._focusState.link = nextLink; this._focusLink(); } } else if (targetLinkIndex !== -1) { const nextLink = node.targetLinks[targetLinkIndex + direction]; if (nextLink) { this._focusState.link = nextLink; this._focusLink(); } } } _focusSourceNode() { const linkData = this._focusState.link; const sourceNode = this.models.map.get(linkData.sourceId); this._focusState = { node: sourceNode.options.node, link: null }; this._focusNode(); } _focusTargetNode() { const linkData = this._focusState.link; const targetNode = this.models.map.get(linkData.targetId); this._focusState = { node: targetNode.options.node, link: null }; this._focusNode(); } _focusSourceLink() { const nodeData = this._focusState.node; const sourceLinks = nodeData.sourceLinks; const linkData = sourceLinks[0]; if (linkData) { this._focusState.link = linkData; this._focusLink(); } } _focusTargetLink() { const nodeData = this._focusState.node; const targetLinks = nodeData.targetLinks; const linkData = targetLinks[0]; if (linkData) { this._focusState.link = linkData; this._focusLink(); } } _focus() { if (!this._skipFocusHighlight) { if (this._focusState.link) { this._focusLink(); } else { this._focusNode(); } } this._skipFocusHighlight = false; } _blur() { this._cleanFocusHighlight(); } _onDown() { if (!this._hasFocus()) { this._skipFocusHighlight = true; } } _hasFocus() { return this.element.ownerDocument.activeElement === this.element; } _cleanFocusHighlight() { this.models.nodes.forEach(node => node.blur()); this.models.links.forEach(link => link.blur()); } _keydown(ev) { let handler = this['on' + ev.key]; const rtl = this.options.rtl; if (rtl && ev.key === 'ArrowLeft') { handler = this.onArrowRight; } else if (rtl && ev.key === 'ArrowRight') { handler = this.onArrowLeft; } if (handler) { handler.call(this, ev); } } onEscape(ev) { ev.preventDefault(); this._focusState = { node: this.firstFocusableNode(), link: null }; this._focusNode(); } onArrowDown(ev) { ev.preventDefault(); if (this._focusState.link) { this._focusNextLink(1); } else { this._focusNextNode(1); } } onArrowUp(ev) { ev.preventDefault(); if (this._focusState.link) { this._focusNextLink(-1); } else { this._focusNextNode(-1); } } onArrowLeft(ev) { ev.preventDefault(); if (this._focusState.link) { this._focusSourceNode(); } else { this._focusTargetLink(); } } onArrowRight(ev) { ev.preventDefault(); if (this._focusState.link) { this._focusTargetNode(); } else { this._focusSourceLink(); } } highlightLinks(node, highlight) { if (node) { this.setLinksInactivityOpacity(highlight.inactiveOpacity); node.links.forEach(link => { this.setOpacity(link, highlight.opacity, link.linkOptions.highlight.opacity); }); } } _destroySurface() { if (this.surface) { this.surface.destroy(); this.surface = null; this._destroySurfaceElement(); } } _destroyResizeObserver() { if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } } _initSurfaceElement() { if (!this.surfaceElement) { this.surfaceElement = document.createElement('div'); this.element.appendChild(this.surfaceElement); } } _destroySurfaceElement() { if (this.surfaceElement && this.surfaceElement.parentNode) { this.surfaceElement.parentNode.removeChild(this.surfaceElement); this.surfaceElement = null; } } setOptions(options, theme) { this._setOptions(options); this._initTheme(theme); this._initSurface(); this._redraw(); } _redraw() { this.surface.clear(); const { width, height } = this._getSize(); this.size = { width, height }; this.surface.setSize(this.size); this.createVisual(); this.surface.draw(this.visual); } _getSize() { return this.element.getBoundingClientRect(); } createVisual() { this.visual = this._render(); } titleBox(title, drawingRect) { if (!title || title.visible === false || !title.text) { return null; } const titleElement = new Title(Object.assign({}, {drawingRect}, title)); const titleVisual = titleElement.exportVisual(); return titleVisual.chartElement.box; } legendBox(options, nodes, drawingRect) { if (!options || options.visible === false) { return null; } const legend = new Legend(Object.assign({}, {nodes}, options, {drawingRect})); const legendVisual = legend.exportVisual(); return legendVisual.chartElement.box; } calculateSankey(calcOptions, sankeyOptions) { const { title, legend, data, nodes, labels, nodeColors, disableAutoLayout, disableKeyboardNavigation, rtl } = sankeyOptions; const autoLayout = !disableAutoLayout; const focusHighlightWidth = ((nodes.focusHighlight || {}).border || {}).width || 0; const padding = disableKeyboardNavigation ? 0 : focusHighlightWidth / 2; const sankeyBox = new Box(0, 0, calcOptions.width, calcOptions.height); sankeyBox.unpad(padding); const titleBox = this.titleBox(title, sankeyBox); let legendArea = sankeyBox.clone(); if (titleBox) { const titleHeight = titleBox.height(); if (title.position === TOP) { sankeyBox.unpad({ top: titleHeight }); legendArea = new Box(0, titleHeight, calcOptions.width, calcOptions.height); } else { sankeyBox.shrink(0, titleHeight); legendArea = new Box(0, 0, calcOptions.width, calcOptions.height - titleHeight); } } const legendBox = this.legendBox(legend, data.nodes, legendArea); const legendPosition = (legend && legend.position) || Legend.prototype.options.position; if (legendBox) { if (legendPosition === LEFT) { sankeyBox.unpad({ left: legendBox.width() }); } if (legendPosition === RIGHT) { sankeyBox.shrink(legendBox.width(), 0); } if (legendPosition === TOP) { sankeyBox.unpad({ top: legendBox.height() }); } if (legendPosition === BOTTOM) { sankeyBox.shrink(0, legendBox.height()); } } const { nodes: calculatedNodes, circularLinks } = calculateSankey(Object.assign({}, calcOptions, {offsetX: 0, offsetY: 0, width: sankeyBox.width(), height: sankeyBox.height()})); if (circularLinks) { console.warn('Circular links detected. Kendo Sankey diagram does not support circular links.'); return { sankey: { nodes: [], links: [], columns: [[]], circularLinks }, legendBox, titleBox }; } const box = new Box(); const diagramMinX = calculatedNodes.reduce((acc, node) => Math.min(acc, node.x0), Infinity); const diagramMaxX = calculatedNodes.reduce((acc, node) => Math.max(acc, node.x1), 0); calculatedNodes.forEach((nodeEl, i) => { if (rtl) { const { x0, x1 } = nodeEl; nodeEl.x0 = diagramMaxX - x1; nodeEl.x1 = diagramMaxX - x0; } const nodeOps = resolveNodeOptions(nodeEl, nodes, nodeColors, i); const nodeInstance = new Node(nodeOps); box.wrap(rectToBox(nodeInstance.exportVisual().rawBBox())); const labelInstance = new Label(resolveLabelOptions(nodeEl, labels, rtl, diagramMinX, diagramMaxX)); const labelVisual = labelInstance.exportVisual(); if (labelVisual) { box.wrap(rectToBox(labelVisual.rawBBox())); } }); let offsetX = sankeyBox.x1; let offsetY = sankeyBox.y1; let width = sankeyBox.width() + offsetX; let height = sankeyBox.height() + offsetY; width -= box.x2 > sankeyBox.width() ? box.x2 - sankeyBox.width() : 0; height -= box.y2 > sankeyBox.height() ? box.y2 - sankeyBox.height() : 0; offsetX += box.x1 < 0 ? -box.x1 : 0; offsetY += box.y1 < 0 ? -box.y1 : 0; if (autoLayout === false) { return { sankey: calculateSankey(Object.assign({}, calcOptions, {offsetX, offsetY, width, height, autoLayout: false})), legendBox, titleBox }; } if (this.resize && autoLayout && this.permutation) { this.resize = false; return { sankey: calculateSankey(Object.assign({}, calcOptions, {offsetX, offsetY, width, height}, this.permutation)), legendBox, titleBox }; } const startColumn = 1; const loops = 2; const columnsLength = calculateSankey(Object.assign({}, calcOptions, {offsetX, offsetY, width, height, autoLayout: false})).columns.length; const results = []; const permutation = (targetColumnIndex, reverse) => { let currPerm = calculateSankey(Object.assign({}, calcOptions, {offsetX, offsetY, width, height, loops: loops, targetColumnIndex, reverse})); let crosses = crossesValue(currPerm.links); results.push({ crosses: crosses, reverse: reverse, targetColumnIndex: targetColumnIndex }); return crosses === 0; }; for (let index = startColumn; index <= columnsLength - 1; index++) { if (permutation(index, false) || permutation(index, true)) { break; } } const minCrosses = Math.min.apply(null, results.map(r => r.crosses)); const bestResult = results.find(r => r.crosses === minCrosses); this.permutation = { targetColumnIndex: bestResult.targetColumnIndex, reverse: bestResult.reverse }; const result = calculateSankey(Object.assign({}, calcOptions, {offsetX, offsetY, width, height}, this.permutation)); return { sankey: result, legendBox, titleBox }; } _render(options, context) { const sankeyOptions = options || this.options; const sankeyContext = context || this; const { labels: labelOptions, nodes: nodesOptions, links: linkOptions, nodeColors, title, legend, rtl, disableKeyboardNavigation } = sankeyOptions; let data = sankeyOptions.data; const { width, height } = sankeyContext.size; const calcOptions = Object.assign({}, data, {width, height, nodesOptions, title, legend}); const { sankey, titleBox, legendBox } = this.calculateSankey(calcOptions, sankeyOptions); if (rtl) { toRtl(sankey); } const { nodes, links, columns } = sankey; sankeyContext.columns = columns.map(column => { const newColumn = column.slice(); newColumn.sort((a, b) => a.y0 - b.y0); return newColumn; }); const visual = new drawing.Group({ clip: drawing.Path.fromRect(new geometry.Rect([0, 0], [width, height])) }); if (titleBox) { const titleElement = new Title(Object.assign({}, title, {drawingRect: titleBox})); const titleVisual = titleElement.exportVisual(); visual.append(titleVisual); } if (sankey.circularLinks) { return visual; } const visualNodes = new Map(); sankeyContext.nodesVisuals = visualNodes; const models = { nodes: [], links: [], map: new Map() }; sankeyContext.models = models; const focusHighlights = []; nodes.forEach((node, i) => { const nodeOps = resolveNodeOptions(node, nodesOptions, nodeColors, i); nodeOps.root = () => sankeyContext.element; nodeOps.navigatable = disableKeyboardNavigation !== true; const nodeInstance = new Node(nodeOps); const nodeVisual = nodeInstance.exportVisual(); nodeVisual.links = []; nodeVisual.type = NODE; node.color = nodeOps.color; node.opacity = nodeOps.opacity; nodeVisual.dataItem = Object.assign({}, data.nodes[i], {color: nodeOps.color, opacity: nodeOps.opacity, sourceLinks: node.sourceLinks.map(link => ({ sourceId: link.sourceId, targetId: link.targetId, value: link.value })), targetLinks: node.targetLinks.map(link => ({ sourceId: link.sourceId, targetId: link.targetId, value: link.value }))}); visualNodes.set(node.id, nodeVisual); models.nodes.push(nodeInstance); models.map.set(node.id, nodeInstance); visual.append(nodeVisual); nodeInstance.createFocusHighlight(); if (nodeInstance._highlight) { focusHighlights.push(nodeInstance._highlight); } }); const sortedLinks = links.slice().sort((a, b) => b.value - a.value); const linksVisuals = []; sankeyContext.linksVisuals = linksVisuals; sortedLinks.forEach(link => { const { source, target } = link; const sourceNode = visualNodes.get(source.id); const targetNode = visualNodes.get(target.id); const resolvedOptions = resolveLinkOptions(link, linkOptions, sourceNode, targetNode); resolvedOptions.root = () => sankeyContext.element; resolvedOptions.navigatable = disableKeyboardNavigation !== true; resolvedOptions.rtl = rtl; const linkInstance = new Link(resolvedOptions); const linkVisual = linkInstance.exportVisual(); linkVisual.type = LINK; linkVisual.dataItem = { source: Object.assign({}, sourceNode.dataItem), target: Object.assign({}, targetNode.dataItem), value: link.value }; linkVisual.linkOptions = resolvedOptions; linksVisuals.push(linkVisual); sourceNode.links.push(linkVisual); targetNode.links.push(linkVisual); models.links.push(linkInstance); models.map.set(`${source.id}-${target.id}`, linkInstance); linkInstance.createFocusHighlight(); if (linkInstance._highlight) { focusHighlights.push(linkInstance._highlight); } visual.append(linkVisual); }); const diagramMinX = nodes.reduce((acc, node) => Math.min(acc, node.x0), Infinity); const diagramMaxX = nodes.reduce((acc, node) => Math.max(acc, node.x1), 0); nodes.forEach((node) => { const textOps = resolveLabelOptions(node, labelOptions, rtl, diagramMinX, diagramMaxX); const labelInstance = new Label(textOps); const labelVisual = labelInstance.exportVisual(); if (labelVisual) { visual.append(labelVisual); } }); if (legendBox) { const legendElement = new Legend(Object.assign({}, legend, {rtl, drawingRect: legendBox, nodes})); const legendVisual = legendElement.exportVisual(); visual.append(legendVisual); } if (focusHighlights.length !== 0) { const focusHighlight = new drawing.Group(); focusHighlight.append(...focusHighlights); visual.append(focusHighlight); } return visual; } exportVisual(exportOptions) { const options = (exportOptions && exportOptions.options) ? deepExtend({}, this.options, exportOptions.options) : this.options; const context = { size: { width: defined(exportOptions && exportOptions.width) ? exportOptions.width : this.size.width, height: defined(exportOptions && exportOptions.height) ? exportOptions.height : this.size.height } }; return this._render(options, context); } _setOptions(options) { this.options = deepExtend({}, this.options, options); } } const highlightOptions = { opacity: 1, width: 2, color: BLACK }; setDefaultOptions(Sankey, { title: { position: TOP, // 'top', 'bottom' }, labels: { visible: true, margin: { left: 8, right: 8 }, padding: 0, border: { width: 0 }, paintOrder: 'stroke', stroke: { lineJoin: "round", width: 1 }, offset: { left: 0, top: 0 } }, nodes: { width: 24, padding: 16, opacity: 1, align: 'stretch', // 'left', 'right', 'stretch' offset: { left: 0, top: 0 }, focusHighlight: { border: Object.assign({}, highlightOptions) }, labels: { ariaTemplate: ({ node }) => node.label.text } }, links: { colorType: 'static', // 'source', 'target', 'static' opacity: 0.4, highlight: { opacity: 0.8, inactiveOpacity: 0.2 }, focusHighlight: { border: Object.assign({}, highlightOptions) }, labels: { ariaTemplate: ({ link }) => `${link.source.label.text} to ${link.target.label.text}` } }, tooltip: { followPointer: false, delay: 200 } });