Dynamic variable selection in r2d3

321 Views Asked by At

I'm trying to take a d3 example script and turn it into a plotting function using r2d3. I can use r2d3's "options" parameter to pass strings into the d3 script, but I can't figure out a way to pass in expressions (which, I think, is necessary to make it into a generic plotting function). Working from the example here https://bl.ocks.org/john-guerra/17fe498351a3e70929e2e36d081e1067 my .js script is called scatter_matrix.js:

// !preview r2d3 data=small_cars, d3_version = "3", css = "scatter_matrix.css", options = list(exclude = "mpg")
// r2d3: https://rstudio.github.io/r2d3
//

var size = 200,
    padding = 20;

var x = d3.scale.linear()
    .range([padding / 2, size - padding / 2]);

var y = d3.scale.linear()
    .range([size - padding / 2, padding / 2]);

var xAxis = d3.svg.axis()
    .scale(x)
    .orient("bottom")
    .ticks(6);

 var yAxis = d3.svg.axis()
    .scale(y)
    .orient("left")
    .ticks(6);

 var color = d3.scale.linear()
               .domain([9, 30])
               .range(["red", "green"]);  

// I might need to include more of the lines outside this functions, inside the function?
r2d3.onRender(function(data, svg, width, height, options, error) {
  if (error) throw error;

  var domainByTrait = {},
      traits = d3.keys(data[0]).filter(function(d) { return d !== options.exclude; }),
      n = traits.length;

  traits.forEach(function(trait) {
    domainByTrait[trait] = d3.extent(data, function(d) { return d[trait]; });

});

  xAxis.tickSize(size * n);
  yAxis.tickSize(-size * n);

  var brush = d3.svg.brush()
      .x(x)
      .y(y)
      .on("brushstart", brushstart)
      .on("brush", brushmove)
      .on("brushend", brushend);

  svg.selectAll(".x.axis")
      .data(traits)
    .enter().append("g")
      .attr("class", "x axis")
      .attr("transform", function(d, i) { return "translate(" + (n - i - 1) * size + ",0)"; })
      .each(function(d) { x.domain(domainByTrait[d]); d3.select(this).call(xAxis); });

  svg.selectAll(".y.axis")
      .data(traits)
    .enter().append("g")
      .attr("class", "y axis")
      .attr("transform", function(d, i) { return "translate(0," + i * size + ")"; })
      .each(function(d) { y.domain(domainByTrait[d]); d3.select(this).call(yAxis); });

  var cell = svg.selectAll(".cell")
      .data(cross(traits, traits))
    .enter().append("g")
      .attr("class", "cell")
      .attr("transform", function(d) { return "translate(" + (n - d.i - 1) * size + "," + d.j * size + ")"; })
      .each(plot);

  // Titles for the diagonal.
  cell.filter(function(d) { return d.i === d.j; }).append("text")
      .attr("x", padding)
      .attr("y", padding)
      .attr("dy", ".71em")
      .text(function(d) { return d.x; });

  cell.call(brush);

  function plot(p) {
    var cell = d3.select(this);

    x.domain(domainByTrait[p.x]);
    y.domain(domainByTrait[p.y]);

    cell.append("rect")
        .attr("class", "frame")
        .attr("x", padding / 2)
        .attr("y", padding / 2)
        .attr("width", size - padding)
        .attr("height", size - padding);

    cell.selectAll("circle")
        .data(data)
      .enter().append("circle")
        .attr("cx", function(d) { return x(d[p.x]); })
        .attr("cy", function(d) { return y(d[p.y]); })
        .attr("r", 4)
        .style("opacity", 0.5)
        .style("fill", function(d) { return color(d.mpg); });
  }

  var brushCell;

  // Clear the previously-active brush, if any.
  function brushstart(p) {
    if (brushCell !== this) {
      d3.select(brushCell).call(brush.clear());
      x.domain(domainByTrait[p.x]);
      y.domain(domainByTrait[p.y]);
      brushCell = this;
    }
  }

  // Highlight the selected circles.
  function brushmove(p) {
    var e = brush.extent();
    svg.selectAll("circle").classed("hidden", function(d) {
      return e[0][0] > d[p.x] || d[p.x] > e[1][0]
          || e[0][1] > d[p.y] || d[p.y] > e[1][1];
    });
  }

  // If the brush is empty, select all circles.
  function brushend() {
    if (brush.empty()) svg.selectAll(".hidden").classed("hidden", false);
  }
});

function cross(a, b) {
  var c = [], n = a.length, m = b.length, i, j;
  for (i = -1; ++i < n;) for (j = -1; ++j < m;) c.push({x: a[i], i: i, y: b[j], j: j});
  return c;
}

and scatter_matrix.css is:

svg {
  font: 10px sans-serif;
  padding: 10px;
}

.axis,
.frame {
  shape-rendering: crispEdges;
}

.axis line {
  stroke: #ddd;
}

.axis path {
  display: none;
}

.cell text {
  font-weight: bold;
  text-transform: capitalize;
}

.frame {
  fill: none;
  stroke: #aaa;
}

circle {
  fill-opacity: .125;
}

circle.hidden {
  fill: #ccc !important;
}

.extent {
  fill: #000;
  fill-opacity: .125;
  stroke: #fff;
}

So, as you can see, I've changed

var domainByTrait = {},
      traits = d3.keys(data[0]).filter(function(d) { return d !== "species"; }),
      n = traits.length;

To

var domainByTrait = {},
          traits = d3.keys(data[0]).filter(function(d) { return d !== options.exclude; }),
          n = traits.length;

With

// !preview r2d3 data=small_cars, d3_version = "3", css = "scatter_matrix.css", options = list(exclude = "mpg")

making it possible to then making it possible to choose which variable to exclude from the plot. However, lower down there's

   cell.selectAll("circle")
            .data(data)
          .enter().append("circle")
            .attr("cx", function(d) { return x(d[p.x]); })
            .attr("cy", function(d) { return y(d[p.y]); })
            .attr("r", 4)
            .style("opacity", 0.5)
            .style("fill", function(d) { return color(d.mpg); });
      }

which is hard-coded into the script. I tried using

 options = list(exclude = "mpg", col = expr(d.mpg))

and then having

.style("fill", function(d) { return color(options.col); });
          }

But that didn't work. I'm using to using quasiquotation in R to solve this type of stuff, but I'm not sure if there's a way to do that in JS? d3 is amazing, but to be able to package the scripts up into functions would be... super amazing.

To reproduce this plot you should just need

small_cars <- mtcars %>% select(1:5)
r2d3::r2d3("catter_matrix.js", data=small_cars, d3_version = "3", css = "scatter_matrix.css", options = list(exclude = "mpg"))
1

There are 1 best solutions below

0
Tom Greenwood On

Actually, I think I've figured this out. You can use javascript's eval() function to parse strings into code. So, if I change scatter_matrix.js to

// !preview r2d3 data= small_cars,  d3_version = "3", css = "T:/R Code Library/r2d3_library/scatter_matrix.css", options = list(exclude = "mpg", col = "d.mpg", col_range = "[10, 35]")
// r2d3: https://rstudio.github.io/r2d3
// n.b small_cars <- mtcars %>% select(1:5)

function range(numbers) {
    numbers.sort();
    return [numbers[0], numbers[numbers.length - 1]];
}

var size = 200,
    padding = 20;

var x = d3.scale.linear()
    .range([padding / 2, size - padding / 2]);

var y = d3.scale.linear()
    .range([size - padding / 2, padding / 2]);

var xAxis = d3.svg.axis()
    .scale(x)
    .orient("bottom")
    .ticks(6);

 var yAxis = d3.svg.axis()
    .scale(y)
    .orient("left")
    .ticks(6);

 var color = d3.scale.linear()
               .domain(eval(options.col_range))
               .range(["red", "green"]);  

// I might need to include more of the lines outside this functions, inside the function?
r2d3.onRender(function(data, svg, width, height, options, error) {
  if (error) throw error;

  var domainByTrait = {},
      traits = d3.keys(data[0]).filter(function(d) { return d !== options.exclude; }),
      n = traits.length;

  traits.forEach(function(trait) {
    domainByTrait[trait] = d3.extent(data, function(d) { return d[trait]; });

});

  xAxis.tickSize(size * n);
  yAxis.tickSize(-size * n);

  var brush = d3.svg.brush()
      .x(x)
      .y(y)
      .on("brushstart", brushstart)
      .on("brush", brushmove)
      .on("brushend", brushend);

  svg.selectAll(".x.axis")
      .data(traits)
    .enter().append("g")
      .attr("class", "x axis")
      .attr("transform", function(d, i) { return "translate(" + (n - i - 1) * size + ",0)"; })
      .each(function(d) { x.domain(domainByTrait[d]); d3.select(this).call(xAxis); });

  svg.selectAll(".y.axis")
      .data(traits)
    .enter().append("g")
      .attr("class", "y axis")
      .attr("transform", function(d, i) { return "translate(0," + i * size + ")"; })
      .each(function(d) { y.domain(domainByTrait[d]); d3.select(this).call(yAxis); });

  var cell = svg.selectAll(".cell")
      .data(cross(traits, traits))
    .enter().append("g")
      .attr("class", "cell")
      .attr("transform", function(d) { return "translate(" + (n - d.i - 1) * size + "," + d.j * size + ")"; })
      .each(plot);

  // Titles for the diagonal.
  cell.filter(function(d) { return d.i === d.j; }).append("text")
      .attr("x", padding)
      .attr("y", padding)
      .attr("dy", ".71em")
      .text(function(d) { return d.x; });

  cell.call(brush);

  function plot(p) {
    var cell = d3.select(this);

    x.domain(domainByTrait[p.x]);
    y.domain(domainByTrait[p.y]);

    cell.append("rect")
        .attr("class", "frame")
        .attr("x", padding / 2)
        .attr("y", padding / 2)
        .attr("width", size - padding)
        .attr("height", size - padding);

    cell.selectAll("circle")
        .data(data)
      .enter().append("circle")
        .attr("cx", function(d) { return x(d[p.x]); })
        .attr("cy", function(d) { return y(d[p.y]); })
        .attr("r", 4)
        .style("opacity", 0.5)
        .style("fill", function(d) { return color(eval(options.col)); });
  }

  var brushCell;

  // Clear the previously-active brush, if any.
  function brushstart(p) {
    if (brushCell !== this) {
      d3.select(brushCell).call(brush.clear());
      x.domain(domainByTrait[p.x]);
      y.domain(domainByTrait[p.y]);
      brushCell = this;
    }
  }

  // Highlight the selected circles.
  function brushmove(p) {
    var e = brush.extent();
    svg.selectAll("circle").classed("hidden", function(d) {
      return e[0][0] > d[p.x] || d[p.x] > e[1][0]
          || e[0][1] > d[p.y] || d[p.y] > e[1][1];
    });
  }

  // If the brush is empty, select all circles.
  function brushend() {
    if (brush.empty()) svg.selectAll(".hidden").classed("hidden", false);
  }
});

function cross(a, b) {
  var c = [], n = a.length, m = b.length, i, j;
  for (i = -1; ++i < n;) for (j = -1; ++j < m;) c.push({x: a[i], i: i, y: b[j], j: j});
  return c;
}

So, basically just swapping in eval(options.col) for d.mpg and then added a col = "d.mpg" to the r2d3 options list. Also I realised the range for colours was hard-coded, so swapped in eval(options.col_range) for [9,30] and added a col_range element to the r2d3 options list. Having done that, you can then write a function in R, like

 plot_d3sc <- function(df, var) {

    col <- paste0("d.",var)
    col_range <- paste0("[",min(df[[var]], na.rm = T), ",", max(df[[var]], na.rm = T), "]")

    r2d3::r2d3("scatter_matrix.js", 
                  data= df, d3_version = "3", 
                  css = "scatter_matrix.css", 
                  options = list(exclude = var, 
                                 col = col, 
                                 col_range = col_range))
}

which allows you to pick your data and variable of interest from R, like

plot_d3sc(small_cars, "cyl")
plot_d3sc(small_cars, "mpg")