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/dashboards/kitchenSink.json b/public/configurations/dashboards/kitchenSink.json index c72069e3..2b97290c 100644 --- a/public/configurations/dashboards/kitchenSink.json +++ b/public/configurations/dashboards/kitchenSink.json @@ -70,7 +70,7 @@ { "id": "effective-score", "x": 0, - "y": 15, + "y": 30, "w": 3, "h": 15, "minW": 2, @@ -79,7 +79,7 @@ { "id": "vss-domain-acl-time", "x": 3, - "y": 15, + "y": 30, "w": 3, "h": 15, "minW": 2, @@ -88,7 +88,7 @@ { "id": "aar-nsg-line-chart", "x": 6, - "y": 15, + "y": 30, "w": 3, "h": 15, "minW": 2, @@ -96,8 +96,8 @@ }, { "id": "aar-nsg-gauge-chart", - "x": 6, - "y": 15, + "x": 9, + "y": 30, "w": 3, "h": 15, "minW": 2, @@ -105,13 +105,21 @@ }, { "id": "aar-flow-sla-heatmap", - "x": 9, - "y": 15, + "x": 0, + "y": 45, "w": 6, "h": 15, "minW": 2, "minH": 12 + }, + { + "id": "test-area-graph", + "x": 6, + "y": 45, + "w": 6, + "h": 15, + "minW": 6, + "minH": 15 } - ] } 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/public/configurations/visualizations/test-area-graph.json b/public/configurations/visualizations/test-area-graph.json new file mode 100644 index 00000000..88652c8d --- /dev/null +++ b/public/configurations/visualizations/test-area-graph.json @@ -0,0 +1,32 @@ +{ + "id": "test-area-graph", + "graph": "AreaGraph", + "title": "Area Graph", + "description": "New Graph - Area Graph Visualization", + "author": "Anil Chauhan", + "creationDate": "19/08/2017", + "data": { + "dateHistogram": true, + "xColumn": "ts", + "xLabel": "Time", + "yColumn": ["CPU", "MEMORY", "DISK"], + "yTickFormat": ",.1f", + "yLabel": "", + "yTicks": 5, + "xTickGrid": true, + "linesColumn": ["CPU", "MEMORY", "DISK"], + "legend": { + "orientation": "vertical", + "show": true, + "circleSize": 5, + "labelOffset": 5 + }, + "tooltip": [ + { "column": "CPU", "label": "CPU", "format": "0.2f"}, + { "column": "MEMORY", "label": "MEMORY", "format": "0.2f"}, + { "column": "DISK", "label": "DISK", "format": "0.2f"}, + { "column": "ts", "label": "Timestamp", "timeFormat": "%b %d, %y %X"} + ] + }, + "query": "vnf-status-linechart" +} \ No newline at end of file diff --git a/src/components/Graphs/AbstractGraph.js b/src/components/Graphs/AbstractGraph.js index 08bab81d..4a6d41f0 100644 --- a/src/components/Graphs/AbstractGraph.js +++ b/src/components/Graphs/AbstractGraph.js @@ -9,14 +9,23 @@ import { GraphManager } from "./index"; import columnAccessor from "../../utils/columnAccessor"; import crossfilter from "crossfilter2"; +import { + format +} from "d3"; + export default class AbstractGraph extends React.Component { constructor(props, properties = {}) { super(props); + this.configuredProperties = {}; + this.defaults = GraphManager.getDefaultProperties(properties); + // set all configuration into single object + this.setConfiguredProperties(); + // Provide tooltips for subclasses. const { tooltip } = this.getConfiguredProperties(); if(tooltip) { @@ -26,7 +35,6 @@ export default class AbstractGraph extends React.Component { // This function is invoked to produce the content of a tooltip. this.getTooltipContent = () => { - // The value of this.hoveredDatum should be set by subclasses // on mouseEnter and mouseMove of visual marks // to the data entry corresponding to the hovered mark. @@ -67,6 +75,8 @@ export default class AbstractGraph extends React.Component { type="dark" effect="float" getContent={[() => this.getTooltipContent(), 200]} + afterHide={() => this.handleHideEvent()} + afterShow={() => this.handleShowEvent()} /> ); @@ -87,6 +97,10 @@ export default class AbstractGraph extends React.Component { } } + handleShowEvent() {} + + handleHideEvent() {} + wrapD3Text (text, width) { text.each(function() { var text = d3.select(this), @@ -109,8 +123,12 @@ export default class AbstractGraph extends React.Component { }); }; + setConfiguredProperties() { + this.configuredProperties = Object.assign({}, this.defaults, this.props.configuration.data); + } + getConfiguredProperties() { - return Object.assign({}, this.defaults, this.props.configuration.data); + return this.configuredProperties; } getMappedScaleColor(data, defaultColumn) { @@ -290,6 +308,85 @@ export default class AbstractGraph extends React.Component { ); } + setYlabelWidth(data) { + const { + chartWidthToPixel, + yTickFormat + } = this.getConfiguredProperties(); + + const yLabelFn = (d) => { + if(!yTickFormat) { + return d['yColumn']; + } + const formatter = format(yTickFormat); + return formatter(d['yColumn']); + }; + + this.yLabelWidth = this.longestLabelLength(data, yLabelFn) * chartWidthToPixel; + } + + getYlabelWidth() { + return this.yLabelWidth; + } + + setLeftMargin() { + const { + margin + } = this.getConfiguredProperties(); + + this.leftMargin = margin.left + this.getYlabelWidth(); + } + + getLeftMargin() { + return this.leftMargin; + } + + setAvailableWidth({width}) { + + const { + margin, + } = this.getConfiguredProperties(); + + this.availableWidth = width - (margin.left + margin.right + this.getYlabelWidth()); + } + + getAvailableWidth() { + return this.availableWidth; + } + + // height of x-axis + getXAxisHeight() { + const { + chartHeightToPixel, + xLabel + } = this.getConfiguredProperties(); + + return xLabel ? chartHeightToPixel : 0; + } + + setAvailableHeight({height}) { + + const { + chartHeightToPixel, + margin + } = this.getConfiguredProperties(); + + this.availableHeight = height - (margin.top + margin.bottom + chartHeightToPixel + this.getXAxisHeight()); + + } + + getAvailableHeight() { + return this.availableHeight; + } + + // Check whether to display legend as vertical or horizontal + checkIsVerticalLegend() { + const { + legend + } = this.getConfiguredProperties(); + + return legend.orientation === 'vertical'; + } getGroupedData(data, settings) { if(settings.otherOptions && settings.otherOptions.limit) { diff --git a/src/components/Graphs/AreaGraph/default.config.js b/src/components/Graphs/AreaGraph/default.config.js new file mode 100644 index 00000000..d9bd1784 --- /dev/null +++ b/src/components/Graphs/AreaGraph/default.config.js @@ -0,0 +1,23 @@ +import { theme } from "../../../theme" + +export const properties = { + stroke: { + color: "red", + width: "2px", + opacity: "0.4" + }, + legend: { + show: false + }, + colors: [ + theme.palette.orangeLightColor, + theme.palette.blueLightColor, + theme.palette.pinkLightColor, + theme.palette.orangeLighterColor, + theme.palette.greenColor, + theme.palette.yellowLightColor, + theme.palette.yellowDarkColor, + ], + zeroStart: true, + circleRadius: 5 +} diff --git a/src/components/Graphs/AreaGraph/index.js b/src/components/Graphs/AreaGraph/index.js new file mode 100644 index 00000000..b6b3c72a --- /dev/null +++ b/src/components/Graphs/AreaGraph/index.js @@ -0,0 +1,361 @@ +import React from "react"; +import XYGraph from "../XYGraph"; +import { connect } from "react-redux"; +import * as d3 from "d3"; + +import { + line, + select, + brushX, + area +} from "d3"; + +import {properties} from "./default.config"; + +class AreaGraph extends XYGraph { + + constructor(props) { + super(props, properties); + this.brush = brushX(); + } + + componentWillMount() { + this.initiate(this.props); + } + + componentWillReceiveProps(nextProps) { + if(this.props !== nextProps) { + this.initiate(nextProps); + } + } + + initiate(props) { + const { + data, + } = props; + + if (!data || !data.length) + return; + + this.parseData(); + this.setDimensions(props); + this.updateLegend(); + this.generateXYGraph(props); + this.generateElements(); + } + + parseData() { + const { + data + } = this.props; + + const { + linesColumn, + yColumn + } = this.getConfiguredProperties(); + + this.legendsData = []; + let updatedLinesLabel = []; + let finalYColumn = typeof yColumn === 'object' ? yColumn : [yColumn]; + + if(linesColumn) + updatedLinesLabel = typeof linesColumn === 'object' ? linesColumn : [linesColumn]; + + this.legendsData = finalYColumn.map((d, i) => { + return { + 'key' : d, + 'value' : updatedLinesLabel[i] ? updatedLinesLabel[i] : d + }; + }); + + this.filterDatas = []; + + data.forEach((d) => { + this.getLegendsData().forEach((ld) => { + if(d[ld['key']] !== null) { + this.filterDatas.push(Object.assign({ + yColumn: d[ld['key']] !== null ? d[ld['key']] : 0, + columnType: ld['key'] + }, d)); + } + }); + }); + } + + getFilterDatas() { + return this.filterDatas; + } + + setDimensions(props) { + this.setYlabelWidth(this.getFilterDatas()); + this.setAvailableWidth(props); + this.setAvailableHeight(props); + this.setLeftMargin(); + this.setXBandScale(this.props.data); + this.setYBandScale(this.props.data) + } + + updateLegend() { + + const { + chartHeightToPixel, + chartWidthToPixel, + circleToPixel, + legend, + } = this.getConfiguredProperties(); + + const legendFn = (d) => d['value']; + let legendWidth = legend.show && this.getLegendsData().length >= 1 ? this.longestLabelLength(this.getLegendsData(), legendFn) * chartWidthToPixel : 0; + + if (legend.show) + { + legend.width = legendWidth; + + // Compute the available space considering a legend + if (this.checkIsVerticalLegend()) + { + this.leftMargin += legend.width; + this.availableWidth -= legend.width; + } + else { + const nbElementsPerLine = parseInt(this.getAvailableWidth() / legend.width, 10); + const nbLines = parseInt(this.getLegendsData().length / nbElementsPerLine, 10); + this.availableHeight -= nbLines * legend.circleSize * circleToPixel + chartHeightToPixel; + } + } + + this.legend = legend; + } + + getLegend() { + return this.legend; + } + + getLegendsData() { + return this.legendsData; + } + + generateXYGraph(props) { + + const { + data, + } = props; + + this.setXScale(data); + this.setXaxis(data); + this.setYScale(this.getFilterDatas()); + this.setYaxis(); + this.setXTitlePositions(); + this.setYTitlePositions(); + } + + generateElements() { + + this.areas = []; + this.lines = []; + this.circle = []; + this.hoverCircle = []; + + this.getLegendsData().map((d, i) => + this.generateElement(this.getFilterDatas(), d, this.props.data.length === 1 ? true: false) + ) + } + + generateElement(filterData, data, isCircle = false) { + + const { + circleRadius, + colors, + xColumn, + stroke + } = this.getConfiguredProperties(); + + const scale = this.scaleColor(this.getLegendsData(), 'key'); + + this.getColor = (d) => scale ? scale(d['key']) : stroke.color || colors[0]; + + if(isCircle) { + this.circle = filterData.map( (d) => + + ) + return; + } + + let xScale = this.getXScale(); + let yScale = this.getYScale(); + let availableHeight = this.getAvailableHeight(); + + this.hoverCircle.push( + + ) + + var lineGenerator = line() + .x(function(d) { return xScale(d[xColumn]); }) + .y(function(d) { return yScale(d[data['key']]); }); + + var areaGenerator = area() + .x(function(d, i) { return xScale(d[xColumn]); }) + .y0(function(d) { return availableHeight; }) + .y1(function(d) { return yScale(d[data['key']]); }) + + + this.areas.push( + ) + + this.lines.push( + ) + } + + getLines() { + return this.lines ? this.lines : []; + } + + getAreas() { + return this.areas ? this.areas : []; + } + + getCircle() { + return this.circle ? this.circle : []; + } + + getHoverCircle() { + return this.hoverCircle ? this.hoverCircle : []; + } + + handleShowEvent() { + d3.select("#tooltip-line").style("opacity", 1); + } + + handleHideEvent() { + this.hoveredDatum = null; + d3.select("#tooltip-line").style("opacity", 0); + } + + renderTooltip() { + const { + xColumn, + } = this.getConfiguredProperties(); + + let xScale = this.getXScale(); + let bandwidth = this.getXBandScale().bandwidth() * 0.8; + + return this.getFilterDatas().map((d, i) => + this.updateVerticalLine(d)} + + > + + + + + + ) + } + + updateVerticalLine(data) { + const { + xColumn, + } = this.getConfiguredProperties(); + + const yScale = this.getYScale(); + const rightMargin = this.hoveredDatum ? this.getXScale()(data[xColumn]) : 0; + + d3.select("#tooltip-line").attr("transform", "translate("+rightMargin+", 0)"); + + this.getLegendsData().forEach(function(d) { + d3.select(`#circle_${d['key']}`) + .attr("cy", yScale(data[d['key']])); + }); + + } + + renderVerticalLine() { + return + } + + render() { + + const { + data, + width, + height + } = this.props; + + if (!data || !data.length) + return; + + const { + margin + } = this.getConfiguredProperties(); + + const label = (d) => d['value']; + + return ( +
+ { + this.tooltip + } + + {this.axisTitles(this.getXTitlePositions(), this.getYTitlePositions())} + + select(el).call(this.getXAxis()) } + transform={ `translate(0,${this.getAvailableHeight()})` } + /> + select(el).call(this.getYAxis()) } + /> + + + { this.getLines() } + { this.getAreas() } + { this.getCircle() } + + + + { this.renderVerticalLine() } + { this.getHoverCircle() } + + + { this.renderTooltip() } + + + + {this.renderLegend(this.getLegendsData(), this.getLegend(), this.getColor, label)} + +
+ ); + } +} +AreaGraph.propTypes = { + configuration: React.PropTypes.object, + response: React.PropTypes.object +}; + +const actionCreators = (dispatch) => ({ +}); + +export default connect(null, actionCreators)(AreaGraph); 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/XYGraph.js b/src/components/Graphs/XYGraph.js index 9e3595dc..983ecc2f 100644 --- a/src/components/Graphs/XYGraph.js +++ b/src/components/Graphs/XYGraph.js @@ -1,6 +1,17 @@ import React from "react"; import AbstractGraph from "./AbstractGraph"; +import { + format, + scaleTime, + extent, + scaleLinear, + axisBottom, + axisLeft, + scaleBand, + map +} from "d3"; + export default class XYGraph extends AbstractGraph { constructor(props, properties = {}) { @@ -15,6 +26,181 @@ export default class XYGraph extends AbstractGraph { } + setYBandScale(data) { + + const yLabelFn = (d) => d['yColumn']; + + const distYDatas = map(data, yLabelFn).keys().sort(); + + this.yBandScale = scaleBand() + .domain(distYDatas); + this.yBandScale.rangeRound([0, this.getAvailableHeight()]); + } + + getYBandScale() { + return this.yBandScale; + } + + setXBandScale(data) { + const { + xColumn + } = this.getConfiguredProperties(); + + const xLabelFn = (d) => d[xColumn]; + + const distXDatas = map(data, xLabelFn).keys().sort(); + + this.xBandScale = scaleBand() + .domain(distXDatas); + this.xBandScale.rangeRound([0, this.getAvailableWidth()]); + } + + getXBandScale() { + //return min([this.xBandScale.bandwidth(), this.yBandScale.bandwidth()]); + return this.xBandScale; + } + + setXScale(data) { + + if (!data || !data.length) + return; + + const { + dateHistogram, + xColumn + } = this.getConfiguredProperties(); + + const xLabelFn = (d) => d[xColumn]; + + this.xScale = null; + + if (dateHistogram) { + this.xScale = scaleTime() + .domain(extent(data, xLabelFn)); + } else { + this.xScale = scaleLinear() + .domain(extent(data, xLabelFn)); + } + + this.xScale.range([0, this.getAvailableWidth()]); + } + + getXScale() { + return this.xScale; + } + + setXaxis(data) { + + if (!data || !data.length) + return; + + const { + xTickSizeInner, + xTickSizeOuter, + xTickFormat, + xTickGrid, + xTicks + } = this.getConfiguredProperties(); + + this.xAxis = axisBottom(this.getXScale()) + .tickSizeInner(xTickGrid ? -this.getAvailableHeight() : xTickSizeInner) + .tickSizeOuter(xTickSizeOuter); + + if(xTickFormat){ + this.xAxis.tickFormat(format(xTickFormat)); + } + + if(xTicks){ + this.xAxis.ticks(xTicks); + } + } + + getXAxis() { + return this.xAxis; + } + + setYScale(data) { + + const { + zeroStart, + } = this.getConfiguredProperties(); + + const yLabelFn = (d) => d['yColumn']; + + let yExtent = this.updateYExtent(extent(data, yLabelFn), zeroStart); + + this.yScale = scaleLinear() + .domain(yExtent); + + this.yScale.range([this.getAvailableHeight(), 0]); + } + + getYScale() { + return this.yScale; + } + + setYaxis() { + + const { + yTickFormat, + yTickGrid, + yTicks, + yTickSizeInner, + yTickSizeOuter, + } = this.getConfiguredProperties(); + + this.yAxis = axisLeft(this.getYScale()) + .tickSizeInner(yTickGrid ? -this.getAvailableWidth() : yTickSizeInner) + .tickSizeOuter(yTickSizeOuter); + + if(yTickFormat){ + this.yAxis.tickFormat(format(yTickFormat)); + } + + if(yTicks){ + this.yAxis.ticks(yTicks); + } + } + + getYAxis() { + return this.yAxis; + } + + setXTitlePositions() { + + const { + chartHeightToPixel, + margin, + } = this.getConfiguredProperties(); + + this.xTitlePosition = { + left: this.getLeftMargin() + this.getAvailableWidth() / 2, + top: margin.top + this.getAvailableHeight() + chartHeightToPixel + this.getXAxisHeight() + } + } + + getXTitlePositions() { + return this.xTitlePosition; + } + + setYTitlePositions() { + + const { + chartWidthToPixel, + margin, + } = this.getConfiguredProperties(); + + this.yTitlePosition = { + // We use chartWidthToPixel to compensate the rotation of the title + left: margin.left + chartWidthToPixel + (this.checkIsVerticalLegend() ? this.getLegend().width : 0), + top: margin.top + this.getAvailableHeight() / 2 + } + } + + getYTitlePositions() { + return this.yTitlePosition; + } + axisTitles(xLabelPosition, yLabelPosition) { const { diff --git a/src/components/Graphs/index.js b/src/components/Graphs/index.js index 87872f33..95ffce6e 100644 --- a/src/components/Graphs/index.js +++ b/src/components/Graphs/index.js @@ -9,6 +9,8 @@ import Table from "./Table"; import ChordGraph from "./ChordGraph"; import GaugeGraph from "./GaugeGraph"; import HeatmapGraph from "./HeatmapGraph"; +import AreaGraph from "./AreaGraph"; +import SankeyGraph from "./SankeyGraph"; import { theme } from "../../theme"; @@ -25,7 +27,9 @@ let registry = { ChordGraph, GaugeGraph, VariationTextGraph, - HeatmapGraph + HeatmapGraph, + AreaGraph, + SankeyGraph }; /*