From 9681f14e71e42c4cf64d32e49a021b7d4ac5f0de Mon Sep 17 00:00:00 2001 From: Florian Breitwieser Date: Sun, 30 Oct 2016 21:55:03 -0400 Subject: [PATCH] Update to D3 v2 Changes: - Added two new path drawing options, 'path1' and 'path2' based on d3-plugins PR #36 from @ghedamat and PR #40 by @cmorse. The standard bezier curve has difficulties when the thickness of the path is large relative to the node distance. These paths are drawn with individual bezier curves, which may not give equal area along its width, but always works. - Added option showNodeValues to show node values above nodes - Added option nodeCornerRadius for rounded nodes - Added option title for titles in the upper-right corner of the plot - Added _hover event that is fired every 2 seconds - Added option doubleclickTogglesChildren to hide children/downstram nodes - Added option xScalingFactor to scale width between nodes - Added option xAxisDomain to make an x-axis --- DESCRIPTION | 4 +- NEWS | 38 ++++ R/sankeyNetwork.R | 30 ++- README.md | 28 ++- inst/examples/shiny/server.R | 15 +- inst/examples/shiny/ui.R | 64 ++++-- inst/htmlwidgets/lib/d3-sankey/src/sankey.js | 184 +++++++++++------ inst/htmlwidgets/sankeyNetwork.js | 201 +++++++++++++++++-- man/sankeyNetwork.Rd | 33 ++- 9 files changed, 476 insertions(+), 121 deletions(-) create mode 100644 NEWS diff --git a/DESCRIPTION b/DESCRIPTION index 5d266f4..5f8ce3f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -2,8 +2,8 @@ Package: sankeyD3 Type: Package Title: D3 JavaScript Sankey Graphs from R Description: Creates 'D3' 'JavaScript' Sankey graphs from 'R'. -Version: 0.1 -Date: 2016-10-17 +Version: 0.2 +Date: 2016-10-30 Authors@R: c( person("Christopher", "Gandrud", email = "christopher.gandrud@gmail.com", role = c("aut", "cre")), diff --git a/NEWS b/NEWS new file mode 100644 index 0000000..d7755b7 --- /dev/null +++ b/NEWS @@ -0,0 +1,38 @@ +sankeyD3 v0.2 (Release date: 2016-10-30) +========== + +Changes: + + - Added two new path drawing options, 'path1' and 'path2' + based on d3-plugins PR #36 from @ghedamat and PR #40 by @cmorse. + The standard bezier curve has difficulties when the thickness of the path is large relative to the node distance. These paths are drawn with individual bezier curves, which may not give equal area along its width, but always works. + - Added option showNodeValues to show node values above nodes + - Added option nodeCornerRadius for rounded nodes + - Added option title for titles in the upper-right corner of the plot + - Added _hover event that is fired every 2 seconds + - Added option doubleclickTogglesChildren to hide children/downstram + nodes + - Added option xScalingFactor to scale width between nodes + - Added option xAxisDomain to make an x-axis + + +sankeyD3 v0.1 (Release date: 2016-10-20) +======================================== + +Changes: + + - ported to D3 v4 + - based on https://github.com/d3/d3-sankey + - added several modifications from networkD3 sankey.js + - included fixes and features from unmerged pull requests: + - d3/d3-plugins#124: Fix nodesByBreadth to have proper ordering + - d3/d3-plugins#120: Added 'l-bezier' link type + - d3/d3-plugins#74: Sort sankey target links by descending slope + - d3/d3-sankey#4: Add horizontal alignment option to Sankey layout + - added option numberFormat, default being ",.5g" (see , fixes christophergandrud/networkD3#147) + - added option NodePosX, fixes christophergandrud/networkD3#108 + - added option to force node ordering to be alphabetical along a path (only works well with trees with one parent for each node, but might fix christophergandrud/networkD3#153) + - zooming + - dragging both horizontally and vertically + + diff --git a/R/sankeyNetwork.R b/R/sankeyNetwork.R index 6ea7042..5baa9c2 100644 --- a/R/sankeyNetwork.R +++ b/R/sankeyNetwork.R @@ -65,7 +65,8 @@ NULL #' @param nodeWidth numeric width of each node. #' @param nodePadding numeric essentially influences the width height. #' @param nodeStrokeWidth numeric width of the stroke around nodes. -#' @param numberFormat number format in toolstips - see https://github.com/d3/d3-format for options +#' @param nodeCornerRadius numberic Radius for rounded nodes. +#' @param numberFormat number format in toolstips - see https://github.com/d3/d3-format for options. #' @param margin an integer or a named \code{list}/\code{vector} of integers #' for the plot margins. If using a named \code{list}/\code{vector}, #' the positions \code{top}, \code{right}, \code{bottom}, \code{left} @@ -74,6 +75,7 @@ NULL #' to accomodate long text labels. #' @param height numeric height for the network graph's frame area in pixels. #' @param width numeric width for the network graph's frame area in pixels. +#' @param title character Title of plot, put in the upper-left corner of the Sankey #' @param iterations numeric. Number of iterations in the diagramm layout for #' computation of the depth (y-position) of each node. Note: this runs in the #' browser on the client so don't push it too high. @@ -81,12 +83,18 @@ NULL #' If 'none', then the labels of the nodes are always to the right of the node. #' @param zoom logical value to enable (\code{TRUE}) or disable (\code{FALSE}) #' zooming -#' @param linkType character One of 'bezier', 'l-bezier', and trapezoid. -#' @param orderByPath Order the nodes vertically along a path - this layout only +#' @param xScalingFactor numeric Scale the computed x position of the nodes by this value. +#' @param xAxisDomain character[] If xAxisDomain is given, an axis with those value is +#' added to the bottom of the plot. Only sensible when also NodeXPos are given. +#' @param linkType character One of 'bezier', 'l-bezier', 'trapezoid', 'path1' and 'path2'. +#' @param orderByPath boolean Order the nodes vertically along a path - this layout only #' works well for trees where each node has maximum one parent. -#' @param highlightChildLinks Highlight all the links going right from a node or +#' @param highlightChildLinks boolean Highlight all the links going right from a node or #' link. +#' @param doubleclickTogglesChildren boolean Show/hide target nodes and paths to the left +#' on double-click. Does not hide incoming links of target nodes, yet. #' @param curvature numeric Curvature parameter for bezier links - between 0 and 1. +#' @param showNodeValues boolean Show values above nodes. Might require and increased node margin. #' @param scaleNodeBreadthsByString Put nodes at positions relatively to string lengths - #' only work well currently with align='none' #' @@ -122,10 +130,13 @@ NULL sankeyNetwork <- function(Links, Nodes, Source, Target, Value, NodeID, NodeGroup = NodeID, LinkGroup = NULL, NodePosX = NULL, NodeValue = NULL, units = "", colourScale = JS("d3.scaleOrdinal().range(d3.schemeCategory20)"), fontSize = 7, fontFamily = NULL, - nodeWidth = 15, nodePadding = 10, nodeStrokeWidth = 1, margin = NULL, + nodeWidth = 15, nodePadding = 10, nodeStrokeWidth = 1, nodeCornerRadius = 0, + margin = NULL, title = NULL, numberFormat = ",.5g", orderByPath = FALSE, highlightChildLinks = FALSE, + doubleclickTogglesChildren = FALSE, xAxisDomain = NULL, height = NULL, width = NULL, iterations = 32, zoom = FALSE, align = "justify", - linkType = "bezier", curvature = .5, scaleNodeBreadthsByString = FALSE) + showNodeValues = TRUE, linkType = "bezier", curvature = .5, + scaleNodeBreadthsByString = FALSE, xScalingFactor = 1) { # Check if data is zero indexed check_zero(Links[, Source], Links[, Target]) @@ -187,11 +198,14 @@ sankeyNetwork <- function(Links, Nodes, Source, Target, Value, options = list(NodeID = NodeID, NodeGroup = NodeGroup, LinkGroup = LinkGroup, colourScale = colourScale, fontSize = fontSize, fontFamily = fontFamily, nodeWidth = nodeWidth, nodePadding = nodePadding, nodeStrokeWidth = nodeStrokeWidth, + nodeCornerRadius = nodeCornerRadius, numberFormat = numberFormat, orderByPath = orderByPath, units = units, margin = margin, iterations = iterations, zoom = zoom, linkType = linkType, curvature = curvature, - highlightChildLinks = highlightChildLinks, - align = align, scaleNodeBreadthsByString = scaleNodeBreadthsByString) + highlightChildLinks = highlightChildLinks, doubleclickTogglesChildren = doubleclickTogglesChildren, + showNodeValues = showNodeValues, align = align, xAxisDomain = xAxisDomain, + title = title, + scaleNodeBreadthsByString = scaleNodeBreadthsByString, xScalingFactor = xScalingFactor) # create widget htmlwidgets::createWidget(name = "sankeyNetwork", x = list(links = LinksDF, diff --git a/README.md b/README.md index 8665ffb..da3137b 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,35 @@ # D3 JavaScript Sankey Network Graphs from R -Version 0.1 +Version 0.2 This project is based on the Sankey implementation in [networkD3](https://github.com/christophergandrud/networkD3) and [d3-sankey](https://github.com/d3/d3-sankey). Changelog: - - ported to D3 v4 - - based on https://github.com/d3/d3-sankey + - Based on D3 v4 / https://github.com/d3/d3-sankey - added several modifications from networkD3 sankey.js - included fixes and features from unmerged pull requests: - d3/d3-plugins#124: Fix nodesByBreadth to have proper ordering - d3/d3-plugins#120: Added 'l-bezier' link type + - d3/d3-plugins#36: Added 'path1' link type + - d3/d3-plugins#40: Added 'path2' link type - d3/d3-plugins#74: Sort sankey target links by descending slope - d3/d3-sankey#4: Add horizontal alignment option to Sankey layout - - added option numberFormat, default being ",.5g" (see , fixes christophergandrud/networkD3#147) - - added option NodePosX, fixes christophergandrud/networkD3#108 - - added option to force node ordering to be alphabetical along a path (only works well with trees with one parent for each node, but might fix christophergandrud/networkD3#153) - - zooming - - dragging both horizontally and vertically + - Added option numberFormat, default being ",.5g" (see , fixes christophergandrud/networkD3#147) + - Added option NodePosX, fixes christophergandrud/networkD3#108 + - Added option to force node ordering to be alphabetical along a path (only works well with trees with one parent for each node, but might fix christophergandrud/networkD3#153) + - Zooming + - Dragging both horizontally and vertically + - Added option showNodeValues to show node values above nodes + - Added option nodeCornerRadius for rounded nodes + - Added option title for titles in the upper-right corner of the plot + - Added _hover event that is fired every second + - Added option doubleclickTogglesChildren to hide children/downstram + nodes + - Added option xScalingFactor to scale width between nodes + - Added option xAxisDomain to make an x-axis + + + The `inst/examples/shiny` web-app exposes several of the features: ![image](https://cloud.githubusercontent.com/assets/516060/19533346/5af9a822-960d-11e6-984c-333d20f2451f.png) diff --git a/inst/examples/shiny/server.R b/inst/examples/shiny/server.R index ada3fb0..f2bc40c 100644 --- a/inst/examples/shiny/server.R +++ b/inst/examples/shiny/server.R @@ -13,18 +13,31 @@ shinyServer(function(input, output) { Energy$links$target_name <- Energy$nodes[Energy$links$target+1, "name"] sankeyNetwork(Links = Energy$links, Nodes = Energy$nodes, Source = "source", Target = "target", Value = "value", NodeID = "name", - fontSize = 12, nodeWidth = 30, + fontSize = 12, zoom = input$zoom, align = input$align, scaleNodeBreadthsByString = input$scaleNodeBreadthsByString, + nodeWidth = input$nodeWidth, nodeStrokeWidth = input$nodeStrokeWidth, LinkGroup = ifelse(input$LinkGroup == "none", NA, input$LinkGroup), NodeGroup = ifelse(input$NodeGroup == "none", NA, input$NodeGroup), + nodePadding = input$nodePadding, + nodeCornerRadius = input$nodeCornerRadius, + showNodeValues = input$showNodeValues, linkType = input$linkType, curvature = input$curvature, numberFormat = input$numberFormat, highlightChildLinks = input$highlightChildLinks, + doubleclickTogglesChildren = input$doubleclickTogglesChildren, orderByPath = input$orderByPath, + xScalingFactor = input$xScalingFactor, units = "kWh") }) + output$clicked_node <- renderPrint( { + input$sankey_clicked + }) + output$hovered_node <- renderPrint( { + input$sankey_hover + }) + }) diff --git a/inst/examples/shiny/ui.R b/inst/examples/shiny/ui.R index 52b595d..5367842 100644 --- a/inst/examples/shiny/ui.R +++ b/inst/examples/shiny/ui.R @@ -2,27 +2,55 @@ library(shiny) library(sankeyD3) shinyUI(fluidPage( - + tags$head( + tags$style(HTML(" + .form-group { + margin-bottom: 0px; + display: inline-block; + background: lightgreen; + padding-left: 10px; + padding-right: 10px; + padding-bottom: 2px; + margin-bottom: 2px; + } + .shiny-input-container:not(.shiny-input-container-inline) { + width: initial; + } + .irs { + width: 150px; + } + ")) + ), titlePanel("Shiny sankeyD3 network"), - - fluidRow( - column(4,radioButtons("LinkGroup", "LinkGroup", choices = c("source_name", "target_name", "none"), selected = "none", inline = TRUE)), - column(4,radioButtons("NodeGroup", "NodeGroup", choices = c("name", "none"), selected = "name", inline = TRUE)), - column(4,radioButtons("linkType", "linkType", selected = "bezier", choices = c("bezier", "l-bezier", "trapez"), inline = TRUE)) + fluidRow( + radioButtons("LinkGroup", "LinkGroup", choices = c("source_name", "target_name", "none"), selected = "none", inline = TRUE), + radioButtons("NodeGroup", "NodeGroup", choices = c("name", "none"), selected = "name", inline = TRUE), + radioButtons("linkType", "linkType", selected = "bezier", choices = c("bezier", "l-bezier", "trapez", "path1", "path2"), inline = TRUE), + radioButtons("align", "align", choices = c("left", "right", "center", "justify", "none"), selected = "justify", inline = TRUE) ), - fluidRow( - column(4,radioButtons("align", "align", choices = c("left", "right", "center", "justify", "none"), selected = "justify", inline = TRUE)), - column(4,checkboxInput("scaleNodeBreadthsByString", "scaleNodeBreadthsByString", value = FALSE), - checkboxInput("zoom", "zoom", value = FALSE)), - column(4,checkboxInput("highlightChildLinks", "highlightChildLinks", value = FALSE), - checkboxInput("orderByPath", "orderByPath", value = FALSE)) + fluidRow( + checkboxInput("orderByPath", "orderByPath", value = FALSE), + checkboxInput("scaleNodeBreadthsByString", "scaleNodeBreadthsByString", value = FALSE), + checkboxInput("zoom", "zoom", value = TRUE), + checkboxInput("highlightChildLinks", "highlightChildLinks", value = FALSE), + checkboxInput("doubleclickTogglesChildren", "doubleclickTogglesChildren", value = FALSE), + checkboxInput("showNodeValues", "showNodeValues", value = FALSE) ), - fluidRow( - column(4,sliderInput("nodeStrokeWidth","nodeStrokeWidth", value = 1, min = 0, max = 15)), - column(4,sliderInput("curvature","curvature", value = .5, min = 0, max = 1, step=.1)), - column(4,textInput("numberFormat", "numberFormat", value = ",.5g")) + fluidRow( + sliderInput("nodeWidth","nodeWidth", value = 30, min = 0, max = 50), + sliderInput("nodeStrokeWidth","nodeStrokeWidth", value = 1, min = 0, max = 15), + sliderInput("nodePadding","nodePadding", value = 10, min = 0, max=50, step = 1), + sliderInput("nodeCornerRadius","nodeCornerRadius", value = 5, min = 0, max = 15), + sliderInput("curvature","curvature", value = .5, min = 0, max = 1, step=.1), + sliderInput("xScalingFactor","xScalingFactor", value = 1, min = 0, max = 3, step=.1) + ), + fluidRow(textInput("numberFormat", "numberFormat", value = ",.5g")), + fluidRow(verbatimTextOutput("clicked_node")), + fluidRow(verbatimTextOutput("hovered_node")), fluidRow( sankeyNetworkOutput("sankey") - ) -)) + ) +) +) + diff --git a/inst/htmlwidgets/lib/d3-sankey/src/sankey.js b/inst/htmlwidgets/lib/d3-sankey/src/sankey.js index 83583a9..f74019c 100644 --- a/inst/htmlwidgets/lib/d3-sankey/src/sankey.js +++ b/inst/htmlwidgets/lib/d3-sankey/src/sankey.js @@ -13,6 +13,8 @@ d3.sankey = function() { orderByPath = false, scaleNodeBreadthsByString = false, curvature = .5, + showNodeValues = false, + nodeCornerRadius = 0, nodes = [], links = []; @@ -39,18 +41,24 @@ d3.sankey = function() { links = _; return sankey; }; - + sankey.orderByPath = function(_) { if (!arguments.length) return orderByPath; orderByPath = _; return sankey; }; - + sankey.curvature = function(_) { if (!arguments.length) return curvature; curvature = _; return sankey; }; + + sankey.showNodeValues = function(_) { + if (!arguments.length) return showNodeValues; + showNodeValues = _; + return sankey; + }; sankey.linkType = function(_) { if (!arguments.length) return linkType; @@ -70,6 +78,12 @@ d3.sankey = function() { return sankey; }; + sankey.nodeCornerRadius= function(_) { + if (!arguments.length) return nodeCornerRadius; + nodeCornerRadius = _; + return sankey; + }; + sankey.scaleNodeBreadthsByString = function(_) { if (!arguments.length) return scaleNodeBreadthsByString; scaleNodeBreadthsByString = _; @@ -82,7 +96,6 @@ d3.sankey = function() { computeNodeValues(); computeNodeBreadths(); computeNodeDepths(iterations); - computeLinkDepths(); return sankey; }; @@ -93,57 +106,93 @@ d3.sankey = function() { // SVG path data generator, to be used as "d" attribute on "path" element selection. sankey.link = function() { + function xy(x,y) { return x + "," + y; } + + // M(x,y) moveto function - moves pen to new location; doesn't draw + function M(x,y) { return "M" + xy(x,y); } + + // C(x1,y1,x2,y2,x,y) curveto function + // draws a cubic bezier curve from the current point to (x,y) + // using (x1,y1) and (x2,y2) as control points + function C(x1,y1,x2,y2,x,y) { return "C" + xy(x1,y1) + " " + xy(x2,y2) + " " + xy(x,y); } + + // S(x2,y2,x,y) smooth curveto function + // draws a cubic bezier curve from the current point to (x,y) + // with the first control point being a reflection of (x2,y2) + function C(x1,y1,x2,y2,x,y) { return "C" + xy(x1,y1) + " " + xy(x2,y2) + " " + xy(x,y); } + + // L(x,y) lineto function - moves pen to new location; doesn't draw + function L(x,y) { return "L" + xy(x,y); } + + // Z() closepath function - line is drawn from last point to first + function Z() { return "Z"; } + + // V(y) vertical lineto function - draw a horizontal line from the current point to y + function V(y) { return "V" + y; } + + // v(dy) vertical lineto function - draws a horizontal line from the current point for dy px + function v(dy) { return "v" + dy; } + + // H(y) horizontal lineto function - draw a horizontal line from the current point to x + function H(x) { return "H" + x; } + + function link(d) { - var x0 = d.source.x + d.source.dx, - x1 = d.target.x, + + var x0 = d.source.x + d.source.dx - nodeCornerRadius, // x source point + x1 = d.target.x + nodeCornerRadius, // x target point 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; - - if (!d.cycleBreaker) { - if (linkType == "bezier") { - return "M" + x0 + "," + y0 - + "C" + x2 + "," + y0 - + " " + x3 + "," + y1 - + " " + x1 + "," + y1; - - } else if (linkType == "l-bezier") { - // from @michealgasser pull request #120 to d3/d3-plugins - x4 = x0 + d.source.dx/4 - x5 = x1 - d.target.dx/4 - x2 = Math.max(xi(curvature), x4+d.dy) - x3 = Math.min(xi(curvature), x5-d.dy) - return "M" + x0 + "," + y0 - + "L" + x4 + "," + y0 - + "C" + x2 + "," + y0 - + " " + x3 + "," + y1 - + " " + x5 + "," + y1 - + "L" + x1 + "," + y1; - } else if (linkType == "trapez") { - // TRAPEZOID connection - return "M" + (x0) + "," + (y0 - d.dy/2) - + "L" + (x0) + "," + (y0 + d.dy/2) - + " " + (x1) + "," + (y1 + d.dy/2) - + " " + (x1) + "," + (y1 - d.dy/2) + " z"; - } + x3 = xi(1 - curvature); - } else { + + var y0 = d.source.y + d.sy, + y1 = d.target.y + d.ty, + y2 = y1 + d.dy, + y3 = y0 + d.dy; + + var ld; + if (d.cycleBreaker) { + // TODO: Fix notation (xs = x0, etc) var xdelta = (1.5 * d.dy + 0.05 * Math.abs(xs - xt)); xsc = xs + xdelta; xtc = xt - xdelta; var xm = xi(0.5); var ym = d3.interpolateNumber(ys, yt)(0.5); var ydelta = (2 * d.dy + 0.1 * Math.abs(xs - xt) + 0.1 * Math.abs(ys - yt)) * (ym < (size[1] / 2) ? -1 : 1); - return "M" + xs + "," + ys - + "C" + xsc + "," + ys - + " " + xsc + "," + (ys + ydelta) - + " " + xm + "," + (ym + ydelta) - + "S" + xtc + "," + yt - + " " + xt + "," + yt; - + + ld = M(xs,ys) + C(xsc,ys, xsc,(ys + ydelta), xm,(ym + ydelta)) + S(xtc,yt, xt,yt); + } else { + switch (linkType) { + case "trapez": + ld = M(x0,y0) + L(x0,y3) + L(x1,y2) + L(x1,y1) + Z(); + break; + case "path1": // from @ghedamat https://github.com/d3/d3-plugins/pull/36 + ld = M(x0,y0) + C(x2,y0, x3,y1, x1,y1) + L(x1,y2) + C(x3,y2, x2,y3, x0,y3) + Z(); + break; + case "path2": // from @cmorse https://github.com/d3/d3-plugins/pull/40 + var x4 = x3 + ((d.dy < 15) ? ((d.source.y < d.target.y) ? -1 * d.dy : d.dy) : 0), + x5 = x2 + ((d.dy < 15) ? ((d.source.y < d.target.y) ? -1 * d.dy : d.dy) : 0); + ld = M(x0,y0) + C(x2,y0, x3,y1, x1,y1) + v(d.dy) + C(x4,y2, x5,y3, x0,y3) + Z(); + break; + case "l-bezier": // from @michealgasser pull request #120 to d3/d3-plugins + y0 = d.source.y + d.sy + d.dy / 2; + y1 = d.target.y + d.ty + d.dy / 2; + x4 = x0 + d.source.dx/4 + x5 = x1 - d.target.dx/4 + x2 = Math.max(xi(curvature), x4+d.dy) + x3 = Math.min(xi(curvature), x5-d.dy) + ld = M(x0,y0) + H(x4) + C(x2,y0, x3,y1, x5,y1) + H(x1); + break; + case "bezier": + default: + y0 = d.source.y + d.sy + d.dy / 2; + y1 = d.target.y + d.ty + d.dy / 2; + ld = M(x0,y0) + C(x2,y0, x3,y1, x1,y1); + break; + } } + return ld; } link.curvature = function(_) { @@ -163,10 +212,12 @@ d3.sankey = function() { node.sourceLinks = []; // Links that have this node as target. node.targetLinks = []; + node.inactive = false; }); links.forEach(function(link) { var source = link.source, target = link.target; + link.inactive = false; if (typeof source === "number") source = link.source = nodes[link.source]; if (typeof target === "number") target = link.target = nodes[link.target]; source.sourceLinks.push(link); @@ -234,13 +285,13 @@ d3.sankey = function() { node.dx = nodeWidth; }); } - + // calculate maximum string lengths at each posX max_posX= d3.max(nodes, function(d) { return(d.posX); } ) + 1; var max_str_length = new Array(max_posX); nodes.forEach(function(node) { if (typeof max_str_length[node.x] == "undefined" || node.name.length > max_str_length[node.x]) { - max_str_length[node.x] = node.name.length; + max_str_length[node.x] = node.name.length; } // make a path to the beginning for vertical ordering @@ -269,13 +320,14 @@ d3.sankey = function() { node.x += x - 1; }); } + if (align === 'center') { moveSourcesRight(); - } - if (align === 'justify') { + } else if (align === 'justify') { moveSinksRight(max_posX); } + if (align == 'none') { scaleNodeBreadths((size[0] - nodeWidth) / (max_posX)); } else { @@ -349,7 +401,7 @@ d3.sankey = function() { if (!node.sourceLinks.length) { node.x = x - 1; } else { - //move node to second from right + //move node to second from right var nodes_to_right = 0; node.sourceLinks.forEach(function(n) { nodes_to_right = Math.max(nodes_to_right,n.target.sourceLinks.length) @@ -362,6 +414,9 @@ d3.sankey = function() { function scaleNodeBreadths(kx) { nodes.forEach(function(node) { if (scaleNodeBreadthsByString) { + // this scaling is suboptimal - ideally it will be moved out to sankeyNetwork.js and + // calculated based on measured string lengths using el.getComputedTextLength() or + node.x = summed_str_length[node.x]; } else { node.x *= kx; @@ -374,13 +429,13 @@ d3.sankey = function() { var more_nodes = nodes; var nodesByBreadth; - + if (orderByPath) { nodesByBreadth = new Array(max_posX); for (i=0; i < nodesByBreadth.length; ++i) { nodesByBreadth[i] = []; } - + // Add 'invisible' nodes to account for different depths for (posX=0; posX < max_posX; ++posX) { for (j=0; j < nodes.length; ++j) { @@ -389,8 +444,12 @@ d3.sankey = function() { } node = nodes[j]; nodesByBreadth[posX].push(node); - if (node.sourceLinks.length && node.sourceLinks[0].target.posX > node.posX +1) { - for (new_node_posX=node.posX+1; new_node_posX < node.sourceLinks[0].target.posX; ++new_node_posX) { + no_intermediary_nodes = node.sourceLinks.length && node.sourceLinks[0].target.posX > node.posX +1; + no_end_nodes = !node.sourceLinks.length && node.posX < max_posX + + if (no_intermediary_nodes || no_end_nodes) { + end_node = no_intermediary_nodes? node.sourceLinks[0].target.posX : max_posX + for (new_node_posX=node.posX+1; new_node_posX < end_node; ++new_node_posX) { var new_node = node.constructor(); new_node.posX = new_node_posX; new_node.dy = node.dy; @@ -413,20 +472,23 @@ d3.sankey = function() { } - // Group nodes by breath. - //var nodesByBreadth = d3.nest() - // .key(function(d) { return d.x; }) - // .sortKeys(d3.ascending) - // .entries(nodes) - // .map(function(d) { return d.values; }); + //console.log(nodesByBreadth) initializeNodeDepth(); resolveCollisions(); + computeLinkDepths(); + for (var alpha = 1; iterations > 0; --iterations) { relaxRightToLeft(alpha *= .99); resolveCollisions(); + computeLinkDepths(); relaxLeftToRight(alpha); resolveCollisions(); + computeLinkDepths(); + } + + if (orderByPath) { + } function initializeNodeDepth() { @@ -453,6 +515,12 @@ d3.sankey = function() { if (node.targetLinks.length) { // Value-weighted average of the y-position of source node centers linked to this node. var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value); + + //console.log([y, node.targetLinks[0].source.y, node.targetLinks[0].sy, node.targetLinks[0] ]); + //var y = node.targetLinks[0].source.y; + //if (typeof node.targetLinks[0].sy != "undefined") { + // y = y + node.targetLinks[0].sy; + //} node.y += (y - center(node)) * alpha; } }); diff --git a/inst/htmlwidgets/sankeyNetwork.js b/inst/htmlwidgets/sankeyNetwork.js index 58bb1d6..07905db 100644 --- a/inst/htmlwidgets/sankeyNetwork.js +++ b/inst/htmlwidgets/sankeyNetwork.js @@ -86,8 +86,14 @@ HTMLWidgets.widget({ } } + var animation_duration = 50; + var opacity_node = 1; var opacity_link_plus = 0.45; + var opacity_link = function opacity_link(d) { + if (d.inactive) { + return 0; + } if (d.group){ return 0.5; } else { @@ -95,23 +101,53 @@ HTMLWidgets.widget({ } } - format = function(d) { return d3.format(options.numberFormat)(d); } + format = function(d) { + if (options.numberFormat == "pavian") { + var formated_num; + if (d > 1000) { + formated_num = d3.format(".3s")(d); + } else if (d > 0.001) { + formated_num = d3.format(".3r")(d); + } else { + formated_num = d3.format(".3g")(d); + } + + if (formated_num.indexOf('.') >= 0) { + if (formated_num.search(/[0-9]$/ >= 0)) { + formated_num = formated_num.replace(/0+$/, ""); + formated_num = formated_num.replace(/\.$/, ""); + } else { + formated_num = formated_num.replace(/0+(.)/, "$1"); + formated_num = formated_num.replace(/\.(.)/, "$1"); + } + } + + } else { + formated_num = d3.format(options.numberFormat)(d); + } + return formated_num; + } // create d3 sankey layout sankey .nodes(d3.values(nodes)) .align(options.align) .links(links) - .size([width, height]) + .size([width, height - 20]) .linkType(options.linkType) .nodeWidth(options.nodeWidth) .nodePadding(options.nodePadding) .scaleNodeBreadthsByString(options.scaleNodeBreadthsByString) .curvature(options.curvature) .orderByPath(options.orderByPath) + .showNodeValues(options.showNodeValues) + .nodeCornerRadius(options.nodeCornerRadius) .layout(options.iterations); + var max_posX = d3.max(sankey.nodes(), function(d) { return d.posX; }); + sankey.nodes().forEach(function(node) { node.x = node.x * options.xScalingFactor; }); + // thanks http://plnkr.co/edit/cxLlvIlmo1Y6vJyPs6N9?p=preview // http://stackoverflow.com/questions/22924253/adding-pan-zoom-to-d3js-force-directed // allow force drag to work with pan/zoom drag @@ -163,6 +199,18 @@ HTMLWidgets.widget({ zoom.on("zoom", null); } + + if (typeof options.title != "undefined") { + svg.append("text") + .attr("dy", ".5em") + .attr("render-order", -1) + .text(options.title) + .style("cursor", "move") + .style("fill", "black") + .style("font-size", "28px") + .style("font-family", options.fontFamily ? options.fontFamily : "inherit"); + + } // draw path var draw_link = sankey.link(); @@ -179,7 +227,7 @@ HTMLWidgets.widget({ return Math.max(1, d.dy); } - if (options.linkType != "trapez") { + if (options.linkType == "bezier" || options.linkType == "l-bezier") { link.attr("d", draw_link) .style("stroke", color_link) .style("stroke-width", min1_or_dy) @@ -199,6 +247,7 @@ HTMLWidgets.widget({ link .sort(function(a, b) { return b.dy - a.dy; }) .on("mouseover", function(d) { + if (d.inactive) return; var sel = d3.select(this); if (options.highlightChildLinks) { sel = link.filter(function(d1, i) { return(d == d1 || is_child(d.target, d1)); } ) @@ -208,6 +257,7 @@ HTMLWidgets.widget({ .style("fill-opacity", function(d){return opacity_link(d) + opacity_link_plus}); }) .on("mouseout", function(d) { + if (d.inactive) return; var sel = d3.select(this); if (options.highlightChildLinks) { sel = link.filter(function(d1, i) { return(d == d1 || is_child(d.target, d1)); } ) @@ -229,20 +279,25 @@ HTMLWidgets.widget({ .data(sankey.nodes()) function is_child (source_node, target_link) { - if (!source_node.sourceLinks) - return false; - for (var i=0; i < source_node.sourceLinks.length; i++) { if (source_node.sourceLinks[i] == target_link || is_child(source_node.sourceLinks[i].target, target_link)) { return true; } } + return false; + } + function is_child_node (source_node, target_node) { + for (var i=0; i < source_node.sourceLinks.length; i++) { + if (source_node.sourceLinks[i].target == target_node || is_child_node(source_node.sourceLinks[i].target, target_node)) { + return true; + } + } + return false; } var newNode = node.enter().append("g") .attr("class", "node") - .attr("transform", function(d) { return "translate(" + - d.x + "," + d.y + ")"; }) + .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }) // change here if you wanna change .call(d3.drag() .subject(function(d) { return d; }) .on("start", function() { @@ -250,22 +305,95 @@ HTMLWidgets.widget({ this.parentNode.appendChild(this); }) .on("drag", dragmove)) - .on("mouseover", function(d) { - if (options.highlightChildLinks) { + .on("mouseover", function(d,i) { + if (d.inactive) return; + + if (options.highlightChildLinks) { link.filter(function(d1, i) { return(is_child(d, d1)); } ) .style("stroke-opacity", function(d){return opacity_link(d) + opacity_link_plus}) .style("fill-opacity", function(d){return opacity_link(d) + opacity_link_plus}); - } + } + + var t = setTimeout(function() { + Shiny.onInputChange(el.id + '_hover', d.name); + }, 1000); + $(this).data('timeout', t); + }) - .on("mouseout", function(d) { - if (options.highlightChildLinks) { + .on("mouseout", function(d,i) { + clearTimeout($(this).data('timeout')); // Clear hover timeout when the mouse leaves the node + + if (d.inactive) return; + if (options.highlightChildLinks) { link.filter(function(d1, i) { return(is_child(d, d1)); } ) .style("stroke-opacity", opacity_link) .style("fill-opacity", opacity_link); - } + } }) .on("click", function(d) { - Shiny.onInputChange(el.id + '_clicked', d.name) + Shiny.onInputChange(el.id + '_clicked', d.name); + }) + .on("dblclick", function(d) { + Shiny.onInputChange(el.id + '_clicked', d.name); + if (!options.doubleclickTogglesChildren) { + return; + } + d.inactive = !d.inactive; + + if (d.inactive) { + d3.select(this).selectAll('rect').style("stroke-width",options.nodeStrokeWidth+2) + d3.select(this).selectAll('text').style('font-weight', 'bold'); + } else { + d3.select(this).selectAll('rect').style("stroke-width",options.nodeStrokeWidth) + d3.select(this).selectAll('text').style('font-weight', 'normal'); + } + + /* Draft of a transform animation of the links - not working currently + if (d.inactive) { + link.filter(function(d1, i) { if(is_child(d, d1)) { d1.inactive = d.inactive; return true; } else { return false; }} ) + .attr("transform", "scale(1, 1)") + .transition().duration(500) + .delay(function(d1) { + var delay = (d1.source.posX - d.posX )*100 + 50; + if (!d.inactive) delay = 500 - delay; + return delay + } ) + .attr("transform", "scale(0, 1)"); + } else { + link.filter(function(d1, i) { if(is_child(d, d1)) { d1.inactive = d.inactive; return true; } else { return false; }} ) + .attr("transform", "scale(0, 1)") + .transition().duration(500) + .delay(function(d1) { + var delay = (d1.source.posX - d.posX )*100 + 50; + if (!d.inactive) delay = 500 - delay; + return delay + } ) + .attr("transform", "scale(1, 1)"); + + } */ + + var max_delay = (max_posX - d.posX) * animation_duration; + + link.filter(function(d1, i) { if(is_child(d, d1)) { d1.inactive = d.inactive; return true; } else { return false; }} ) + .transition().duration(max_delay) + .delay(function(d1) { + var delay = (d1.source.posX - d.posX + .5)*animation_duration; + if (d.inactive) delay = max_delay - delay; + return delay + } ) + .style("stroke-opacity", opacity_link) + .style("fill-opacity", opacity_link); + + var new_opacity = d.inactive? 0 : opacity_node; + node.filter(function(d1, i) { if(is_child_node(d, d1)) { d1.inactive = d.inactive; return true; } else { return false; }} ) + .transition().duration(max_delay) + .delay(function(d1) { + var delay = (d1.posX - d.posX + .5)*animation_duration; + if (d.inactive) delay = max_delay - delay; + return delay + }) + .style("opacity", new_opacity); + }); node = newNode.merge(node); @@ -290,8 +418,10 @@ HTMLWidgets.widget({ }) .style("stroke", function(d) { return d3.rgb(d.color).darker(2); }) .style("stroke-width", options.nodeStrokeWidth) - .style("opacity", 0.9) + .style("opacity", opacity_node ) .style("cursor", "move") + .attr("rx", options.nodeCornerRadius) + .attr("ry", options.nodeCornerRadius) .append("title") .attr("class", "tooltip") .attr("title", function(d) { return format(d.value); }) @@ -301,18 +431,53 @@ HTMLWidgets.widget({ node .append("text") - .attr("x", - 1) + .attr("x", - 2) .attr("y", function(d) { return d.dy / 2; }) + .attr("class", "node-text") .attr("dy", ".35em") .attr("text-anchor", "end") .attr("transform", null) .text(function(d) { return d.name; }) + .style("cursor", "move") .style("font-size", options.fontSize + "px") .style("font-family", options.fontFamily ? options.fontFamily : "inherit") .filter(function(d) { return d.x < width / 2 || (options.align == "none"); }) - .attr("x", 1 + sankey.nodeWidth()) + .attr("x", 2 + sankey.nodeWidth()) .attr("text-anchor", "start"); + + if (options.showNodeValues) { + node + .append("text") + .attr("x", sankey.nodeWidth()/2) + .attr("text-anchor", "middle") + .attr("dy", "-.1em") + .attr("transform", null) + .attr("class", "node-number") + .text(function(d) { return format(d.value); }) + .style("cursor", "move") + .style("font-size", ( options.fontSize - 2) + "px") + .style("font-family", options.fontFamily ? options.fontFamily : "inherit"); + } + + + // Create an array with values from 1 to max_posX + //var axis_domain = new Array(max_posX + 1) + // .join().split(',') + // .map(function(item, index){ return ++index;}) + + if (options.xAxisDomain) { + + var axisXPos = new Array(options.xAxisDomain.length); + sankey.nodes().forEach(function(node) { axisXPos[node.posX] = node.x + options.nodeWidth/2; }); + + console.log([options.xAxisDomain,axisXPos]) + + var x = d3.scaleOrdinal().domain(options.xAxisDomain).range(axisXPos); + svg.append("g").attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") // move into position + .call(d3.axisBottom(x)); + } // adjust viewBox to fit the bounds of our tree /* // doesn't work with D3 v4 diff --git a/man/sankeyNetwork.Rd b/man/sankeyNetwork.Rd index 19355aa..9d4cecf 100644 --- a/man/sankeyNetwork.Rd +++ b/man/sankeyNetwork.Rd @@ -12,10 +12,13 @@ sankeyNetwork(Links, Nodes, Source, Target, Value, NodeID, NodeGroup = NodeID, LinkGroup = NULL, NodePosX = NULL, NodeValue = NULL, units = "", colourScale = JS("d3.scaleOrdinal().range(d3.schemeCategory20)"), fontSize = 7, fontFamily = NULL, nodeWidth = 15, nodePadding = 10, - nodeStrokeWidth = 1, margin = NULL, numberFormat = ",.5g", - orderByPath = FALSE, highlightChildLinks = FALSE, height = NULL, - width = NULL, iterations = 32, zoom = FALSE, align = "justify", - linkType = "bezier", curvature = 0.5, scaleNodeBreadthsByString = FALSE) + nodeStrokeWidth = 1, nodeCornerRadius = 0, margin = NULL, + title = NULL, numberFormat = ",.5g", orderByPath = FALSE, + highlightChildLinks = FALSE, doubleclickTogglesChildren = FALSE, + xAxisDomain = NULL, height = NULL, width = NULL, iterations = 32, + zoom = FALSE, align = "justify", showNodeValues = TRUE, + linkType = "bezier", curvature = 0.5, scaleNodeBreadthsByString = FALSE, + xScalingFactor = 1) } \arguments{ \item{Links}{a data frame object with the links between the nodes. It should @@ -70,6 +73,8 @@ scale for the nodes. See \item{nodeStrokeWidth}{numeric width of the stroke around nodes.} +\item{nodeCornerRadius}{numberic Radius for rounded nodes.} + \item{margin}{an integer or a named \code{list}/\code{vector} of integers for the plot margins. If using a named \code{list}/\code{vector}, the positions \code{top}, \code{right}, \code{bottom}, \code{left} @@ -77,14 +82,22 @@ are valid. If a single integer is provided, then the value will be assigned to the right margin. Set the margin appropriately to accomodate long text labels.} -\item{numberFormat}{number format in toolstips - see https://github.com/d3/d3-format for options} +\item{title}{character Title of plot, put in the upper-left corner of the Sankey} + +\item{numberFormat}{number format in toolstips - see https://github.com/d3/d3-format for options.} -\item{orderByPath}{Order the nodes vertically along a path - this layout only +\item{orderByPath}{boolean Order the nodes vertically along a path - this layout only works well for trees where each node has maximum one parent.} -\item{highlightChildLinks}{Highlight all the links going right from a node or +\item{highlightChildLinks}{boolean Highlight all the links going right from a node or link.} +\item{doubleclickTogglesChildren}{boolean Show/hide target nodes and paths to the left +on double-click. Does not hide incoming links of target nodes, yet.} + +\item{xAxisDomain}{character[] If xAxisDomain is given, an axis with those value is +added to the bottom of the plot. Only sensible when also NodeXPos are given.} + \item{height}{numeric height for the network graph's frame area in pixels.} \item{width}{numeric width for the network graph's frame area in pixels.} @@ -99,12 +112,16 @@ zooming} \item{align}{character Alignment of the nodes. One of 'right', 'left', 'justify', 'center', 'none'. If 'none', then the labels of the nodes are always to the right of the node.} -\item{linkType}{character One of 'bezier', 'l-bezier', and trapezoid.} +\item{showNodeValues}{boolean Show values above nodes. Might require and increased node margin.} + +\item{linkType}{character One of 'bezier', 'l-bezier', 'trapezoid', 'path1' and 'path2'.} \item{curvature}{numeric Curvature parameter for bezier links - between 0 and 1.} \item{scaleNodeBreadthsByString}{Put nodes at positions relatively to string lengths - only work well currently with align='none'} + +\item{xScalingFactor}{numeric Scale the computed x position of the nodes by this value.} } \description{ Create a D3 JavaScript Sankey diagram