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 ;
+ 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 (
+
+ );
}
}
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}
]
}
}