From 2039fa3be78f58e1b2c34433cf3936f8ff02f53b Mon Sep 17 00:00:00 2001 From: nxanil Date: Tue, 26 Sep 2017 15:34:16 +0530 Subject: [PATCH] Added Sankey graph --- docs/README.md | 42 +++ public/configurations/dashboards/example.json | 4 +- .../visualizations/sample-sankey-graph.json | 18 ++ .../Graphs/SankeyGraph/default.config.js | 21 ++ src/components/Graphs/SankeyGraph/index.js | 306 ++++++++++++++++++ src/components/Graphs/SankeyGraph/sankey.js | 296 +++++++++++++++++ src/components/Graphs/SankeyGraph/style.css | 20 ++ src/components/Graphs/index.js | 4 +- 8 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 public/configurations/visualizations/sample-sankey-graph.json create mode 100644 src/components/Graphs/SankeyGraph/default.config.js create mode 100644 src/components/Graphs/SankeyGraph/index.js create mode 100644 src/components/Graphs/SankeyGraph/sankey.js create mode 100644 src/components/Graphs/SankeyGraph/style.css diff --git a/docs/README.md b/docs/README.md index 6ec24b9b..42285f32 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ The philosophy of the Visualization Framework is to provide a library to quickly - [ChordGraph](#chordgraph) - [SimpleTextGraph](#simpletextgraph) - [VariationTextGraph](#variationtextgraph) + - [SankeyGraph](#sankeygraph) - [Query configuration](#query-configuration) - [Services](#services) @@ -344,6 +345,47 @@ This graph shows a value and its variation from the previous one. - **fontSize** font size - **fontColor** font color +##### SankeyGraph + +Display nice Sankey graph + +![sankey-chart](https://user-images.githubusercontent.com/26645756/30849031-9da964a0-a2be-11e7-8689-95fa95d17015.png) + +This graph shows a value between source and destination columns. + +- **sourceColumn** source column +- **targetColumn** target column +- **valueColumn** value between source and target column. + +*Note:- Data should not be cyclic.* + +#### Sample Data +```javascript + +const data = [ + { + "source": "Barry", + "target": "Elvis", + "value": 2 + }, + { + "source": "Frodo", + "target": "Elvis", + "value": 2 + }, + { + "source": "Frodo", + "target": "Sarah", + "value": 2 + }, + { + "source": "Barry", + "target": "Alice", + "value": 2 + } +]; +``` + ### Query configuration The query configuration allows the Visualization to know which [service](#services) it should use and what is the query it should trigger. diff --git a/public/configurations/dashboards/example.json b/public/configurations/dashboards/example.json index c5464a50..29b40f36 100644 --- a/public/configurations/dashboards/example.json +++ b/public/configurations/dashboards/example.json @@ -5,6 +5,8 @@ "title": "Example", "visualizations": [ { "id": "vsd-from-nsgs-table", "x": 0, "y": 0, "w": 6, "h": 12, "minW": 2, "minH": 12, "static": true}, - { "id": "vsd-to-nsgs-table", "x": 6, "y": 0, "w": 6, "h": 12, "minW": 2, "minH": 12, "static": true} + { "id": "vsd-to-nsgs-table", "x": 6, "y": 0, "w": 6, "h": 12, "minW": 2, "minH": 12, "static": true}, + { "id": "sample-sankey-graph", "x": 0, "y": 12, "w": 12, "h": 20, "minW": 6, "minH": 12} + ] } diff --git a/public/configurations/visualizations/sample-sankey-graph.json b/public/configurations/visualizations/sample-sankey-graph.json new file mode 100644 index 00000000..f31dc602 --- /dev/null +++ b/public/configurations/visualizations/sample-sankey-graph.json @@ -0,0 +1,18 @@ +{ + "id": "sample-sankey-graph", + "graph": "SankeyGraph", + "title": "Sample Sankey Graph", + "author": "Anil Chauhan", + "creationDate": "25/09/2017", + "data": { + "dateHistogram": true, + "sourceColumn": "source", + "sourceColumnLabel": "Source", + "targetColumn": "target", + "targetColumnLabel": "Target", + "valueColumn": "value", + "valueColumnLabel": "Value", + "valueFormat": ",.1f" + }, + "query": "top5-app-vertical-bar" +} diff --git a/src/components/Graphs/SankeyGraph/default.config.js b/src/components/Graphs/SankeyGraph/default.config.js new file mode 100644 index 00000000..85b10607 --- /dev/null +++ b/src/components/Graphs/SankeyGraph/default.config.js @@ -0,0 +1,21 @@ +import { theme } from "../../../theme"; + +export const properties = { + stroke: { + color: "red", + width: "2px", + opacity: "0.4" + }, + colors: [ + theme.palette.orangeLightColor, + theme.palette.blueLightColor, + theme.palette.pinkLightColor, + theme.palette.orangeLighterColor, + theme.palette.greenColor, + theme.palette.yellowLightColor, + theme.palette.yellowDarkColor, + ], + nodeWidth: 20, + nodePadding: 20, + layout : 30 +} diff --git a/src/components/Graphs/SankeyGraph/index.js b/src/components/Graphs/SankeyGraph/index.js new file mode 100644 index 00000000..7987fb82 --- /dev/null +++ b/src/components/Graphs/SankeyGraph/index.js @@ -0,0 +1,306 @@ +import React from "react"; +import AbstractGraph from "../AbstractGraph"; +import * as d3 from "d3"; +import "./sankey.js" +import "./style.css"; +import {properties} from "./default.config"; +import { CardOverlay } from "../../CardOverlay"; + +class SankeyGraph extends AbstractGraph { + + constructor(props) { + super(props, properties); + this.sankey = d3.sankey(); + this.filterDatas = {}; + } + + componentWillMount() { + this.initiate(this.props); + } + + componentWillReceiveProps(nextProps) { + if(this.props !== nextProps) { + this.initiate(nextProps); + } + } + + componentDidMount() { + this.createSankeyChart() + } + + componentDidUpdate() { + this.createSankeyChart() + } + + // get sankey chart object. + getSankey() { + return this.sankey; + } + + //get formatted nodes data. + getNodes() { + return this.filterDatas.nodes; + } + + // get formatted links data. + getLinks() { + return this.filterDatas.links; + } + + // get parent node (svg). + getParentNode() { + return this.node; + } + + // get formatted function to format value. + getFormat() { + const { + valueFormat + } = this.getConfiguredProperties(); + + return d => d3.format(valueFormat)(d); + } + + // function to set initial data to render sankey chart + initiate(props) { + const { + data + } = props; + + if (!data || !data.length) + return; + + + this.dataError = false; + if (this.checkData(data)) { + this.dataError = true; + } else { + this.parseData(props); + this.setProperties(props); + } + } + + // manipulate data in sankey chart format + parseData(props) { + const { + data + } = props; + + const { + sourceColumn, + targetColumn, + valueColumn + } = this.getConfiguredProperties(); + + let nodes = [], links = []; + + data.forEach((d) => { + nodes.push(...[d[sourceColumn], d[targetColumn]]); + links.push({ + "source": d[sourceColumn], + "target": d[targetColumn], + "value": +d[valueColumn] } + ); + }); + + nodes = nodes.filter( (item, i, ar) => ar.indexOf(item) === i ); + + // loop through each link replacing the text with its index from node + links.forEach((d, i) => { + links[i][sourceColumn] = nodes.indexOf(links[i][sourceColumn]); + links[i][targetColumn] = nodes.indexOf(links[i][targetColumn]); + }); + + nodes = nodes.map( (d) => { + return { name: d }; + }); + + this.filterDatas = {"nodes" : nodes, "links" : links}; + } + + // set the data and properties of the sankey chart. + setProperties(props) { + const { + width, + height + } = props; + + const { + margin + } = this.getConfiguredProperties(); + + const + availableWidth = width - margin.left - margin.right, + availableHeight = height - margin.top - margin.bottom, + nodeWidth = (availableWidth/ this.getNodes().length) * 0.1; + + // Set the sankey diagram properties. + let sankey = this.getSankey(); + + sankey + .nodeWidth(nodeWidth) + .nodePadding(properties.nodePadding) + .size([availableWidth, availableHeight]); + + sankey + .nodes(this.getNodes()) + .links(this.getLinks()) + .layout(properties.layout); + } + + // Create sankey chart. + createSankeyChart() { + if(this.dataError) + return; + + // append the g object to the svg object of the page + var node = d3.select(this.getParentNode()); + + // Create links for chart + this.generateLinks(node); + + // Create path for chart + this.generateNodes(node); + } + + // Add links in chart. + generateLinks(parentLink) { + const { + valueColumnLabel + } = this.getConfiguredProperties(); + + // add in the links + const links = parentLink.select(".linkContainer").selectAll(".link") + .data(this.getLinks(), d => d.source.name + "-" + d.target.name ); + + const newLinks = links.enter().append("path") + .attr("class", "link"); + + newLinks.append("title"); + + const allLinks = newLinks.merge(links); + + allLinks + .style("stroke-width", d => Math.max(1, d.dy) ) + .sort( (a, b) => b.dy - a.dy ) + .attr("d", this.getSankey().link()) + + // add the link titles + allLinks.select("title") + .text((d) => + `${d.source.name} -> ${d.target.name} \n${valueColumnLabel} : ${this.getFormat()(d.value)}` + ) + + // Remove all remaining links + links.exit().remove(); + } + + // Add paths in chart. + generateNodes(parentNode) { + const { + width + } = this.props; + + const { + valueColumnLabel + } = this.getConfiguredProperties(); + + const color = d3.scaleOrdinal(properties.colors); + + // add in the nodes + var nodes = parentNode.select(".nodeContainer").selectAll(".node") + .data(this.getNodes(), d => d.name); + + const newNodes = nodes.enter().append("g") + .attr("class", "node"); + + newNodes.append("rect") + .style("fill", d => d.color = color(d.name.replace(/ .*/, ""))) + .style("stroke", d => d3.rgb(d.color).darker(2) ) + .append("title"); + + newNodes.append("text") + .attr("fill", "#423838"); + + const allNodes = newNodes.merge(nodes); + + allNodes.attr("transform", d => `translate(${d.x}, ${d.y})` ); + + // add the rectangles for the nodes + allNodes.select("rect") + .attr("height", d => d.dy ) + .attr("width", this.getSankey().nodeWidth()) + .select("title") + .text( d => + `${d.name} \n${valueColumnLabel} : ${this.getFormat()(d.value)}` ); + + // add in the title for the nodes + allNodes.select("text") + .attr("x", "-6") + .attr("y", d => d.dy / 2 ) + .text( d => d.name ) + .attr("text-anchor", "end") + .filter( d => d.x < width / 2 ) + .attr("x", 6 + this.getSankey().nodeWidth()) + .attr("text-anchor", "start"); + + // Remove all remaining nodes + nodes.exit().remove(); + } + + checkData(data) { + const { + sourceColumn, + targetColumn, + } = this.getConfiguredProperties(); + + let result = data.filter( (item, i, ar) => + ar.find( d => d[sourceColumn] === item[targetColumn] && d[targetColumn] === item[sourceColumn]) + ); + return result.length ? true : false; + } + + render() { + const { + data, + width, + height + } = this.props; + + const { + margin + } = this.getConfiguredProperties(); + + if (!data || !data.length) + return; + + if(this.dataError) + + return ( + + ); + + return ( +
+ + this.node = node}> + + + + +
+ ); + } +} + +SankeyGraph.propTypes = { + configuration: React.PropTypes.object +}; + +export default SankeyGraph; \ No newline at end of file diff --git a/src/components/Graphs/SankeyGraph/sankey.js b/src/components/Graphs/SankeyGraph/sankey.js new file mode 100644 index 00000000..b8c55138 --- /dev/null +++ b/src/components/Graphs/SankeyGraph/sankey.js @@ -0,0 +1,296 @@ +import * as d3 from "d3"; + +d3.sankey = function() { + var sankey = {}, + nodeWidth = 24, + nodePadding = 8, + size = [1, 1], + nodes = [], + links = []; + + sankey.nodeWidth = function(_) { + if (!arguments.length) return nodeWidth; + nodeWidth = +_; + return sankey; + }; + + sankey.nodePadding = function(_) { + if (!arguments.length) return nodePadding; + nodePadding = +_; + return sankey; + }; + + sankey.nodes = function(_) { + if (!arguments.length) return nodes; + nodes = _; + return sankey; + }; + + sankey.links = function(_) { + if (!arguments.length) return links; + links = _; + return sankey; + }; + + sankey.size = function(_) { + if (!arguments.length) return size; + size = _; + return sankey; + }; + + sankey.layout = function(iterations) { + computeNodeLinks(); + computeNodeValues(); + computeNodeBreadths(); + computeNodeDepths(iterations); + computeLinkDepths(); + return sankey; + }; + + sankey.relayout = function() { + computeLinkDepths(); + return sankey; + }; + + sankey.link = function() { + var curvature = .5; + + function link(d) { + var x0 = d.source.x + d.source.dx, + x1 = d.target.x, + xi = d3.interpolateNumber(x0, x1), + x2 = xi(curvature), + x3 = xi(1 - curvature), + y0 = d.source.y + d.sy + d.dy / 2, + y1 = d.target.y + d.ty + d.dy / 2; + return "M" + x0 + "," + y0 + + "C" + x2 + "," + y0 + + " " + x3 + "," + y1 + + " " + x1 + "," + y1; + } + + link.curvature = function(_) { + if (!arguments.length) return curvature; + curvature = +_; + return link; + }; + + return link; + }; + + // Populate the sourceLinks and targetLinks for each node. + // Also, if the source and target are not objects, assume they are indices. + function computeNodeLinks() { + nodes.forEach(function(node) { + node.sourceLinks = []; + node.targetLinks = []; + }); + links.forEach(function(link) { + var source = link.source, + target = link.target; + if (typeof source === "number") source = link.source = nodes[link.source]; + if (typeof target === "number") target = link.target = nodes[link.target]; + source.sourceLinks.push(link); + target.targetLinks.push(link); + }); + } + + // Compute the value (size) of each node by summing the associated links. + function computeNodeValues() { + nodes.forEach(function(node) { + node.value = Math.max( + d3.sum(node.sourceLinks, value), + d3.sum(node.targetLinks, value) + ); + }); + } + + // Iteratively assign the breadth (x-position) for each node. + // Nodes are assigned the maximum breadth of incoming neighbors plus one; + // nodes with no incoming links are assigned breadth zero, while + // nodes with no outgoing links are assigned the maximum breadth. + function computeNodeBreadths() { + var remainingNodes = nodes, + nextNodes, + x = 0; + + while (remainingNodes.length) { + nextNodes = []; + remainingNodes.forEach(function(node) { + node.x = x; + node.dx = nodeWidth; + node.sourceLinks.forEach(function(link) { + if (nextNodes.indexOf(link.target) < 0) { + nextNodes.push(link.target); + } + }); + }); + remainingNodes = nextNodes; + ++x; + } + + // + moveSinksRight(x); + scaleNodeBreadths((size[0] - nodeWidth) / (x - 1)); + } + + function moveSourcesRight() { + nodes.forEach(function(node) { + if (!node.targetLinks.length) { + node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1; + } + }); + } + + function moveSinksRight(x) { + nodes.forEach(function(node) { + if (!node.sourceLinks.length) { + node.x = x - 1; + } + }); + } + + function scaleNodeBreadths(kx) { + nodes.forEach(function(node) { + node.x *= kx; + }); + } + + function computeNodeDepths(iterations) { + var nodesByBreadth = d3.nest() + .key(function(d) { return d.x; }) + .sortKeys(d3.ascending) + .entries(nodes) + .map(function(d) { return d.values; }); + + // + initializeNodeDepth(); + resolveCollisions(); + for (var alpha = 1; iterations > 0; --iterations) { + relaxRightToLeft(alpha *= .99); + resolveCollisions(); + relaxLeftToRight(alpha); + resolveCollisions(); + } + + function initializeNodeDepth() { + var ky = d3.min(nodesByBreadth, function(nodes) { + return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value); + }); + + nodesByBreadth.forEach(function(nodes) { + nodes.forEach(function(node, i) { + node.y = i; + node.dy = node.value * ky; + }); + }); + + links.forEach(function(link) { + link.dy = link.value * ky; + }); + } + + function relaxLeftToRight(alpha) { + nodesByBreadth.forEach(function(nodes, breadth) { + nodes.forEach(function(node) { + if (node.targetLinks.length) { + var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value); + node.y += (y - center(node)) * alpha; + } + }); + }); + + function weightedSource(link) { + return center(link.source) * link.value; + } + } + + function relaxRightToLeft(alpha) { + nodesByBreadth.slice().reverse().forEach(function(nodes) { + nodes.forEach(function(node) { + if (node.sourceLinks.length) { + var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value); + node.y += (y - center(node)) * alpha; + } + }); + }); + + function weightedTarget(link) { + return center(link.target) * link.value; + } + } + + function resolveCollisions() { + nodesByBreadth.forEach(function(nodes) { + var node, + dy, + y0 = 0, + n = nodes.length, + i; + + // Push any overlapping nodes down. + nodes.sort(ascendingDepth); + for (i = 0; i < n; ++i) { + node = nodes[i]; + dy = y0 - node.y; + if (dy > 0) node.y += dy; + y0 = node.y + node.dy + nodePadding; + } + + // If the bottommost node goes outside the bounds, push it back up. + dy = y0 - nodePadding - size[1]; + if (dy > 0) { + y0 = node.y -= dy; + + // Push any overlapping nodes back up. + for (i = n - 2; i >= 0; --i) { + node = nodes[i]; + dy = node.y + node.dy + nodePadding - y0; + if (dy > 0) node.y -= dy; + y0 = node.y; + } + } + }); + } + + function ascendingDepth(a, b) { + return a.y - b.y; + } + } + + function computeLinkDepths() { + nodes.forEach(function(node) { + node.sourceLinks.sort(ascendingTargetDepth); + node.targetLinks.sort(ascendingSourceDepth); + }); + nodes.forEach(function(node) { + var sy = 0, ty = 0; + node.sourceLinks.forEach(function(link) { + link.sy = sy; + sy += link.dy; + }); + node.targetLinks.forEach(function(link) { + link.ty = ty; + ty += link.dy; + }); + }); + + function ascendingSourceDepth(a, b) { + return a.source.y - b.source.y; + } + + function ascendingTargetDepth(a, b) { + return a.target.y - b.target.y; + } + } + + function center(node) { + return node.y + node.dy / 2; + } + + function value(link) { + return link.value; + } + + return sankey; +}; \ No newline at end of file diff --git a/src/components/Graphs/SankeyGraph/style.css b/src/components/Graphs/SankeyGraph/style.css new file mode 100644 index 00000000..205e34f0 --- /dev/null +++ b/src/components/Graphs/SankeyGraph/style.css @@ -0,0 +1,20 @@ +.node rect { + cursor: move; + fill-opacity: .9; + shape-rendering: crispEdges; +} + +.node text { + pointer-events: none; + text-shadow: 0 1px 0 #fff; +} + +.link { + fill: none; + stroke: #CAC6C6; + stroke-opacity: .4; +} + +.link:hover { + stroke-opacity: .5; +} \ No newline at end of file diff --git a/src/components/Graphs/index.js b/src/components/Graphs/index.js index 7db6b328..95ffce6e 100644 --- a/src/components/Graphs/index.js +++ b/src/components/Graphs/index.js @@ -10,6 +10,7 @@ import ChordGraph from "./ChordGraph"; import GaugeGraph from "./GaugeGraph"; import HeatmapGraph from "./HeatmapGraph"; import AreaGraph from "./AreaGraph"; +import SankeyGraph from "./SankeyGraph"; import { theme } from "../../theme"; @@ -27,7 +28,8 @@ let registry = { GaugeGraph, VariationTextGraph, HeatmapGraph, - AreaGraph + AreaGraph, + SankeyGraph }; /*