D3 (and other javascript) in R

Leave abandoned wrapper packages behinds and do it yourself

D3 and ObservablePlot are awesome data visualization packages. Unfortunately, the packages to work with D3 from R are no longer maintained (R2D3 was last updated in 2021, and the obsplot R package was abandoned in 2023). The challenge is that D3 is still very actively developed. So any new package risks being outpaced by Mike Bostock’s hyper-productivity.

Instead of a new package, I will show how to dynamically load any Javascript library and pass data from R to Javascript. I will use the jrc package to set up the communication channel between R and Javascript1.

I adapted the following code from the D3 gallery:

# I am using R raw strings to avoid conflicting quotation marks
javascript_code <- r"(
// Import the latest D3 version (or whichever you want!)
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";

function chart(data, drag){

  // Specify the chart's dimensions.
  const width = 928;
  const height = 600;

  // Compute the graph and start the force simulation.
  const root = d3.hierarchy(data);
  const links = root.links();
  const nodes = root.descendants();

  const simulation = d3.forceSimulation(nodes)
      .force("link", d3.forceLink(links).id(d => d.id).distance(0).strength(1))
      .force("charge", d3.forceManyBody().strength(-50))
      .force("x", d3.forceX())
      .force("y", d3.forceY());

  // Create the container SVG.
  const svg = d3.create("svg")
      .attr("width", width)
      .attr("height", height)
      .attr("viewBox", [-width / 2, -height / 2, width, height])
      .attr("style", "max-width: 100%; height: auto;");

  // Append links.
  const link = svg.append("g")
      .attr("stroke", "#999")
      .attr("stroke-opacity", 0.6)
    .selectAll("line")
    .data(links)
    .join("line");

  // Append nodes.
  const node = svg.append("g")
      .attr("fill", "#fff")
      .attr("stroke", "#000")
      .attr("stroke-width", 1.5)
    .selectAll("circle")
    .data(nodes)
    .join("circle")
      .attr("fill", d => d.children ? null : "#000")
      .attr("stroke", d => d.children ? null : "#fff")
      .attr("r", 3.5)
      .call(drag(simulation));

  node.append("title")
      .text(d => d.data.name);

  simulation.on("tick", () => {
    link
        .attr("x1", d => d.source.x)
        .attr("y1", d => d.source.y)
        .attr("x2", d => d.target.x)
        .attr("y2", d => d.target.y);

    node
        .attr("cx", d => d.x)
        .attr("cy", d => d.y);
  });

  return svg.node();
}

function drag(simulation){ 
    function dragstarted(event, d) {
      if (!event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }
    
    function dragged(event, d) {
      d.fx = event.x;
      d.fy = event.y;
    }
    
    function dragended(event, d) {
      if (!event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }
    
    return d3.drag()
        .on("start", dragstarted)
        .on("drag", dragged)
        .on("end", dragended);
}

// The data is hosted at http://const-ae.name/post/2024-12-02_d3_plots_in_r/d3_in_r/flare-2.json
const data = await d3.json("./flare-2.json")
console.log(data)
document.querySelector("#container").append(chart(data, drag));
)"
# Helper function to escape javascript template characters
escape_js_template_str <- function(x){
  x <- gsub("`", "\\\\`", x = x)
  x <- gsub("\\$\\{", "\\\\${", x = x)
  x
}

To get the Javascript into a browser, we will use jrc. We first need to open a new page (this should open in Rstudio’s Viewer or in your default browser).

jrc::openPage()

Next, we add an empty div to the page to which we will dynamically add content. Then we send the javascript code to the website. (We have to wrap the code in a blob and load it with import so that the code supports ES6 modules.)

jrc::sendHTML("<div id='container'></div>")
jrc::sendCommand(paste0("var module_code = `", escape_js_template_str(javascript_code), "`;\n",
                        "import(URL.createObjectURL(new Blob([module_code], {type: 'application/javascript'})));"))

This will create the following interactive plot:

Observable Plot

The Observable Plot abstracts away some of the D3 boilerplate code. It is heavily inspired by ggplot2, so many R programmers will feel right at home.

javascript_code = r"(
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm";
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";

const pl_obj = Plot.rectY({length: 10000}, Plot.binX({y: "count"}, {x: d3.randomNormal()})).plot();

document.querySelector("#container").append(pl_obj);
)"
jrc::sendCommand(paste0("var module_code = `", escape_js_template_str(javascript_code), "`;\n",
                        "import(URL.createObjectURL(new Blob([module_code], {type: 'application/javascript'})));"))

Sending data

The jrc::sendData function sends a data.frame from R to Javascript. We will send the mtcars object, which is internally converted to JSON via jsonlite::toJSON.

head(mtcars)
##                    mpg cyl disp  hp drat    wt  qsec vs am gear carb
## Mazda RX4         21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
## Mazda RX4 Wag     21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
## Datsun 710        22.8   4  108  93 3.85 2.320 18.61  1  1    4    1
## Hornet 4 Drive    21.4   6  258 110 3.08 3.215 19.44  1  0    3    1
## Hornet Sportabout 18.7   8  360 175 3.15 3.440 17.02  0  0    3    2
## Valiant           18.1   6  225 105 2.76 3.460 20.22  1  0    3    1
plot(mtcars$mpg, mtcars$cyl, col = as.factor(mtcars$gear > 3))

The string provided as the first argument in sendData will be used as a global variable name in Javascript.

jrc::sendData("r_mtcars", tibble::remove_rownames(mtcars))

Making a scatter plot in Observable Plot:

javascript_code = r"(
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm";
const pl_obj = Plot.plot({
  marks: [
    Plot.dot(r_mtcars, {x: "mpg", y: "cyl", fill: (d) => d.gear > 3 ? "red" : "black"})
  ]
});

document.querySelector("#container").replaceChildren(pl_obj);
)"
jrc::sendCommand(paste0("var module_code = `", escape_js_template_str(javascript_code), "`;\n",
                        "import(URL.createObjectURL(new Blob([module_code], {type: 'application/javascript'})));"))

Conclusion

With just a few lines of Javascript and with the help of the jrc package we can make awesome, interactive visualizations.

Session Info

sessionInfo()
## R version 4.4.1 (2024-06-14)
## Platform: aarch64-apple-darwin20
## Running under: macOS Sonoma 14.6
## 
## Matrix products: default
## BLAS:   /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRblas.0.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: Europe/London
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## loaded via a namespace (and not attached):
##  [1] vctrs_0.6.5       cli_3.6.3         knitr_1.49        rlang_1.1.4      
##  [5] xfun_0.49         stringi_1.8.4     jsonlite_1.8.9    glue_1.8.0       
##  [9] htmltools_0.5.8.1 sass_0.4.9        hms_1.1.3         fansi_1.0.6      
## [13] rmarkdown_2.29    evaluate_1.0.1    jquerylib_0.1.4   tibble_3.2.1     
## [17] tzdb_0.4.0        fastmap_1.2.0     yaml_2.3.10       lifecycle_1.0.4  
## [21] bookdown_0.41     stringr_1.5.1     compiler_4.4.1    pkgconfig_2.0.3  
## [25] rstudioapi_0.17.1 blogdown_1.19     digest_0.6.37     R6_2.5.1         
## [29] utf8_1.2.4        readr_2.1.5       pillar_1.9.0      magrittr_2.0.3   
## [33] bslib_0.8.0       tools_4.4.1       cachem_1.1.0

  1. Shout-out to my former colleague Svetlana Ovchinnikova for the neat package↩︎