From 0692bca9aac6d636b9feddd0ada336d01793761e Mon Sep 17 00:00:00 2001 From: CJ Yetman Date: Thu, 21 Dec 2023 22:53:22 +0100 Subject: [PATCH] modernized sankey --- R/sankey_network.R | 17 +-- inst/sankey_network.js | 231 ++++++++++++++++++++++++----------------- 2 files changed, 137 insertions(+), 111 deletions(-) diff --git a/R/sankey_network.R b/R/sankey_network.R index 0398937..958e65b 100644 --- a/R/sankey_network.R +++ b/R/sankey_network.R @@ -22,20 +22,7 @@ sankey_network <- function(data, width = NULL, height = NULL, ..., viewer = "int UTC = TRUE, rownames = FALSE, keep_vec_names = TRUE, json_verabitm = TRUE) - options <- list( - linkStrokeOpacity = ifelse(methods::hasArg("linkStrokeOpacity"), list(...)$linkStrokeOpacity, 0.3), - linkMixBlendMode = ifelse(methods::hasArg("linkMixBlendMode"), list(...)$linkMixBlendMode, "multiply"), - linkPath = ifelse(methods::hasArg("linkPath"), list(...)$linkPath, "d3.sankeyLinkHorizontal()"), - linkColor = ifelse(methods::hasArg("linkColor"), list(...)$linkColor, "source-target"), - nodeAlign = ifelse(methods::hasArg("nodeAlign"), list(...)$nodeAlign, "justify"), - nodeGroup = ifelse(methods::hasArg("nodeGroup"), list(...)$nodeGroup, "group"), - nodeWidth = ifelse(methods::hasArg("nodeWidth"), list(...)$nodeWidth, 15), - nodePadding = ifelse(methods::hasArg("nodePadding"), list(...)$nodePadding, 10), - nodeLabelPadding = ifelse(methods::hasArg("nodeLabelPadding"), list(...)$nodeLabelPadding, 6), - nodeLabelFontFamily = ifelse(methods::hasArg("nodeLabelFontFamily"), list(...)$nodeLabelFontFamily, "sans-serif"), - nodeLabelFontSize = ifelse(methods::hasArg("nodeLabelFontSize"), list(...)$nodeLabelFontSize, 10), - colors = ifelse(methods::hasArg("color"), list(...)$color, "d3.schemeCategory10") - ) + options <- list(...) r2d3::r2d3( data = data, @@ -43,7 +30,7 @@ sankey_network <- function(data, width = NULL, height = NULL, ..., viewer = "int script = system.file("sankey_network.js", package = "network.r2d3"), dependencies = system.file("lib/d3-sankey/d3-sankey.min.js", package = "network.r2d3"), d3_version = 6, - container = "svg", + container = "div", width = width, height = height, viewer = viewer diff --git a/inst/sankey_network.js b/inst/sankey_network.js index 7bb9f93..0904c35 100644 --- a/inst/sankey_network.js +++ b/inst/sankey_network.js @@ -1,107 +1,146 @@ -// !preview r2d3 data = jsonlite::toJSON(list(nodes=data.frame(id=c(0,1,2,3,4,5,6),name=c("node0","node1","node2","node3","node4","node5","node6"),group=c("grp1","grp1","grp2","grp2","grp2","grp3","grp3")),links=data.frame(source=c(0,1,1,1,0,2,2,3,5),target=c(2,2,3,5,4,3,4,4,6),value=c(2,2,2,2,2,2,2,4,4)))), dependencies = "inst/lib/d3-sankey/d3-sankey.min.js", d3_version = 6, width = 600, height = 300, options = list(linkStrokeOpacity=0.3,linkMixBlendMode="multiply",linkPath="d3.sankeyLinkHorizontal()",linkColor="source-target",nodeAlign="justify",nodeGroup="group",nodeWidth=15,nodePadding=10,nodeLabelPadding=6,nodeLabelFontFamily="sans-serif",nodeLabelFontSize=10,colors="d3.schemeCategory10"), viewer = "internal" - -r2d3.onRender(function(data, svg, width, height, options) { - let linkStrokeOpacity = options.linkStrokeOpacity; - let linkMixBlendMode = options.linkMixBlendMode; - let linkPath = eval(options.linkPath); - let linkColor = options.linkColor; - let nodeAlign = options.nodeAlign; - let nodeGroup = options.nodeGroup; - let nodeWidth = options.nodeWidth; - let nodePadding = options.nodePadding; - let nodeLabelPadding = options.nodeLabelPadding; - let nodeLabelFontFamily = options.nodeLabelFontFamily; - let nodeLabelFontSize = options.nodeLabelFontSize; - let colors = eval(options.colors); - - const uid = `O-${Math.random().toString(16).slice(2)}`; - - nodeAlign = { - left: d3.sankeyLeft, - right: d3.sankeyRight, - center: d3.sankeyCenter - }[nodeAlign] ?? d3.sankeyJustify; - - const formatNumber = d3.format(",.0f"); - - const color = d3.scaleOrdinal(colors); - - let sankey = d3.sankey() - .nodeId(function id(d) { return d.id; }) +// !preview r2d3 data = jsonlite::toJSON(list(links = data.frame(source = c("A", "A"), target = c("B", "C"), value = 10), nodes = data.frame(name = c("A", "B", "C"), group = c("A", "B", "C")))), dependencies = "inst/lib/d3-sankey/d3-sankey.min.js", d3_version = 6, options = list(linkStrokeOpacity=0.3,nodeLabelPadding=6), container = "div", viewer = "internal" + +r2d3.onRender(function(data, div, width, height, options) { + + const nodeAlign = options.nodeAlign ?? "sankeyJustify"; + const nodeWidth = options.nodeWidth ?? 24; + const nodePadding = options.nodePadding ?? 8; + const nodeGroup = options.nodeGroup ?? "group"; + const colorScheme = options.colorScheme ?? "schemeCategory10"; + const linkColor = options.linkColor ?? "source-target"; + const nodeLabelFontFamily = options.nodeLabelFontFamily ?? "sans-serif"; + const nodeLabelFontSize = options.nodeLabelFontSize ?? 10; + const tooltipTransitionDuration = options.tooltipTransitionDuration ?? 200; + const tooltipOpacity = options.tooltipOpacity ?? 0.8; + const tooltipFontSize = options.tooltipFontSize ?? 12; + const tooltipFontFamily = options.tooltipFontFamily ?? "sans-serif"; + const tooltipBorderRadius = options.tooltipBorderRadius ?? 4; + + const color = d3.scaleOrdinal(d3[colorScheme]); + + const widgetPadding = 40; + + const format = d3.format(",.0f"); + + const svg = div.append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", [0, 0, width, height]) + .attr("style", "max-width: 100%; height: auto;"); + + const sankey = d3.sankey() + .nodeId(d => d.name) + .nodeAlign(d3[nodeAlign]) .nodeWidth(nodeWidth) - .nodeAlign(nodeAlign) .nodePadding(nodePadding) - .size([width, height]) - ; - - let sankeydata = sankey(data); - let links = sankeydata.links; - let nodes = sankeydata.nodes; - - // add in the links - const link_slct = svg - .append("g") - .attr("fill", "none") - .attr("stroke-opacity", linkStrokeOpacity) - .selectAll("g") + .extent([[1, 5], [width - 1, height - 5]]); + + const {nodes, links} = sankey({ + nodes: data.nodes.map(d => Object.assign({}, d)), + links: data.links.map(d => Object.assign({}, d)) + }); + + // add tooltip div + const tooltip_div = div.append("div") + .attr("class", "tooltip") + .style("opacity", 0) + .style("position", "absolute") + .style("text-align", "center") + .style("padding", "10px") + .style("font-size", tooltipFontSize + "px") + .style("font-family", tooltipFontFamily) + .style("background-color", "white") + .style("color", "black") + .style("border", "1px solid") + .style("border-radius", tooltipBorderRadius + "px") + .style("pointer-events", "none"); + + function mouseover(event, d) { + let tooltip_text = ""; + if (d.name === undefined) { + tooltip_text = d.source.name + " → " + d.target.name + "
" + format(d.value); + } else { + tooltip_text = d.name + "
" + format(d.value); + } + tooltip_div.transition() + .duration(tooltipTransitionDuration) + .style("opacity", tooltipOpacity); + tooltip_div.html(tooltip_text) + .style("left", event.pageX + "px") + .style("top", (event.pageY - widgetPadding) + "px"); + } + + function mousemove(event) { + tooltip_div + .style("left", event.pageX + "px") + .style("top", (event.pageY - widgetPadding) + "px"); + } + + function mouseout() { + tooltip_div.transition() + .duration(tooltipTransitionDuration) + .style("opacity", 0); + } + + // build nodes + svg.append("g") + .attr("stroke", "#000") + .selectAll() + .data(nodes) + .join("rect") + .attr("x", d => d.x0) + .attr("y", d => d.y0) + .attr("height", d => d.y1 - d.y0) + .attr("width", d => d.x1 - d.x0) + .attr("fill", d => color(d[nodeGroup])) + .on("mouseover", mouseover) + .on("mousemove", mousemove) + .on("mouseout", mouseout); + + svg.append("g") + .selectAll() + .data(nodes) + .join("text") + .attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6) + .attr("y", d => (d.y1 + d.y0) / 2) + .attr("dy", "0.35em") + .attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end") + .text(d => d.name) + .style("font-size", nodeLabelFontSize + "px") + .style("font-family", nodeLabelFontFamily); + + // build links + const link = svg.append("g") + .attr("fill", "none") + .attr("stroke-opacity", 0.5) + .selectAll() .data(links) .join("g") - .style("mix-blend-mode", linkMixBlendMode) - ; + .style("mix-blend-mode", "multiply"); if (linkColor === "source-target") { - link_slct.append("linearGradient") - .attr("id", d => `${uid}-link-${d.index}`) - .attr("gradientUnits", "userSpaceOnUse") - .attr("x1", d => d.source.x1) - .attr("x2", d => d.target.x0) - .call(gradient => gradient.append("stop") + const gradient = link.append("linearGradient") + .attr("id", (d, i) => (d.uid = `link-${i}`)) + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", d => d.source.x1) + .attr("x2", d => d.target.x0); + gradient.append("stop") .attr("offset", "0%") - .attr("stop-color", d => color(d.source[nodeGroup])) - ) - .call(gradient => gradient.append("stop") + .attr("stop-color", d => color(d.source[nodeGroup])); + gradient.append("stop") .attr("offset", "100%") - .attr("stop-color", d => color(d.target[nodeGroup])) - ) - ; + .attr("stop-color", d => color(d.target[nodeGroup])); } - link_slct.append("path") - .attr("d", linkPath) - .attr("stroke", linkColor === "source-target" ? d => `url(#${uid}-link-${d.index})` - : linkColor === "source" ? d => color(d.source[nodeGroup]) - : linkColor === "target" ? d => color(d.target[nodeGroup]) - : linkColor) - .attr("stroke-width", ({width}) => Math.max(1, width)) - .append("title") - .text(d => d.source.name + " → " + d.target.name + "\n" + formatNumber(d.value)) - ; - - const node_slct = svg.append("g") - .selectAll(".node") - .data(nodes) - .join("rect") - .attr("class", "node") - .attr("x", d => d.x0) - .attr("y", d => d.y0) - .attr("height", d => d.y1 - d.y0) - .attr("width", sankey.nodeWidth()) - .style("fill", d => d.color = color(d[nodeGroup])) - .style("stroke", d => d3.rgb(d.color).darker(2)) - ; - - node_slct.append("title").text(d => d.name + "\n" + formatNumber(d.value)); - - const nodeLabel_slct = svg.append("g") - .attr("font-family", nodeLabelFontFamily) - .attr("font-size", nodeLabelFontSize) - .selectAll("text") - .data(nodes) - .join("text") - .attr("x", d => d.x0 < width / 2 ? d.x1 + nodeLabelPadding : d.x0 - nodeLabelPadding) - .attr("y", d => (d.y1 + d.y0) / 2) - .attr("dy", "0.35em") - .attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end") - .text(d => d.name) - ; + link.append("path") + .attr("d", d3.sankeyLinkHorizontal()) + .attr("stroke", linkColor === "source-target" ? (d) => `url(#${d.uid})` + : linkColor === "source" ? (d) => color(d.source[nodeGroup]) + : linkColor === "target" ? (d) => color(d.target[nodeGroup]) + : linkColor) + .attr("stroke-width", d => Math.max(1, d.width)) + .on("mouseover", mouseover) + .on("mousemove", mousemove) + .on("mouseout", mouseout); + });