UNPKG

chartjs-chart-sankey

Version:

Chart.js module for creating sankey diagrams

854 lines (847 loc) 27.6 kB
/*! * chartjs-chart-sankey v0.14.0 * https://github.com/kurkle/chartjs-chart-sankey#readme * (c) 2024 Jukka Kurkela * Released under the MIT license */ import { DatasetController, Element } from 'chart.js'; import { toFont, valueOrDefault, getHoverColor, color } from 'chart.js/helpers'; const defined = (x)=>x !== undefined; function toTextLines(raw) { if (!raw) return []; const lines = []; const inputs = Array.isArray(raw) ? raw : [ raw ]; while(inputs.length){ const input = inputs.pop(); if (typeof input === 'string') { lines.unshift(...input.split('\n')); } else if (Array.isArray(input)) { inputs.push(...input); } else if (input) { lines.unshift(`${input}`); } } return lines; } function validateSizeValue(size) { if (!size || [ 'min', 'max' ].indexOf(size) === -1) { return 'max'; } return size; } const flowSort = (a, b)=>{ if (b.flow === a.flow) return a.index - b.index; return b.flow - a.flow; }; const setSizes = (nodes, size)=>{ const sizeMethod = validateSizeValue(size); for (const node of nodes.values()){ node.from.sort(flowSort); node.to.sort(flowSort); node.size = Math[sizeMethod](node.in || node.out, node.out || node.in); } }; const setPriorities = (nodes, priority)=>{ if (!priority) return; for (const node of nodes.values()){ if (node.key in priority) { node.priority = priority[node.key]; } } }; const setColumns = (nodes, column)=>{ if (!column) return; for (const node of nodes.values()){ if (node.key in column) { node.column = true; node.x = column[node.key]; } } }; const getParsedData = (data, parsing)=>{ const { from: fromKey = 'from', to: toKey = 'to', flow: flowKey = 'flow' } = parsing; return data.map(({ [fromKey]: from, [toKey]: to, [flowKey]: flow })=>({ from, to, flow })); }; function buildNodesFromData(data, { size, priority, column }) { const nodes = new Map(); for(let i = 0; i < data.length; i++){ const { from, to, flow } = data[i]; const fromNode = nodes.get(from) ?? { key: from, in: 0, out: 0, size: 0, from: [], to: [] }; const toNode = (from === to ? fromNode : nodes.get(to)) ?? { key: to, in: 0, out: 0, size: 0, from: [], to: [] }; fromNode.out += flow; fromNode.to.push({ key: to, flow: flow, index: i, node: toNode, addY: 0 }); if (fromNode.to.length === 1) { nodes.set(from, fromNode); } toNode.in += flow; toNode.from.push({ key: from, flow: flow, index: i, node: fromNode, addY: 0 }); if (toNode.from.length === 1) { nodes.set(to, toNode); } } setSizes(nodes, size); setPriorities(nodes, priority); setColumns(nodes, column); return nodes; } const SMALL_VALUE = 1e-6; const getAllKeysForward = (nodes, visited = new Set())=>{ const keys = []; for (const node of nodes){ if (visited.has(node.key)) continue; visited.add(node.key); keys.push(node.key, ...getAllKeysForward(node.to.map((to)=>to.node), visited)); } return keys; }; const startColumn = (data, nodes)=>{ const startNodes = nodes.filter((node)=>node.from.length === 0); const column = startNodes.map((node)=>node.key); const startRef = getAllKeysForward(startNodes); const referencedNodes = new Set(startRef); for (const point of data){ if (!referencedNodes.has(point.from) && !referencedNodes.has(point.to)) { column.push(point.from); referencedNodes.add(point.from); } referencedNodes.add(point.to); } return column; }; const nextColumn = (dataWithoutDirectLoops, remainingKeys)=>{ const remainingTo = new Set(dataWithoutDirectLoops.filter((flow)=>remainingKeys.has(flow.from)).map((flow)=>flow.to)); const remainingKeyArray = [ ...remainingKeys ]; const columnsNotInTo = remainingKeyArray.filter((key)=>!remainingTo.has(key)); return columnsNotInTo.length ? columnsNotInTo : remainingKeyArray.slice(0, 1); }; function calculateX(nodeMap, data, mode) { const dataWithoutDirectLoops = data.filter((dp)=>dp.from !== dp.to); const allKeys = [ ...nodeMap.keys() ]; const allNodes = [ ...nodeMap.values() ]; const keysToPlace = new Set(allKeys); let x = 0; while(keysToPlace.size){ const column = x === 0 ? startColumn(data, allNodes) : nextColumn(dataWithoutDirectLoops, keysToPlace); if (!column.length) { throw new Error('Fatal error: Unable to place nodes to columns. Please report this issue.'); } for (const key of column){ const node = nodeMap.get(key); if (node && !defined(node.x)) { node.x = x; } keysToPlace.delete(key); } if (keysToPlace.size) { x++; } } const maxX = allNodes.reduce((max, node)=>Math.max(max, node.x), 0); if (mode === 'edge') { const from = new Set(data.map((dataPoint)=>dataPoint.from)); allKeys.filter((key)=>!from.has(key)).forEach((key)=>{ const node = nodeMap.get(key); if (node && !node.column) { node.x = maxX; } }); } return maxX; } let prevCountId = -1; function getCountId() { prevCountId = prevCountId < 100 ? prevCountId + 1 : 0; return prevCountId; } function nodeCount(list, prop, countId = getCountId()) { let count = 0; for (const elem of list){ if (elem.node._visited === countId) { continue; } elem.node._visited = countId; count += elem.node[prop].length + nodeCount(elem.node[prop], prop, countId); } return count; } const flowByNodeCount = (prop)=>(a, b)=>nodeCount(a.node[prop], prop) - nodeCount(b.node[prop], prop) || a.node[prop].length - b.node[prop].length; function processFrom(node, y) { if (!node.from.length) return y; node.from.sort(flowByNodeCount('from')); for (const flow of node.from){ const n = flow.node; if (!defined(n.y)) { n.y = y; processFrom(n, y ? y + SMALL_VALUE : 0); } y = Math.max(n.y + n.out, y); } return node.y + node.size; } function processTo(node, y) { if (!node.to.length) return y; node.to.sort(flowByNodeCount('to')); for (const flow of node.to){ const n = flow.node; if (!defined(n.y)) { n.y = y; processTo(n, y ? y + SMALL_VALUE : 0); } y = Math.max(n.y + Math.max(n.in, n.out), y); } return node.y + node.size; } function setOrGetY(node, value) { if (defined(node.y)) { return node.y; } node.y = value; return value; } function processRest(nodeArray, maxX) { const leftNodes = nodeArray.filter((node)=>node.x === 0); const rightNodes = nodeArray.filter((node)=>node.x === maxX); const leftToDo = leftNodes.filter((node)=>!defined(node.y)); const rightToDo = rightNodes.filter((node)=>!defined(node.y)); const centerToDo = nodeArray.filter((node)=>node.x > 0 && node.x < maxX && !defined(node.y)); let leftY = leftNodes.reduce((acc, cur)=>Math.max(acc, cur.y + cur.out || 0), 0) + SMALL_VALUE; let rightY = rightNodes.reduce((acc, cur)=>Math.max(acc, cur.y + cur.in || 0), 0) + SMALL_VALUE; let centerY = 0; if (leftY >= rightY) { leftToDo.forEach((node)=>{ leftY = setOrGetY(node, leftY); leftY = Math.max(leftY + node.out, processTo(node, leftY)); }); rightToDo.forEach((node)=>{ rightY = setOrGetY(node, rightY); rightY = Math.max(rightY + node.in, processFrom(node, rightY)); }); } else { leftToDo.forEach((node)=>{ leftY = setOrGetY(node, leftY); }); rightToDo.forEach((node)=>{ rightY = setOrGetY(node, rightY); rightY = Math.max(rightY + node.in, processFrom(node, rightY)); }); } centerToDo.forEach((node)=>{ 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); y = setOrGetY(node, y); y = Math.max(y + node.in, processFrom(node, y)); y = Math.max(y + node.out, processTo(node, y)); centerY = Math.max(centerY, y); }); return Math.max(leftY, rightY, centerY); } const fixTop = (nodeArray, maxX)=>{ let maxY = 0; for(let x = 0; x <= maxX; x++){ const nodes = nodeArray.filter((n)=>n.x === x).sort((a, b)=>a.y - b.y); let minY = 0; for (const node of nodes){ if (node.y < minY) node.y = minY; minY = node.y + node.size; } maxY = Math.max(maxY, minY); } return maxY; }; const findStartNode = (nodeArray, maxX)=>{ const size = [ ...nodeArray ].sort((a, b)=>a.size - b.size).pop().size; const biggest = nodeArray.filter((n)=>n.size === size); if (biggest.length === 1) return biggest[0]; biggest.sort((a, b)=>a.x - b.x); if (biggest[0].x === 0) return biggest[0]; if (biggest[biggest.length - 1].x === maxX) return biggest.pop(); const mid = Math.floor(biggest.length / 2); return biggest[mid]; }; function calculateY(nodeArray, maxX) { if (!nodeArray.length) return 0; const start = findStartNode(nodeArray, maxX); start.y = 0; processFrom(start, 0); processTo(start, 0); processRest(nodeArray, maxX); return fixTop(nodeArray, maxX); } function calculateYUsingPriority(nodeArray, maxX) { let maxY = 0; let nextYStart = 0; for(let x = 0; x <= maxX; x++){ let y = nextYStart; const nodes = nodeArray.filter((node)=>node.x === x).sort((a, b)=>(a.priority ?? 0) - (b.priority ?? 0)); nextYStart = nodes.length ? nodes[0].to.filter((to)=>to.node.x > x + 1).reduce((acc, cur)=>acc + cur.flow, 0) || 0 : 0; for (const node of nodes){ node.y = y; y += Math.max(node.out, node.in); } maxY = Math.max(y, maxY); } return maxY; } const nodeByXYSize = (a, b)=>{ if (a.x !== b.x) return a.x - b.x; if (a.y === b.y) return a.size - b.size; return a.y - b.y; }; function addPadding(nodeArray, padding) { let maxY = 0; const columnXs = new Map(); const grid = []; const getColIndex = (x)=>{ if (!columnXs.has(x)) { columnXs.set(x, grid.length); grid.push([]); } return columnXs.get(x); }; nodeArray.sort(nodeByXYSize); for (const node of nodeArray){ const colIdx = getColIndex(node.x); const column = grid[colIdx]; if (node.y) { column.push(node.y); let paddings = column.length; if (node.in) { for(let col = 0; col < colIdx; col++){ const otherColumn = grid[col]; for(let row = 0; row < otherColumn.length; row++){ if (otherColumn[row] > node.y) break; paddings = Math.max(row + 1, paddings); } } while(column.length < paddings)column.push(node.y); } node.y += paddings * padding; } maxY = Math.max(maxY, node.y + Math.max(node.in, node.out)); } return maxY; } function sortFlows(nodeArray) { nodeArray.forEach((node)=>{ const nodeSize = node.size; const overlapFrom = nodeSize < node.in; const overlapTo = nodeSize < node.out; let addY = 0; let len = node.from.length; node.from.sort((a, b)=>a.node.y + a.node.out / 2 - (b.node.y + b.node.out / 2)).forEach((flow, idx)=>{ if (overlapFrom) { flow.addY = idx * (nodeSize - flow.flow) / (len - 1); } else { flow.addY = addY; addY += flow.flow; } }); addY = 0; len = node.to.length; node.to.sort((a, b)=>a.node.y + a.node.in / 2 - (b.node.y + b.node.in / 2)).forEach((flow, idx)=>{ if (overlapTo) { flow.addY = idx * (nodeSize - flow.flow) / (len - 1); } else { flow.addY = addY; addY += flow.flow; } }); }); } function layout(nodes, data, { priority, height, nodePadding, modeX }) { const nodeArray = [ ...nodes.values() ]; const maxX = calculateX(nodes, data, modeX); const maxY = priority ? calculateYUsingPriority(nodeArray, maxX) : calculateY(nodeArray, maxX); const padding = maxY / height * nodePadding; const maxYWithPadding = addPadding(nodeArray, padding); sortFlows(nodeArray); return { maxX, maxY: maxYWithPadding }; } function getAddY(arr, key, index) { for (const item of arr){ if (item.key === key && item.index === index) { return item.addY; } } return 0; } class SankeyController extends DatasetController { parseObjectData(meta, data, start, count) { const sankeyData = getParsedData(data, this.options.parsing); const { xScale, yScale } = meta; const parsed = []; const nodes = this._nodes = buildNodesFromData(sankeyData, this.options); const { maxX, maxY } = layout(nodes, sankeyData, { priority: !!this.options.priority, height: this.chart.canvas.height, nodePadding: this.options.nodePadding, modeX: this.options.modeX }); this._maxX = maxX; this._maxY = maxY; if (!xScale || !yScale) return []; for(let i = 0, ilen = sankeyData.length; i < ilen; ++i){ const dataPoint = sankeyData[i]; const from = nodes.get(dataPoint.from); const to = nodes.get(dataPoint.to); if (!from || !to) continue; const fromY = (from.y ?? 0) + getAddY(from.to, dataPoint.to, i); const toY = (to.y ?? 0) + getAddY(to.from, dataPoint.from, i); parsed.push({ x: xScale.parse(from.x, i), y: yScale.parse(fromY, i), _custom: { from, to, x: xScale.parse(to.x, i), y: yScale.parse(toY, i), height: yScale.parse(dataPoint.flow, i), flow: dataPoint.flow } }); } return parsed.slice(start, start + count); } getMinMax(scale) { return { min: 0, max: scale === this._cachedMeta.xScale ? this._maxX : this._maxY }; } update(mode) { const { data } = this._cachedMeta; this.updateElements(data, 0, data.length, mode); } updateElements(elems, start, count, mode) { const { xScale, yScale } = this._cachedMeta; if (!xScale || !yScale) return; const firstOpts = this.resolveDataElementOptions(start, mode); const sharedOptions = this.getSharedOptions(firstOpts); const { borderWidth, nodeWidth = 10 } = this.options; const borderSpace = borderWidth ? borderWidth / 2 + 0.5 : 0; for(let i = start; i < start + count; i++){ const parsed = this.getParsed(i); const custom = parsed._custom; const y = yScale.getPixelForValue(parsed.y); this.updateElement(elems[i], i, { x: xScale.getPixelForValue(parsed.x) + nodeWidth + borderSpace, y, x2: xScale.getPixelForValue(custom.x) - borderSpace, y2: yScale.getPixelForValue(custom.y), from: custom.from, to: custom.to, progress: mode === 'reset' ? 0 : 1, height: Math.abs(yScale.getPixelForValue(parsed.y + custom.height) - y), options: this.resolveDataElementOptions(i, mode) }, mode); } this.updateSharedOptions(sharedOptions, mode, firstOpts); } _drawLabels() { const ctx = this.chart.ctx; const options = this.options; const nodes = this._nodes || new Map(); const size = validateSizeValue(options.size); const borderWidth = options.borderWidth ?? 1; const nodeWidth = options.nodeWidth ?? 10; const labels = options.labels; const { xScale, yScale } = this._cachedMeta; if (!xScale || !yScale) return; ctx.save(); const chartArea = this.chart.chartArea; for (const node of nodes.values()){ const x = xScale.getPixelForValue(node.x); const y = yScale.getPixelForValue(node.y); const max = Math[size](node.in || node.out, node.out || node.in); const height = Math.abs(yScale.getPixelForValue(node.y + max) - y); const label = labels?.[node.key] ?? node.key; let textX = x; ctx.fillStyle = options.color ?? 'black'; ctx.textBaseline = 'middle'; if (x < chartArea.width / 2) { ctx.textAlign = 'left'; textX += nodeWidth + borderWidth + 4; } else { ctx.textAlign = 'right'; textX -= borderWidth + 4; } this._drawLabel(label, y, height, ctx, textX); } ctx.restore(); } _drawLabel(label, y, height, ctx, textX) { const font = toFont(this.options.font, this.chart.options.font); const lines = toTextLines(label); const lineCount = lines.length; const middle = y + height / 2; const textHeight = font.lineHeight; const padding = valueOrDefault(this.options.padding, textHeight / 2); ctx.font = font.string; if (lineCount > 1) { const top = middle - textHeight * lineCount / 2 + padding; for(let i = 0; i < lineCount; i++){ ctx.fillText(lines[i], textX, top + i * textHeight); } } else { ctx.fillText(label, textX, middle); } } _drawNodes() { const ctx = this.chart.ctx; const nodes = this._nodes || new Map(); const { borderColor, borderWidth = 0, nodeWidth = 10, size } = this.options; const sizeMethod = validateSizeValue(size); const { xScale, yScale } = this._cachedMeta; ctx.save(); if (borderColor && borderWidth) { ctx.strokeStyle = borderColor; ctx.lineWidth = borderWidth; } for (const node of nodes.values()){ ctx.fillStyle = node.color ?? 'black'; const x = xScale.getPixelForValue(node.x); const y = yScale.getPixelForValue(node.y); const max = Math[sizeMethod](node.in || node.out, node.out || node.in); const height = Math.abs(yScale.getPixelForValue(node.y + max) - y); if (borderWidth) { ctx.strokeRect(x, y, nodeWidth, height); } ctx.fillRect(x, y, nodeWidth, height); } ctx.restore(); } draw() { const ctx = this.chart.ctx; const data = this.getMeta().data ?? []; const active = []; for(let i = 0, ilen = data.length; i < ilen; ++i){ const flow = data[i]; flow.from.color = flow.options.colorFrom; flow.to.color = flow.options.colorTo; if (flow.active) { active.push(flow); } } for (const flow of active){ flow.from.color = flow.options.colorFrom; flow.to.color = flow.options.colorTo; } this._drawNodes(); for(let i = 0, ilen = data.length; i < ilen; ++i){ data[i].draw(ctx); } this._drawLabels(); } } SankeyController.id = 'sankey'; SankeyController.defaults = { dataElementType: 'flow', animations: { numbers: { type: 'number', properties: [ 'x', 'y', 'x2', 'y2', 'height' ] }, progress: { easing: 'linear', duration: (ctx)=>ctx.type === 'data' ? (ctx.parsed._custom.x - ctx.parsed.x) * 200 : undefined, delay: (ctx)=>ctx.type === 'data' ? ctx.parsed.x * 500 + ctx.dataIndex * 20 : undefined }, colors: { type: 'color', properties: [ 'colorFrom', 'colorTo' ] } }, color: 'black', borderColor: 'black', borderWidth: 1, modeX: 'edge', nodeWidth: 10, nodePadding: 10, transitions: { hide: { animations: { colors: { type: 'color', properties: [ 'colorFrom', 'colorTo' ], to: 'transparent' } } }, show: { animations: { colors: { type: 'color', properties: [ 'colorFrom', 'colorTo' ], from: 'transparent' } } } } }; SankeyController.overrides = { interaction: { mode: 'nearest', intersect: true }, datasets: { clip: false, parsing: { from: 'from', to: 'to', flow: 'flow' } }, plugins: { tooltip: { callbacks: { title () { return ''; }, label (context) { const parsedCustom = context.parsed._custom; return parsedCustom.from.key + ' -> ' + parsedCustom.to.key + ': ' + parsedCustom.flow; } } }, legend: { display: false } }, scales: { x: { type: 'linear', bounds: 'data', display: false, min: 0, offset: false }, y: { type: 'linear', bounds: 'data', display: false, min: 0, reverse: true, offset: false } }, layout: { padding: { top: 3, left: 3, right: 13, bottom: 3 } } }; const controlPoints = (x, y, x2, y2)=>x < x2 ? { cp1: { x: x + (x2 - x) / 3 * 2, y }, cp2: { x: x + (x2 - x) / 3, y: y2 } } : { cp1: { x: x - (x - x2) / 3, y: 0 }, cp2: { x: x2 + (x - x2) / 3, y: 0 } }; const pointInLine = (p1, p2, t)=>({ x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }); const applyAlpha = (original, alpha)=>color(original).alpha(alpha).rgbString(); const getColorOption = (option, alpha)=>typeof option === 'string' ? applyAlpha(option, alpha) : option; function setStyle(ctx, { x, x2, options }) { let fill = 'black'; if (options.colorMode === 'from') { fill = getColorOption(options.colorFrom, options.alpha); } else if (options.colorMode === 'to') { fill = getColorOption(options.colorTo, options.alpha); } else if (typeof options.colorFrom === 'string' && typeof options.colorTo === 'string') { fill = ctx.createLinearGradient(x, 0, x2, 0); fill.addColorStop(0, applyAlpha(options.colorFrom, options.alpha)); fill.addColorStop(1, applyAlpha(options.colorTo, options.alpha)); } ctx.fillStyle = fill; ctx.strokeStyle = fill; ctx.lineWidth = 0.5; } class Flow extends Element { draw(ctx) { const { x, x2, y, y2, height, progress } = this; const { cp1, cp2 } = controlPoints(x, y, x2, y2); if (progress === 0) { return; } ctx.save(); if (progress < 1) { ctx.beginPath(); ctx.rect(x, Math.min(y, y2), (x2 - x) * progress + 1, Math.abs(y2 - y) + height + 1); ctx.clip(); } setStyle(ctx, this); ctx.beginPath(); ctx.moveTo(x, y); ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, x2, y2); ctx.lineTo(x2, y2 + height); ctx.bezierCurveTo(cp2.x, cp2.y + height, cp1.x, cp1.y + height, x, y + height); ctx.lineTo(x, y); ctx.stroke(); ctx.closePath(); ctx.fill(); ctx.restore(); } inRange(mouseX, mouseY, useFinalPosition) { const { x, y, x2, y2, height } = this.getProps([ 'x', 'y', 'x2', 'y2', 'height' ], useFinalPosition); if (mouseX < x || mouseX > x2) { return false; } const { cp1, cp2 } = controlPoints(x, y, x2, y2); const t = (mouseX - x) / (x2 - x); const p1 = { x, y }; const p2 = { x: x2, y: y2 }; const a = pointInLine(p1, cp1, t); const b = pointInLine(cp1, cp2, t); const c = pointInLine(cp2, p2, t); const d = pointInLine(a, b, t); const e = pointInLine(b, c, t); const topY = pointInLine(d, e, t).y; return mouseY >= topY && mouseY <= topY + height; } inXRange(mouseX, useFinalPosition) { const { x, x2 } = this.getProps([ 'x', 'x2' ], useFinalPosition); return mouseX >= x && mouseX <= x2; } inYRange(mouseY, useFinalPosition) { const { y, y2, height } = this.getProps([ 'y', 'y2', 'height' ], useFinalPosition); const minY = Math.min(y, y2); const maxY = Math.max(y, y2) + height; return mouseY >= minY && mouseY <= maxY; } getCenterPoint(useFinalPosition) { const { x, y, x2, y2, height } = this.getProps([ 'x', 'y', 'x2', 'y2', 'height' ], useFinalPosition); return { x: (x + x2) / 2, y: (y + y2 + height) / 2 }; } tooltipPosition(useFinalPosition) { return this.getCenterPoint(useFinalPosition); } getRange(axis) { return axis === 'x' ? this.width / 2 : this.height / 2; } constructor(cfg){ super(); if (cfg) { Object.assign(this, cfg); } } } Flow.id = 'flow'; Flow.defaults = { colorFrom: 'red', colorTo: 'green', colorMode: 'gradient', alpha: 0.5, hoverColorFrom: (_ctx, options)=>getHoverColor(options.colorFrom), hoverColorTo: (_ctx, options)=>getHoverColor(options.colorTo) }; Flow.descriptors = { _scriptable: true }; export { Flow, SankeyController };