From fcf0309844770848a51c69f40ee6467e9f4d6d72 Mon Sep 17 00:00:00 2001 From: Dan Delany Date: Mon, 11 Dec 2017 14:24:07 -0500 Subject: [PATCH] lots of work on sankey diagram --- .../examples/SankeyInteractive.js.example | 18 +- .../docs/TreeMap/examples/TreeMap.js.example | 35 +- docs/styles/charts.less | 3 + package.json | 3 +- src/SankeyDiagram.js | 528 ++++++++++++------ tests/jsdom/spec/SankeyDiagram.spec.js | 413 +++++++++++++- 6 files changed, 792 insertions(+), 208 deletions(-) diff --git a/docs/src/docs/SankeyDiagram/examples/SankeyInteractive.js.example b/docs/src/docs/SankeyDiagram/examples/SankeyInteractive.js.example index 4c934021..9486882e 100644 --- a/docs/src/docs/SankeyDiagram/examples/SankeyInteractive.js.example +++ b/docs/src/docs/SankeyDiagram/examples/SankeyInteractive.js.example @@ -66,16 +66,14 @@ function getSampleData() { {id: 'g', label: "Grapes"}, ], links: [ - {source: 'a', target: 'c', value: 0.5}, - {source: 'a', target: 'd', value: 0.5}, - {source: 'b', target: 'c', value: 0.5}, - {source: 'b', target: 'd', value: 0.5}, - {source: 'c', target: 'e', value: 0.333}, - {source: 'c', target: 'f', value: 0.333}, - {source: 'c', target: 'g', value: 0.333}, - {source: 'd', target: 'e', value: 0.333}, - {source: 'd', target: 'f', value: 0.333}, - {source: 'd', target: 'g', value: 0.333}, + {source: 'a', target: 'c', value: 5}, + {source: 'a', target: 'd', value: 5}, + {source: 'b', target: 'c', value: 5}, + {source: 'b', target: 'd', value: 5}, + {source: 'c', target: 'e', value: 3.33}, + {source: 'c', target: 'g', value: 3.33}, + {source: 'd', target: 'e', value: 3.33}, + {source: 'd', target: 'f', value: 3.33}, ] } } \ No newline at end of file diff --git a/docs/src/docs/TreeMap/examples/TreeMap.js.example b/docs/src/docs/TreeMap/examples/TreeMap.js.example index d7b2695b..1af4d9c5 100644 --- a/docs/src/docs/TreeMap/examples/TreeMap.js.example +++ b/docs/src/docs/TreeMap/examples/TreeMap.js.example @@ -1,4 +1,4 @@ -const TreeMapExample = (props) => { +const TreeMapExample = props => { const data = { children: _.range(1, 5).map(n => ({ children: _.times(n * n, m => ({ @@ -7,24 +7,27 @@ const TreeMapExample = (props) => { })) }; - const colorScale = d3.scaleLinear() + const colorScale = d3 + .scaleLinear() .domain([0, 65]) - .range(['#6b6ecf', '#8ca252']) + .range(["#6b6ecf", "#8ca252"]) .interpolate(d3.interpolateHcl); - return
- ({ - backgroundColor: colorScale(parseInt(node.data.size)), - border: '1px solid #333' - })} - width={400} - height={500} - /> -
+ return ( +
+ ({ + backgroundColor: colorScale(parseInt(node.data.size)), + border: "1px solid #333" + })} + width={400} + height={500} + /> +
+ ); }; ReactDOM.render(, mountNode); diff --git a/docs/styles/charts.less b/docs/styles/charts.less index 26af9881..870ff61e 100644 --- a/docs/styles/charts.less +++ b/docs/styles/charts.less @@ -126,4 +126,7 @@ path { dominant-baseline: central; font-size: 10pt; color: #333; +} +.sankey-node-terminal { + fill: steelblue; } \ No newline at end of file diff --git a/package.json b/package.json index e3e800bc..7cf97539 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "serve": "python -m SimpleHTTPServer", "test": "npm run test-jsdom", "test-browser": "webpack-dev-server --config tests/browser/webpack.config.test.js", - "test-jsdom": "env NODE_PATH=$NODE_PATH:$PWD/src BABEL_ENV=production mocha --compilers js:babel-register --require tests/jsdom/setup.js --recursive tests/jsdom/spec" + "test-jsdom": "env NODE_PATH=$NODE_PATH:$PWD/src BABEL_ENV=production mocha --compilers js:babel-register --require tests/jsdom/setup.js --recursive tests/jsdom/spec", + "test-watch": "env NODE_PATH=$NODE_PATH:$PWD/src BABEL_ENV=production mocha --watch --compilers js:babel-register --require tests/jsdom/setup.js --recursive tests/jsdom/spec" }, "dependencies": { "d3": "^4.4.0", diff --git a/src/SankeyDiagram.js b/src/SankeyDiagram.js index 6f100d98..17edc887 100644 --- a/src/SankeyDiagram.js +++ b/src/SankeyDiagram.js @@ -1,13 +1,14 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import _ from 'lodash'; -import {area} from 'd3'; +import React from "react"; +import PropTypes from "prop-types"; +import _ from "lodash"; +import numeral from "numeral"; +import {sankey, sankeyLinkHorizontal, sankeyLeft, sankeyRight, sankeyCenter, sankeyJustify} from "d3-sankey"; -import {sankey, sankeyLinkHorizontal, sankeyLeft, sankeyRight, sankeyCenter, sankeyJustify} from 'd3-sankey'; +import {makeAccessor, getValue, domainFromData, combineDomains} from "./utils/Data"; +import xyPropsEqual from "./utils/xyPropsEqual"; +import * as CustomPropTypes from "./utils/CustomPropTypes"; -import {makeAccessor, getValue, domainFromData, combineDomains} from './utils/Data'; -import xyPropsEqual from './utils/xyPropsEqual'; -import * as CustomPropTypes from './utils/CustomPropTypes'; +window.numeral = numeral; const nodeAlignmentsByName = { left: sankeyLeft, @@ -16,91 +17,179 @@ const nodeAlignmentsByName = { justify: sankeyJustify }; - -const SankeyNode = (props) => { - const {graph, node, nodeIndex, nodeClassName, nodeStyle, - onMouseEnterNode, onMouseLeaveNode, onMouseMoveNode, onMouseDownNode, onMouseUpNode, onClickNode +const SankeyNode = props => { + const { + graph, + node, + nodeClassName, + nodeStyle, + onMouseEnterNode, + onMouseLeaveNode, + onMouseMoveNode, + onMouseDownNode, + onMouseUpNode, + onClickNode } = props; - // create partial functions for handlers - callbacks with the current graph/node/nodeIndex arguments attached - const makeHandler = (origHandler) => - (_.isFunction(origHandler) ? _.partial(origHandler, _, {graph, node, nodeIndex}) : null); - - return ; + // create partial functions for handlers - callbacks with the current node/graph arguments attached + const makeHandler = origHandler => (_.isFunction(origHandler) ? _.partial(origHandler, _, {node, graph}) : null); + + return ( + + ); }; -const SankeyLink = (props) => { +const SankeyLink = props => { const { - graph, link, linkIndex, linkPath, linkClassName, linkStyle, - onMouseEnterLink, onMouseLeaveLink, onMouseMoveLink, onMouseDownLink, onMouseUpLink, onClickLink + graph, + link, + linkPath, + linkClassName, + linkStyle, + onMouseEnterLink, + onMouseLeaveLink, + onMouseMoveLink, + onMouseDownLink, + onMouseUpLink, + onClickLink } = props; - // create partial functions for handlers - callbacks with the current graph/link/linkIndex arguments attached - const makeHandler = (origHandler) => - (_.isFunction(origHandler) ? _.partial(origHandler, _, {graph, link, linkIndex}) : null); - - return + // create partial functions for handlers - callbacks with the current graph/link arguments attached + const makeHandler = origHandler => (_.isFunction(origHandler) ? _.partial(origHandler, _, {link, graph}) : null); + + return ( + + ); }; -const SankeyNodeLabel = (props) => { - const {node, nodeLabelText} = props; - return - {nodeLabelText(node)} - +const SankeyNodeTerminal = props => { + const {node, graph} = props; + const getWithNode = accessor => getValue(accessor, node, graph, props); + const width = getWithNode(props.nodeTerminalWidth) || 0; + const distance = getWithNode(props.nodeTerminalDistance) || 0; + const nodeHeight = Math.abs(node.y1 - node.y0) || 0; + const height = (((nodeHeight * node.terminalValue) || 0) / (node.value || 0)) || 0; + const style = getWithNode(props.nodeTerminalStyle); + const className = `sankey-node-terminal ${getWithNode(props.nodeTerminalClassName)}`; + const attributes = getWithNode(props.nodeTerminalAttributes); + + return ( + + ); }; +const SankeyNodeLabel = props => { + const { + node, + graph, + nodeLabelText, + nodeId, + nodeLabelPlacement, + nodeLabelDistance, + nodeLabelClassName, + nodeLabelStyle + } = props; + const getWithNode = accessor => getValue(accessor, node, graph); + const getLabelText = _.isFunction(nodeLabelText) ? nodeLabelText : nodeId; + const placement = getWithNode(nodeLabelPlacement); + const distance = getWithNode(nodeLabelDistance) || 0; + + let style = {...getWithNode(nodeLabelStyle)}; + let textPosition; + + // use placement prop to determine x, y, alignmentBaseline and + if (placement === "above") { + style = {alignmentBaseline: "baseline", textAnchor: "middle", ...style}; + textPosition = { + x: node.x0 + Math.abs(node.x1 - node.x0) / 2, + y: node.y0 - distance + }; + } else if (placement === "below") { + style = {alignmentBaseline: "hanging", textAnchor: "middle", ...style}; + textPosition = { + x: node.x0 + Math.abs(node.x1 - node.x0) / 2, + y: node.y1 + distance + }; + } else if (placement === "before") { + style = {alignmentBaseline: "middle", textAnchor: "end", ...style}; + textPosition = { + x: node.x0 - distance, + y: node.y0 + Math.abs(node.y1 - node.y0) / 2 + }; + } else { + if (!_.isUndefined(placement) && placement !== "after") + console.warn(`${placement} is not a valid value for nodeLabelPlacement - defaulting to "after"`); + style = {alignmentBaseline: "middle", textAnchor: "start", ...style}; + textPosition = { + x: node.x1 + distance, + y: node.y0 + Math.abs(node.y1 - node.y0) / 2 + }; + } -const SankeyLinkLabel = (props) => { - const {link, linkIndex, linkPath, linkClassName, linkStyle, nodeId} = props; - - return - - - - - - {Math.round(link.value)} to {link.target.name} - + return ( + + {getLabelText(node, graph)} - ; + ); +}; + +const SankeyLinkLabel = props => { + const {link, linkPath, nodeId, linkLabelText, linkLabelClassName, linkLabelStyle} = props; + const className = getValue(linkLabelClassName, link, props.graph, props); + const style = getValue(linkLabelStyle, link, props.graph, props); + + return ( + + + + + + + {getValue(linkLabelText, link, props.graph, props)} + + + + ); }; /** * A Sankey diagram is a type of flow diagram which represents */ - export default class SankeyDiagram extends React.Component { static propTypes = { /** @@ -114,11 +203,13 @@ export default class SankeyDiagram extends React.Component { * Each should have a 'source' node [identifier], a 'target' node [identifier], * and a numerical value representing flow magnitude. */ - links: PropTypes.arrayOf(PropTypes.shape({ - source: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - target: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - value: PropTypes.number - })).isRequired, + links: PropTypes.arrayOf( + PropTypes.shape({ + source: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + target: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + value: PropTypes.number + }) + ).isRequired, /** * Width of the SVG element. */ @@ -137,25 +228,32 @@ export default class SankeyDiagram extends React.Component { style: PropTypes.object, /** - * Accessor function `nodeId(node, nodeIndex)` which specifies how to access the ID of each node object. - * These should be the same identifiers used by `links[].source` and `.target`. - * Uses the node's index in `nodes` array by default. + * Boolean which determines if node rectangles should be shown, + * or function (`showNode(node, graph)`) which returns a boolean */ - nodeId: PropTypes.func, - - nodeLabelText: PropTypes.func, - // nodeLabelPlacement - // nodeLabelDistance - // showLinkInLabels - // showLinkOutLabels - // className - // style + showNodes: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + /** + * Boolean which determines if link paths should be shown, + * or function (`showLink(link, graph)`) which returns a boolean + */ + showLinks: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + /** + * Boolean which determines if node labels should be shown, + * or function (`showLink(link, graph)`) which returns a boolean + */ + showNodeLabels: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + /** + * Boolean which determines if node labels should be shown, + * or function (`showLink(link, graph)`) which returns a boolean + */ + showLinkLabels: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), /** - * Boolean which determines if node rectangles should be shown, - * or function (`showNode(node, nodeIndex)`) which returns a boolean + * Accessor function `nodeId(node, graph)` which specifies how to access the ID of each node object. + * These should be the same identifiers used by `links[].source` and `.target`. + * Uses the node's index in `nodes` array by default. */ - showNodes: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + nodeId: PropTypes.func, /** * Width (in pixels) of the vertical node lines. */ @@ -169,7 +267,7 @@ export default class SankeyDiagram extends React.Component { * May be 'left', 'right', 'center', 'justify', or a custom function. * See [d3-sankey alignment docs](https://github.com/d3/d3-sankey#alignments) for more details. */ - nodeAlignment: PropTypes.oneOf(['left', 'right', 'center', 'justify']), + nodeAlignment: PropTypes.oneOf(["left", "right", "center", "justify"]), /** * `className` attribute to be applied to each node, * or accessor function which returns a class (string). @@ -181,11 +279,6 @@ export default class SankeyDiagram extends React.Component { */ nodeStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - /** - * Boolean which determines if link paths should be shown, - * or function (`showLink(link, linkIndex)`) which returns a boolean - */ - showLinks: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), /** * Class attribute to be applied to each link, * or accessor function which returns a class (string). @@ -198,16 +291,56 @@ export default class SankeyDiagram extends React.Component { linkStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), /** - * Boolean which determines if node labels should be shown, - * or function (`showLink(link, linkIndex)`) which returns a boolean + * Placement of the node label relative to the node rectangle. + * Expects 'before', 'after', 'above' or 'below', or a function which returns one of these. + * By default, labels in the left half of the diagram are placed 'after' and those in the right half 'before' */ - showNodeLabels: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + nodeLabelPlacement: PropTypes.oneOfType([PropTypes.oneOf(["before", "after", "above", "below"]), PropTypes.func]), + /** + * Distance (in pixels) between nodes and their labels, + * or accessor function `f(node, graph)` which returns a distance. + */ + nodeLabelDistance: PropTypes.oneOfType([PropTypes.number, PropTypes.func]), + /** + * Accessor function `nodeLabelText(node, graph)` which returns the text to be used for node labels. + */ + nodeLabelText: PropTypes.func, + /** + * `className` attribute to be applied to each node label, + * or accessor function which returns a class (string). + */ + nodeLabelClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + /** + * Inline style object to be applied to each node label, + * or accessor function which returns a style object. + */ + nodeLabelStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + // to test /** - * Boolean which determines if node labels should be shown, - * or function (`showLink(link, linkIndex)`) which returns a boolean + * Accessor function `f(link, graph)` which returns the text to be used for link labels. */ - showLinkLabels: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), + linkLabelText: PropTypes.func, + /** + * `className` attribute to be applied to each link label, + * or accessor function which returns a class (string). + */ + linkLabelClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + /** + * Inline style object to be applied to each link label, + * or accessor function which returns a style object. + */ + linkLabelStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), + + // showNodeTerminals + // nodeTerminalWidth + // nodeTerminalDistance + // nodeTerminalClassName + // nodeTerminalStyle + // nodeTerminalAttributes + + // showLinkInLabels + // showLinkOutLabels onMouseEnterNode: PropTypes.func, onMouseLeaveNode: PropTypes.func, @@ -221,26 +354,46 @@ export default class SankeyDiagram extends React.Component { onMouseMoveLink: PropTypes.func, onMouseDownLink: PropTypes.func, onMouseUpLink: PropTypes.func, - onClickLink: PropTypes.func, + onClickLink: PropTypes.func }; static defaultProps = { width: 400, height: 300, - className: '', + className: "", style: {}, nodeId: node => node.index, showNodes: true, nodeWidth: 12, nodePadding: 8, - nodeAlignment: 'justify', - nodeClassName: '', + nodeAlignment: "justify", + nodeClassName: "", nodeStyle: {}, showLinks: true, - linkClassName: '', + linkClassName: "", linkStyle: {}, showNodeLabels: true, + nodeLabelPlacement: (node, graph) => { + return node.depth < graph.maxDepth / 2 ? "after" : "before"; + }, + nodeLabelDistance: 4, nodeLabelText: node => node.name, - showLinkLabels: true + nodeLabelClassName: "", + nodeLabelStyle: {}, + showLinkLabels: true, + linkLabelText: (link, graph, props) => { + const valueRelative = (link.value || 0) / _.get(link, "source.value", 0); + if (!_.isFinite(valueRelative)) return ""; + const percentText = valueRelative < 0.001 ? "<0.1%" : numeral(valueRelative).format("0.[0]%"); + return `${percentText} to ${getValue(props.nodeLabelText, link.target, graph, props)}`; + }, + linkLabelClassName: "", + linkLabelStyle: {}, + showNodeTerminals: true, + nodeTerminalWidth: 6, + nodeTerminalDistance: 1, + nodeTerminalClassName: '', + nodeTerminalStyle: {}, + nodeTerminalAttributes: {rx: 2, ry: 2} }; componentWillMount() { @@ -254,54 +407,107 @@ export default class SankeyDiagram extends React.Component { } render() { - const {nodes, links, width, height, className, style, showNodes, showLinks, showNodeLabels, showLinkLabels, nodeId} = this.props; + const { + nodes, + links, + width, + height, + className, + style, + showNodes, + showLinks, + showNodeLabels, + showLinkLabels, + showNodeTerminals, + nodeId + } = this.props; - const graph = this._sankey({nodes, links}); + const graph = enhanceGraph(this._sankey({nodes, links})); const makeLinkPath = sankeyLinkHorizontal(); - // console.log('graph', graph); - - return - {showLinks ? - - {(graph.links || []).map((link, linkIndex) => { - if(!getValue(showLinks, link, linkIndex)) return null; - const key = `link-${nodeId(link.source)}-to-${nodeId(link.target)}`; - return - })} - - : null - } - {showNodes ? - - {graph.nodes.map((node, nodeIndex) => { - if(!getValue(showNodes, node, nodeIndex)) return null; - const key = `node-${nodeId(node)}`; - return - })} - - : null - } - {showLinkLabels ? - - {graph.links.map((link, linkIndex) => { - if(!getValue(showLinkLabels, link, linkIndex)) return null; - const key = `link-label-${nodeId(link.source)}-to-${nodeId(link.target)}`; - return - })} - - : null - } - {showNodeLabels ? - - {graph.nodes.map((node, nodeIndex) => { - if(!getValue(showNodeLabels, node, nodeIndex)) return null; - const key = `node-label-${nodeId(node)}`; - return - })} - - : null - } - ; + console.log(graph); + + function enhanceGraph(graph) { + graph.nodes.forEach(node => { + const sourceLinksSum = (node.sourceLinks || []).reduce((sum, link) => sum + link.value, 0); + node.terminalValue = Math.max(node.value - sourceLinksSum, 0); + }); + + graph.maxDepth = _.maxBy(graph.nodes, "depth"); + graph.maxDepth = graph.nodes.reduce((max, node) => Math.max(node.depth || 0, max), 0); + return graph; + } + + + return ( + + {showLinks ? ( + + {(graph.links || []).map(link => { + if (!getValue(showLinks, link, graph)) return null; + const key = `link-${nodeId(link.source)}-to-${nodeId(link.target)}`; + return ( + + ); + })} + + ) : null} + {showNodes ? ( + + {graph.nodes.map(node => { + if (!getValue(showNodes, node, graph)) return null; + const key = `node-${nodeId(node)}`; + return ; + })} + + ) : null} + {showNodeTerminals ? ( + + {graph.nodes.map(node => { + if (!getValue(showNodeTerminals, node, graph)) return null; + const key = `node-terminal-${nodeId(node)}`; + return ; + })} + + ) : null} + {showLinkLabels ? ( + + {graph.links.map(link => { + if (!getValue(showLinkLabels, link, graph)) return null; + const key = `link-label-${nodeId(link.source)}-to-${nodeId(link.target)}`; + const linkPath = makeLinkPath(link); + return ( + + ); + })} + + ) : null} + {showNodeLabels ? ( + + {graph.nodes.map(node => { + if (!getValue(showNodeLabels, node, graph)) return null; + const key = `node-label-${nodeId(node)}`; + return ; + })} + + ) : null} + + ); } } diff --git a/tests/jsdom/spec/SankeyDiagram.spec.js b/tests/jsdom/spec/SankeyDiagram.spec.js index 0548141d..18ae52d9 100644 --- a/tests/jsdom/spec/SankeyDiagram.spec.js +++ b/tests/jsdom/spec/SankeyDiagram.spec.js @@ -14,6 +14,8 @@ const Sankey = rewire('../../../src/SankeyDiagram'); const SankeyDiagram = Sankey.default; const SankeyNode = Sankey.__get__('SankeyNode'); const SankeyLink = Sankey.__get__('SankeyLink'); +const SankeyNodeLabel = Sankey.__get__('SankeyNodeLabel'); +const SankeyLinkLabel = Sankey.__get__('SankeyLinkLabel'); describe('SankeyDiagram', () => { it('renders a Sankey Diagram', () => { @@ -29,13 +31,11 @@ describe('SankeyDiagram', () => { const sankeyNodes = chart.find(SankeyNode); const sankeyLinks = chart.find(SankeyLink); expect(sankeyNodes).to.have.length(5); - expect(sankeyLinks).to.have.length(5); + expect(sankeyLinks).to.have.length(6); sankeyNodes.forEach((node, i) => { const nodeProps = node.props(); expect(nodeProps.graph).to.be.an('object'); - expect(nodeProps.nodeIndex).to.be.finite; - expect(nodeProps.nodeIndex).to.equal(i); expect(nodeProps.node).to.be.an('object'); expect(nodeProps.node.index).to.be.finite; expect(nodeProps.node.index).to.equal(i); @@ -63,8 +63,6 @@ describe('SankeyDiagram', () => { sankeyLinks.forEach((link, i) => { const linkProps = link.props(); expect(linkProps.graph).to.be.an('object'); - expect(linkProps.linkIndex).to.be.finite; - expect(linkProps.linkIndex).to.equal(i); expect(linkProps.linkPath).to.be.a('string'); expect(linkProps.linkPath.length).to.be.above(2); expect(linkProps.linkPath).to.contain('M'); @@ -79,6 +77,7 @@ describe('SankeyDiagram', () => { expect(linkProps.link.value).to.be.finite; expect(linkProps.link.value).to.equal(sampleData.links[i].value); expect(linkProps.link.width).to.be.finite; + expect(linkProps.link.width).not.to.equal(0); }); }); @@ -99,10 +98,260 @@ describe('SankeyDiagram', () => { expect(svg.props().style.paddingLeft).to.equal(30); }); + it('uses nodeId accessor prop to determine node IDs', () => { + const props = { + width: 600, height: 400, + ...getSampleDataWithId(), + nodeId: (node, graph) => node.id + }; + const chart = mount(); + const svg = chart.find('svg'); + expect(svg).to.have.length(1); + + // get sampleData again since it has been mutated by the component + const sampleData = getSampleDataWithId(); + const sankeyNodes = chart.find(SankeyNode); + const sankeyLinks = chart.find(SankeyLink); + expect(sankeyNodes).to.have.length(5); + expect(sankeyLinks).to.have.length(6); + + sankeyNodes.forEach((node, i) => { + const nodeProps = node.props(); + expect(nodeProps.node).to.be.an('object'); + const sourceLinks = sampleData.links.filter(link => link.source === nodeProps.node.id); + const targetLinks = sampleData.links.filter(link => link.target === nodeProps.node.id); + expect(nodeProps.node.sourceLinks).to.be.an('array'); + expect(nodeProps.node.sourceLinks).to.have.length(sourceLinks.length); + expect(nodeProps.node.targetLinks).to.be.an('array'); + expect(nodeProps.node.targetLinks).to.have.length(targetLinks.length); + const expectedNodeValue = Math.max(_.sumBy(sourceLinks, l => l.value), _.sumBy(targetLinks, l => l.value)); + expect(nodeProps.node.value).to.equal(expectedNodeValue); + expect(nodeProps.node.x0).to.be.finite; + expect(nodeProps.node.x1).to.be.finite; + expect(nodeProps.node.x0).not.to.equal(nodeProps.node.x1); + expect(nodeProps.node.y0).to.be.finite; + expect(nodeProps.node.y1).to.be.finite; + expect(nodeProps.node.y0).not.to.equal(nodeProps.node.y1); + }); + expect(sankeyNodes.at(0).props().node.depth).to.equal(0); + expect(sankeyNodes.at(2).props().node.depth).to.equal(1); + expect(sankeyNodes.at(4).props().node.depth).to.equal(2); + + sankeyLinks.forEach((link, i) => { + const linkProps = link.props(); + expect(linkProps.graph).to.be.an('object'); + expect(linkProps.linkPath).to.be.a('string'); + expect(linkProps.linkPath.length).to.be.above(2); + expect(linkProps.linkPath).to.contain('M'); + expect(linkProps.linkPath).to.contain('C'); + expect(linkProps.link).to.be.an('object'); + expect(linkProps.link.source).to.be.an('object'); + expect(linkProps.link.source.id).to.equal(sampleData.links[i].source); + expect(linkProps.link.target).to.be.an('object'); + expect(linkProps.link.target.id).to.equal(sampleData.links[i].target); + expect(linkProps.link.value).to.be.finite; + expect(linkProps.link.value).to.equal(sampleData.links[i].value); + expect(linkProps.link.width).to.be.finite; + expect(linkProps.link.width).not.to.equal(0); + }); + }); + + it('uses showNodes boolean or accessor prop to determine whether to render nodes', () => { + const size = {width: 600, height: 400}; + const showNodesProps = { + ...size, ...getSampleData(), + showNodes: true + }; + const showNodesChart = mount(); + expect(showNodesChart.find(SankeyNode)).to.have.length(5); + + const hideNodesProps = { + ...size, ...getSampleData(), + showNodes: false + }; + const hideNodesChart = mount(); + expect(hideNodesChart.find(SankeyNode)).to.have.length(0); + + const showSomeNodesProps = { + ...size, ...getSampleData(), + showNodes: (node) => node.index < 3 + }; + const showSomeNodesChart = mount(); + expect(showSomeNodesChart.find(SankeyNode)).to.have.length(3); + }); + + it('uses showLinks boolean or accessor prop to determine whether to render links', () => { + const size = {width: 600, height: 400}; + const showLinksProps = { + ...size, ...getSampleData(), + showLinks: true + }; + const showLinksChart = mount(); + expect(showLinksChart.find(SankeyLink)).to.have.length(6); + + const hideLinksProps = { + ...size, ...getSampleData(), + showLinks: false + }; + const hideLinksChart = mount(); + expect(hideLinksChart.find(SankeyLink)).to.have.length(0); + + const showSomeLinksProps = { + ...size, ...getSampleData(), + showLinks: (link, graph) => link.target.index === 2 + }; + const showSomeLinksChart = mount(); + expect(showSomeLinksChart.find(SankeyLink)).to.have.length(2); + }); + + it('uses nodeWidth prop to control the width of the node rectangles', () => { + const props = { + ...getSampleData(), width: 600, height: 400, + nodeWidth: 19 + }; + const chart = mount(); + const sankeyNodes = chart.find(SankeyNode); + expect(sankeyNodes).to.have.length(5); + + sankeyNodes.forEach((node, i) => { + const nodeProps = node.props(); + expect(nodeProps.node).to.be.an('object'); + expect(nodeProps.node.x0).to.be.finite; + expect(nodeProps.node.x1).to.be.finite; + expect(nodeProps.node.x1 - nodeProps.node.x0).to.equal(19); + expect(nodeProps.node.y0).to.be.finite; + expect(nodeProps.node.y1).to.be.finite; + expect(nodeProps.node.y0).not.to.equal(nodeProps.node.y1); + }); + }); + + it('uses nodePadding to control vertical space between node rectangles', () => { + const props = { + ...getSampleData(), width: 600, height: 400, + nodePadding: 37 + }; + const chart = mount(); + const sankeyNodes = chart.find(SankeyNode); + expect(sankeyNodes).to.have.length(5); + expect(sankeyNodes.at(1).props().node.y0 - sankeyNodes.at(0).props().node.y1).to.equal(37); + expect(sankeyNodes.at(3).props().node.y0 - sankeyNodes.at(2).props().node.y1).to.equal(37); + }); + + // todo: test nodeAlignment? how? + + it('passes nodeClassName, nodeStyle and node mouse event handlers through to nodes', () => { + const props = { + ...getSampleData(), width: 600, height: 400, + nodeClassName: 'doggo', + nodeStyle: {fill: 'orange'}, + onMouseEnterNode: sinon.spy(), + onMouseLeaveNode: sinon.spy(), + onMouseMoveNode: sinon.spy(), + onMouseDownNode: sinon.spy(), + onMouseUpNode: sinon.spy(), + onClickNode: sinon.spy() + }; + const chart = mount(); + const sankeyNodes = chart.find(SankeyNode); + expect(sankeyNodes).to.have.length(5); + + sankeyNodes.forEach((node, i) => { + const nodeProps = node.props(); + expect(nodeProps.nodeClassName).to.equal('doggo'); + expect(nodeProps.nodeStyle).to.be.an('object'); + expect(nodeProps.nodeStyle.fill).to.equal('orange'); + expect(nodeProps.onMouseEnterNode).to.equal(props.onMouseEnterNode); + expect(nodeProps.onMouseLeaveNode).to.equal(props.onMouseLeaveNode); + expect(nodeProps.onMouseMoveNode).to.equal(props.onMouseMoveNode); + expect(nodeProps.onMouseDownNode).to.equal(props.onMouseDownNode); + expect(nodeProps.onMouseUpNode).to.equal(props.onMouseUpNode); + expect(nodeProps.onClickNode).to.equal(props.onClickNode); + }); + }); + + it('passes linkClassName, linkStyle and link mouse event handlers through to nodes', () => { + const props = { + ...getSampleData(), width: 600, height: 400, + linkClassName: 'kitten', + linkStyle: {fill: 'tomato'}, + onMouseEnterLink: sinon.spy(), + onMouseLeaveLink: sinon.spy(), + onMouseMoveLink: sinon.spy(), + onMouseDownLink: sinon.spy(), + onMouseUpLink: sinon.spy(), + onClickLink: sinon.spy() + }; + const chart = mount(); + const sankeyLinks = chart.find(SankeyLink); + expect(sankeyLinks).to.have.length(6); + + sankeyLinks.forEach((link, i) => { + const linkProps = link.props(); + expect(linkProps.linkClassName).to.equal('kitten'); + expect(linkProps.linkStyle).to.be.an('object'); + expect(linkProps.linkStyle.fill).to.equal('tomato'); + expect(linkProps.onMouseEnterLink).to.equal(props.onMouseEnterLink); + expect(linkProps.onMouseLeaveLink).to.equal(props.onMouseLeaveLink); + expect(linkProps.onMouseMoveLink).to.equal(props.onMouseMoveLink); + expect(linkProps.onMouseDownLink).to.equal(props.onMouseDownLink); + expect(linkProps.onMouseUpLink).to.equal(props.onMouseUpLink); + expect(linkProps.onClickLink).to.equal(props.onClickLink); + }); + }); + + it('uses showNodeLabels boolean or accessor prop to determine whether to render node labels', () => { + const size = {width: 600, height: 400}; + const showNodeLabelsProps = { + ...size, ...getSampleData(), + showNodeLabels: true + }; + const showNodeLabelsChart = mount(); + expect(showNodeLabelsChart.find(SankeyNodeLabel)).to.have.length(5); + + const hideNodeLabelsProps = { + ...size, ...getSampleData(), + showNodeLabels: false + }; + const hideNodeLabelsChart = mount(); + expect(hideNodeLabelsChart.find(SankeyNodeLabel)).to.have.length(0); + + const showSomeNodeLabelsProps = { + ...size, ...getSampleData(), + showNodeLabels: (node) => node.index < 3 + }; + const showSomeNodeLabelsChart = mount(); + expect(showSomeNodeLabelsChart.find(SankeyNodeLabel)).to.have.length(3); + }); + + it('uses showLinkLabels boolean or accessor prop to determine whether to render link labels', () => { + const size = {width: 600, height: 400}; + const showLinkLabelsProps = { + ...size, ...getSampleData(), + showLinkLabels: true + }; + const showLinkLabelsChart = mount(); + expect(showLinkLabelsChart.find(SankeyLinkLabel)).to.have.length(6); + + const hideLinkLabelsProps = { + ...size, ...getSampleData(), + showLinkLabels: false + }; + const hideLinkLabelsChart = mount(); + expect(hideLinkLabelsChart.find(SankeyLinkLabel)).to.have.length(0); + + const showSomeLinkLabelsProps = { + ...size, ...getSampleData(), + showLinkLabels: (link, graph) => link.target.index === 2 + }; + const showSomeLinkLabelsChart = mount(); + expect(showSomeLinkLabelsChart.find(SankeyLinkLabel)).to.have.length(2); + }); + describe('SankeyNode', () => { const basicNodeObj = { + index: 5, x0: 30, x1: 50, - y0: 25, y1: 100 + y0: 25, y1: 100, }; it('renders a rectangle with the position & size of the current node', () => { const node = mount(); @@ -125,9 +374,9 @@ describe('SankeyDiagram', () => { expect(rect.props().style.fill).to.equal('coral'); }); it('calls nodeClassName & nodeStyle to get class & style, if they are functions', () => { - const className = (node, nodeIndex) => `i-${nodeIndex}-x0-${node.x0}`; - const style = (node, nodeIndex) => ({strokeWidth: `${node.x1}px`}); - const nodeProps = {node: basicNodeObj, nodeIndex: 5, nodeClassName: className, nodeStyle: style}; + const className = (node, graph) => `i-${node.index}-x0-${node.x0}`; + const style = (node, graph) => ({strokeWidth: `${node.x1}px`}); + const nodeProps = {node: basicNodeObj, nodeClassName: className, nodeStyle: style}; const node = mount(); const rect = node.find('rect'); expect(rect.props().className).to.contain('i-5-x0-30'); @@ -137,7 +386,6 @@ describe('SankeyDiagram', () => { it('attaches mouse event handlers (enter, leave, move, down, up, click) to the node rectangle', () => { const nodeProps = { node: basicNodeObj, - nodeIndex: 3, graph: {nodes: [], links: []}, onMouseEnterNode: sinon.spy(), onMouseLeaveNode: sinon.spy(), @@ -175,7 +423,7 @@ describe('SankeyDiagram', () => { rect.simulate('click'); expect(nodeProps.onClickNode).to.have.been.called; - // make sure callbacks are called with (event, {link, linkIndex, graph}) + // make sure callbacks are called with (event, {link, graph}) expect(nodeProps.onClickNode.args[0]).to.have.length(2); const eventArg = nodeProps.onClickNode.args[0][0]; const infoArg = nodeProps.onClickNode.args[0][1]; @@ -184,7 +432,6 @@ describe('SankeyDiagram', () => { expect(eventArg.target).to.be.an('object'); expect(infoArg).to.be.an('object'); expect(infoArg.node).to.equal(basicNodeObj); - expect(infoArg.nodeIndex).to.equal(nodeProps.nodeIndex); expect(infoArg.graph).to.equal(nodeProps.graph); }); }); @@ -210,19 +457,18 @@ describe('SankeyDiagram', () => { expect(path.props().style.fill).to.equal('thistle'); }); it('calls linkClassName & linkStyle to get class & style, if they are functions', () => { - const linkClassName = (link, linkIndex) => `i-${linkIndex}-w-${link.width}`; - const linkStyle = (link, linkIndex) => ({borderWidth: link.width}); - const linkProps = {link: linkObj, linkIndex: 6, linkClassName, linkStyle}; + const linkClassName = (link, graph) => `w-${link.width}`; + const linkStyle = (link, graph) => ({borderWidth: link.width}); + const linkProps = {link: linkObj, linkClassName, linkStyle}; const link = mount(); const path = link.find('path'); - expect(path.props().className).to.contain('i-6-w-20'); + expect(path.props().className).to.contain('w-20'); expect(path.props().style).to.be.an('object'); expect(path.props().style.borderWidth).to.equal(20); }); it('attaches mouse event handlers (enter, leave, move, down, up, click) to the link path', () => { const linkProps = { link: linkObj, - linkIndex: 9, graph: {nodes: [], links: []}, onMouseEnterLink: sinon.spy(), onMouseLeaveLink: sinon.spy(), @@ -260,7 +506,7 @@ describe('SankeyDiagram', () => { path.simulate('click'); expect(linkProps.onClickLink).to.have.been.called; - // make sure callbacks are called with (event, {link, linkIndex, graph}) + // make sure callbacks are called with (event, {link, graph}) expect(linkProps.onClickLink.args[0]).to.have.length(2); const eventArg = linkProps.onClickLink.args[0][0]; const infoArg = linkProps.onClickLink.args[0][1]; @@ -269,10 +515,135 @@ describe('SankeyDiagram', () => { expect(eventArg.target).to.be.an('object'); expect(infoArg).to.be.an('object'); expect(infoArg.link).to.equal(linkObj); - expect(infoArg.linkIndex).to.equal(linkProps.linkIndex); expect(infoArg.graph).to.equal(linkProps.graph); }) }); + + describe('SankeyNodeLabel', () => { + const basicNodeObj = { + x0: 30, x1: 50, + y0: 40, y1: 100, + id: 'lemons', + name: 'Sour Lemons' + }; + it('renders a node label', () => { + const label = mount( "ok"}} />); + const text = label.find('text'); + expect(text).to.have.length(1); + expect(text.props().x).to.be.finite; + expect(text.props().y).to.be.finite; + }); + it('uses nodeLabelText accessor prop to create label text, falls back to nodeId if nodeLabelText not provided', () => { + const labelWithName = mount( node.name, + nodeId: (node) => node.id + }} />); + const textWithName = labelWithName.find('text'); + expect(textWithName).to.have.length(1); + expect(textWithName.text()).to.equal('Sour Lemons'); + + const labelWithId = mount( node.id + }} />); + const textWithId = labelWithId.find('text'); + expect(textWithId).to.have.length(1); + expect(textWithId.text()).to.equal('lemons'); + }); + it('passes nodeLabelClassName and nodeLabelStyle through to the text element', () => { + const nodeLabelClassName = 'my-fun-node-label'; + const nodeLabelStyle = {fill: 'salmon'}; + const nodeLabelText = (node) => node.name; + const label = mount(); + const text = label.find('text'); + expect(text).to.have.length(1); + expect(text.props().className).to.contain('my-fun-node-label'); + expect(text.props().style).to.be.an('object'); + expect(text.props().style.fill).to.equal('salmon'); + }); + it('calls nodeLabelClassName & nodeLabelStyle to get class & style, if they are functions', () => { + const nodeLabelClassName = (node) => `node-label-${node.id}`; + const nodeLabelStyle = (node) => ({fill: node.id === 'lemons' ? 'orange' : 'purple'}); + const nodeLabelText = (node) => node.name; + const label = mount(); + const text = label.find('text'); + expect(text).to.have.length(1); + expect(text.props().className).to.contain('node-label-lemons'); + expect(text.props().style).to.be.an('object'); + expect(text.props().style.fill).to.equal('orange'); + }); + it('uses nodeLabelPlacement to determine the label\'s position', () => { + const nodeLabelText = (node) => node.name; + const commonProps = {node: basicNodeObj, nodeLabelText}; + + const labelBefore = mount(); + const labelBeforeText = labelBefore.find('text'); + expect(labelBeforeText.props().x).to.be.at.most(30); + expect(labelBeforeText.props().y).to.equal(70); + expect(labelBeforeText.props().style.alignmentBaseline).to.equal('middle'); + expect(labelBeforeText.props().style.textAnchor).to.equal('end'); + + const labelAfter = mount(); + const labelAfterText = labelAfter.find('text'); + expect(labelAfterText.props().x).to.be.at.least(50); + expect(labelAfterText.props().y).to.equal(70); + expect(labelAfterText.props().style.alignmentBaseline).to.equal('middle'); + expect(labelAfterText.props().style.textAnchor).to.equal('start'); + + const labelAbove = mount(); + const labelAboveText = labelAbove.find('text'); + expect(labelAboveText.props().x).to.equal(40); + expect(labelAboveText.props().y).to.be.at.most(40); + expect(labelAboveText.props().style.alignmentBaseline).to.equal('baseline'); + expect(labelAboveText.props().style.textAnchor).to.equal('middle'); + + const labelBelow = mount(); + const labelBelowText = labelBelow.find('text'); + expect(labelBelowText.props().x).to.equal(40); + expect(labelBelowText.props().y).to.be.at.least(100); + expect(labelBelowText.props().style.alignmentBaseline).to.equal('hanging'); + expect(labelBelowText.props().style.textAnchor).to.equal('middle'); + + const conditionalPlacement = (node) => (node.id === 'lemons') ? 'below' : 'above'; + const labelConditional = mount(); + // should resolve to 'below', so use same tests as 'below' + const labelConditionalText = labelConditional.find('text'); + expect(labelConditionalText.props().x).to.equal(40); + expect(labelConditionalText.props().y).to.be.at.least(100); + expect(labelConditionalText.props().style.alignmentBaseline).to.equal('hanging'); + expect(labelConditionalText.props().style.textAnchor).to.equal('middle'); + }); + it('uses nodeLabelDistance to determine node label\'s distance from the node', () => { + const nodeLabelText = (node) => node.name; + const commonProps = {node: basicNodeObj, nodeLabelText, nodeLabelDistance: 9}; + + const labelBefore = mount(); + const labelBeforeText = labelBefore.find('text'); + expect(labelBeforeText.props().x).to.equal(21); + + const labelAfter = mount(); + const labelAfterText = labelAfter.find('text'); + expect(labelAfterText.props().x).to.equal(59); + + const labelAbove = mount(); + const labelAboveText = labelAbove.find('text'); + expect(labelAboveText.props().y).to.equal(31); + + const labelBelow = mount(); + const labelBelowText = labelBelow.find('text'); + expect(labelBelowText.props().y).to.equal(109); + + const getDistance = (node) => 7; + + const labelDynamic = mount(); + const labelDynamicText = labelDynamic.find('text'); + expect(labelDynamicText.props().y).to.equal(107); + }); + }); + // todo SankeyLinkLabel }); function getSampleData() { @@ -289,7 +660,8 @@ function getSampleData() { {source: 0, target: 3, value: 0.5}, {source: 1, target: 2, value: 0.5}, {source: 1, target: 3, value: 0.5}, - {source: 2, target: 4, value: 1} + {source: 2, target: 4, value: 1}, + {source: 3, target: 4, value: 1} ] } } @@ -309,6 +681,7 @@ function getSampleDataWithId() { {source: 'b', target: 'c', value: 0.5}, {source: 'b', target: 'd', value: 0.5}, {source: 'c', target: 'e', value: 1}, + {source: 'd', target: 'e', value: 1} ] } }