|
| 1 | +import * as d3 from 'd3'; |
| 2 | +import * as d3Sankey from 'd3-sankey'; |
| 3 | + |
| 4 | +const DISPLAY_VALUES = { total: 'total', percentage: 'percentage', both: 'both', none: 'none' }; |
| 5 | +const EDGE_COLORS = { none: 'none', path: 'path', input: 'input', output: 'output'}; |
| 6 | + |
| 7 | +export class Sankey { |
| 8 | + constructor(svg, container) { |
| 9 | + this._svg = svg; |
| 10 | + this._container = container || svg; |
| 11 | + this._gBound = null; |
| 12 | + |
| 13 | + this._data = null; |
| 14 | + this._nodes = null; |
| 15 | + this._links = null; |
| 16 | + |
| 17 | + this._width = 0; |
| 18 | + this._height = 0; |
| 19 | + this._boundedWidth = 0; |
| 20 | + this._boundedHeight = 0; |
| 21 | + |
| 22 | + this._marginTop = 20; |
| 23 | + this._marginRight = 20; |
| 24 | + this._marginBottom = 20; |
| 25 | + this._marginLeft = 20; |
| 26 | + |
| 27 | + this._background = '#f8f8fa'; |
| 28 | + this._edgeColor = 'path'; |
| 29 | + this._colorScheme = 'Tableau10'; |
| 30 | + this._colorScale = null; |
| 31 | + |
| 32 | + this._sankeyAlignType = 'Justify'; |
| 33 | + this._sankeyAlign = null; |
| 34 | + this._sankeyGenerator = null; |
| 35 | + this._sankeyNodeWith = 15; |
| 36 | + this._sankeyNodePadding = 20; |
| 37 | + |
| 38 | + this._svgNode = null; |
| 39 | + this._svgLink = null; |
| 40 | + |
| 41 | + this._displayValues = 'none'; |
| 42 | + this._highlightOnHover = false; |
| 43 | + |
| 44 | + } |
| 45 | + |
| 46 | + _init() { |
| 47 | + this._setBoundDimensions(); |
| 48 | + this._setColorScale(); |
| 49 | + this._configureSankey(); |
| 50 | + this._calculateSankey(); |
| 51 | + } |
| 52 | + |
| 53 | + // ---------------------------- DIMENSIONS ---------------------------- |
| 54 | + |
| 55 | + _setBoundDimensions() { |
| 56 | + this._boundedWidth = this._width - this._marginLeft - this._marginRight; |
| 57 | + this._boundedHeight = this._height - this._marginTop - this._marginBottom; |
| 58 | + } |
| 59 | + |
| 60 | + // ------------------------------ COLOR ------------------------------- |
| 61 | + |
| 62 | + _setColorScale() { |
| 63 | + this._colorScale = d3.scaleOrdinal(d3[`scheme${this._colorScheme}`]); |
| 64 | + } |
| 65 | + |
| 66 | + _color(node) { |
| 67 | + return this._colorScale(node.name); |
| 68 | + } |
| 69 | + |
| 70 | + // ------------------------------ SANKEY ------------------------------- |
| 71 | + |
| 72 | + _configureSankey() { |
| 73 | + this._sankeyAlign = d3Sankey[`sankey${this._sankeyAlignType}`]; |
| 74 | + |
| 75 | + this._sankeyGenerator = d3Sankey |
| 76 | + .sankey() |
| 77 | + .nodeId(d => d.name) |
| 78 | + .nodeAlign(this._sankeyAlign) |
| 79 | + .nodeWidth(this._sankeyNodeWith) |
| 80 | + .nodePadding(this._sankeyNodePadding) |
| 81 | + .extent([ |
| 82 | + [0, 0], |
| 83 | + [this._boundedWidth, this._boundedHeight], |
| 84 | + ]); |
| 85 | + } |
| 86 | + |
| 87 | + _calculateSankey() { |
| 88 | + const sankeyData = this._sankeyGenerator({ |
| 89 | + nodes: this._data.nodes.map(d => Object.assign({}, d)), |
| 90 | + links: this._data.links.map(d => Object.assign({}, d)) |
| 91 | + }); |
| 92 | + |
| 93 | + this._nodes = sankeyData.nodes; |
| 94 | + this._links = sankeyData.links; |
| 95 | + } |
| 96 | + |
| 97 | + // ---------------------------- VALIDATIONS ----------------------------- |
| 98 | + |
| 99 | + _validate() { |
| 100 | + return this._data && |
| 101 | + this._data.nodes && |
| 102 | + this._data.links && |
| 103 | + this._data.nodes.length > 0 && |
| 104 | + this._data.links.length > 0 |
| 105 | + } |
| 106 | + |
| 107 | + // ------------------------------ HELPERS ------------------------------- |
| 108 | + |
| 109 | + _setLinkGradient() { |
| 110 | + const gradient = this._svgLink |
| 111 | + .append('linearGradient') |
| 112 | + .attr('id', d => (d.uid = `link-${d.index}-${Math.random()}`)) |
| 113 | + .attr('gradientUnits', 'userSpaceOnUse') |
| 114 | + .attr('x1', d => d.source.x1) |
| 115 | + .attr('x2', d => d.target.x0); |
| 116 | + |
| 117 | + gradient |
| 118 | + .append('stop') |
| 119 | + .attr('offset', '0%') |
| 120 | + .attr('stop-color', d => this._color(d.source)); |
| 121 | + |
| 122 | + gradient |
| 123 | + .append('stop') |
| 124 | + .attr('offset', '100%') |
| 125 | + .attr('stop-color', d => this._color(d.target)); |
| 126 | + } |
| 127 | + |
| 128 | + _setLinkStroke(d) { |
| 129 | + switch (this._edgeColor) { |
| 130 | + case EDGE_COLORS.none: |
| 131 | + return '#aaa'; |
| 132 | + case EDGE_COLORS.path: |
| 133 | + return `url(#${d.uid})`; |
| 134 | + case EDGE_COLORS.input: |
| 135 | + return this._color(d.source) |
| 136 | + case EDGE_COLORS.output: |
| 137 | + return this._color(d.target) |
| 138 | + default: |
| 139 | + return |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + // NODE HOVER |
| 144 | + _showLinks(currentNode) { |
| 145 | + const linkedNodes = []; |
| 146 | + |
| 147 | + let traverse = [ |
| 148 | + { |
| 149 | + linkType: 'sourceLinks', |
| 150 | + nodeType: 'target', |
| 151 | + }, |
| 152 | + { |
| 153 | + linkType: 'targetLinks', |
| 154 | + nodeType: 'source', |
| 155 | + }, |
| 156 | + ]; |
| 157 | + |
| 158 | + traverse.forEach(step => { |
| 159 | + currentNode[step.linkType].forEach(l => { |
| 160 | + linkedNodes.push(l[step.nodeType]); |
| 161 | + }); |
| 162 | + }); |
| 163 | + |
| 164 | + // highlight linked nodes |
| 165 | + this._gBound |
| 166 | + .selectAll('.sankey-node') |
| 167 | + .style('opacity', node => |
| 168 | + currentNode.name === node.name || |
| 169 | + linkedNodes.find(linkedNode => linkedNode.name === node.name) ? |
| 170 | + '1' : '0.2' |
| 171 | + ); |
| 172 | + |
| 173 | + // highlight links |
| 174 | + this._gBound |
| 175 | + .selectAll('.sankey-link') |
| 176 | + .style('opacity', link => |
| 177 | + link && ( |
| 178 | + link.source.name === currentNode.name || |
| 179 | + link.target.name === currentNode.name |
| 180 | + ) ? |
| 181 | + '1' : '0.2' |
| 182 | + ); |
| 183 | + }; |
| 184 | + |
| 185 | + _showAll() { |
| 186 | + this._gBound.selectAll('.sankey-node').style('opacity', '1'); |
| 187 | + this._gBound.selectAll('.sankey-link').style('opacity', '1'); |
| 188 | + }; |
| 189 | + |
| 190 | + // NODE LABELING |
| 191 | + _formatValue(value) { return d3.format('.2~f')(value); } |
| 192 | + _formatPercent(percent) { return d3.format('.2~%')(percent); } |
| 193 | + _formatThousand(value) { return d3.format('.3~s')(value); } |
| 194 | + |
| 195 | + _labelNode(currentNode) { |
| 196 | + const nodesAtDepth = this._nodes.filter(node => node.depth === currentNode.depth); |
| 197 | + const totalAtDepth = d3.sum(nodesAtDepth, node => node.value); |
| 198 | + const nodeValue = this._formatThousand(currentNode.value); |
| 199 | + const nodePercent = this._formatPercent(currentNode.value / totalAtDepth); |
| 200 | + |
| 201 | + let label = currentNode.name; |
| 202 | + switch (this._displayValues) { |
| 203 | + case DISPLAY_VALUES.total: |
| 204 | + label = `${label}: ${nodeValue}`; |
| 205 | + break; |
| 206 | + case DISPLAY_VALUES.percentage: |
| 207 | + label = `${label}: ${nodePercent}`; |
| 208 | + break; |
| 209 | + case DISPLAY_VALUES.both: |
| 210 | + label = `${label}: ${nodePercent} - ${nodeValue}`; |
| 211 | + break; |
| 212 | + default: |
| 213 | + break; |
| 214 | + } |
| 215 | + return label; |
| 216 | + }; |
| 217 | + |
| 218 | + // ------------------------------ DRAWING ------------------------------- |
| 219 | + |
| 220 | + _renderSVG() { |
| 221 | + // BACKGROUND |
| 222 | + this._container.style('background-color', this._background) |
| 223 | + |
| 224 | + // BOUNDS |
| 225 | + this._gBound = this._container.append('g') |
| 226 | + .attr('transform', `translate(${this._marginLeft}, ${this._marginTop})`); |
| 227 | + |
| 228 | + // NODES |
| 229 | + this._svgNode = this._gBound |
| 230 | + .append('g') |
| 231 | + .attr('stroke', '#000') |
| 232 | + .selectAll('.sankey-node') |
| 233 | + .data(this._nodes, node => node.name) |
| 234 | + .join('rect') |
| 235 | + .attr('class', 'sankey-node') |
| 236 | + .attr('x', d => d.x0) |
| 237 | + .attr('y', d => d.y0) |
| 238 | + .attr('rx', 2) |
| 239 | + .attr('ry', 2) |
| 240 | + .attr('height', d => d.y1 - d.y0) |
| 241 | + .attr('width', d => d.x1 - d.x0) |
| 242 | + .attr('stroke', d => d3.color(this._color(d)).darker(0.5)) |
| 243 | + .attr('fill', d => this._color(d)) |
| 244 | + .on('mouseover', d => this._highlightOnHover && this._showLinks(d)) |
| 245 | + .on('mouseout', _ => this._highlightOnHover && this._showAll()); |
| 246 | + |
| 247 | + // LINKS |
| 248 | + this._svgLink = this._gBound |
| 249 | + .append('g') |
| 250 | + .attr('fill', 'none') |
| 251 | + .attr('stroke-opacity', 0.3) |
| 252 | + .selectAll('g') |
| 253 | + .data(this._links, link => `${link.source.name}-${link.target.name}`) |
| 254 | + .join('g') |
| 255 | + .style('mix-blend-mode', 'multiply'); |
| 256 | + |
| 257 | + if (this._edgeColor === 'path') this._setLinkGradient() |
| 258 | + |
| 259 | + this._svgLink |
| 260 | + .append('path') |
| 261 | + .attr('class', 'sankey-link') |
| 262 | + .attr('d', d3Sankey.sankeyLinkHorizontal()) |
| 263 | + .attr('stroke', d => this._setLinkStroke(d)) |
| 264 | + .attr('stroke-width', d => Math.max(1, d.width)); |
| 265 | + |
| 266 | + // LABELS |
| 267 | + this._gBound |
| 268 | + .append('g') |
| 269 | + .attr('font-family', 'sans-serif') |
| 270 | + .attr('font-size', 10) |
| 271 | + .selectAll('text') |
| 272 | + .data(this._nodes) |
| 273 | + .join('text') |
| 274 | + .attr('x', d => (d.x0 < this._width / 2 ? d.x1 + 6 : d.x0 - 6)) |
| 275 | + .attr('y', d => (d.y1 + d.y0) / 2) |
| 276 | + .attr('dy', '0.35em') |
| 277 | + .attr('text-anchor', d => (d.x0 < this._width / 2 ? 'start' : 'end')) |
| 278 | + .text(d => this._labelNode(d)); |
| 279 | + |
| 280 | + this._svgNode |
| 281 | + .append('title') |
| 282 | + .text(d => `${d.name}\n${this._formatValue(d.value)}`); |
| 283 | + |
| 284 | + this._svgLink |
| 285 | + .append('title') |
| 286 | + .text(d => `${d.source.name} → ${d.target.name}\n${this._formatValue(d.value)}`); |
| 287 | + } |
| 288 | + |
| 289 | + |
| 290 | + // ----------------------------------------------------------------------- |
| 291 | + // ------------------------------ API ------------------------------ |
| 292 | + // ----------------------------------------------------------------------- |
| 293 | + |
| 294 | + data(_) { |
| 295 | + return arguments.length ? (this._data = _, this) : this._data; |
| 296 | + }; |
| 297 | + |
| 298 | + width(_) { |
| 299 | + return arguments.length ? (this._width = +_, this) : this._width; |
| 300 | + }; |
| 301 | + |
| 302 | + height(_) { |
| 303 | + return arguments.length ? (this._height = +_, this) : this._height; |
| 304 | + }; |
| 305 | + |
| 306 | + align(_) { |
| 307 | + return arguments.length ? (this._sankeyAlignType = _, this) : this._sankeyAlignType; |
| 308 | + } |
| 309 | + |
| 310 | + colorScheme(_) { |
| 311 | + return arguments.length ? (this._colorScheme = _, this) : this._colorScheme; |
| 312 | + } |
| 313 | + |
| 314 | + edgeColor(_) { |
| 315 | + return arguments.length ? (this._edgeColor = _, this) : this._edgeColor; |
| 316 | + } |
| 317 | + |
| 318 | + displayValues(_) { |
| 319 | + return arguments.length ? (this._displayValues = _, this) : this._displayValues; |
| 320 | + } |
| 321 | + |
| 322 | + highlightOnHover(_) { |
| 323 | + return arguments.length ? (this._highlightOnHover = _, this) : this._highlightOnHover; |
| 324 | + } |
| 325 | + |
| 326 | + render() { |
| 327 | + if (!this._validate()) { |
| 328 | + // no graph data |
| 329 | + } |
| 330 | + else { |
| 331 | + this._init(); |
| 332 | + this._renderSVG() |
| 333 | + } |
| 334 | + return this; |
| 335 | + } |
| 336 | +} |
0 commit comments