How to create a 'facetplot' in d3.js?

Viewed 545

Let's say I have a dataset of different athletes that perform the same test on different days. On each day, they will have several trials/runs. I want to visualise each athlete's development across these days using d3.js but I'm struggling to understand how I can accomplish this task.

Using seaborn in Python or ggplot2 in R, I would have used a facetplot where each day is a facet. Within these facets, I would have the trials on the x-axis and the performance on the I axis. But how can I do this in d3.js?

The d3.group and group allow me to group the dataset by athletes and I understand how I can iterate over each athlete's values. But I don't understand how I can go from here to actually creating a facetplot in d3.js.

I have tried to search for related tutorials on observable but without much luck. One interesting and related visualisation is[Trumps Golfs bt Bostock]. Another one is this scatterplot.

Could someone please help me in the right direction? I have created a simple dataset and scatterplot that may serve as a starter

const data = d3.range(10).map(i => ({
        bib: Math.floor(i / 5) + 1,
        ratio: -1 + Math.random() * 5,
        run: [1, 2, 3, 4, 5][i % 5],
        run: [1, 2, 3, 4, 5][i % 5],
        name: ['GIRL1', 'GIRL2', 'GIRL3', 'GIRL4'][Math.floor(i / 5)]
        }));

        const width = 250;
        const height = 150;
       
        const svg = d3.select('svg')
            .attr('width', width)
            .attr('height', height)

        const margin = {
            top: 20,
            right: 20,
            bottom: 20,
            left: 50
        }

        const innerWidth = width - margin.left - margin.right;
        const innerHeight = height - margin.top - margin.bottom;

        const xScale = d3.scaleLinear()
            .domain(d3.extent(data, d => d.run))
            .range([0, innerWidth])
     
        const yScale = d3.scaleLinear()
            .domain(d3.extent(data, d => d.ratio))
            .range([0, innerHeight])

        const g = svg.append('g')
            .attr('transform', `translate(${margin.left},${margin.top})`)

        g.selectAll('circle')
            .data(data)
            .join('circle')
            .attr('r', 3)
            .attr('cx', d => xScale(d.run))
            .attr('cy', d => yScale(d.ratio))

            g.append("g")
                .call(d3.axisBottom(xScale))
                .attr('transform', `translate(0,${innerHeight})`);;
            
            g.append("g")
                .call(d3.axisLeft(yScale))
<script src="https://unpkg.com/d3@6.2.0/dist/d3.min.js"></script>
<svg></svg>

Plot example from R:

enter image description here

2 Answers

Setting up the SVG

First let's set up a convention on how we'll space and structure the page. With only one row of charts, this is a bit easier.

enter image description here

We need to define the variables in the image above. Then we can derive each plot's width and height:

const height = 500;
const width = 500;
const margin = {
   top: 50,
   left: 50,
   right: 50,
   bottom: 50
}
const padding = 10; // labelled `pad` in image due to space constraints

const plotWidth = (width-padding)/numberOfPlots - padding;
const plotHeight = height-padding*2;

Of course you can arrange however you like, my inclusion of this is partially just to make the rest of the answer more understandable.

Below I use a g to hold the area bounded by the margins to make later positioning easier.

Now we also have two different scales, and you've already done this in your code, but we can now define the ranges with plotWidth and plotHeight using the above spacing convention.

Plotting the Data

Now we're ready to create the plots. To do so we'll create a nested data set: we want to group data by plot, and as you noted, we can use d3.group:

 const grouped = d3.group(data,d=>d.bib);

For each grouping we'll create a g and position using a translate based on the above structure:

 const plots = g.selectAll(".plot")
  .data(grouped)
  .enter()
  .append("g")
  .attr("transform", function(d,i) {
     return "translate("+[i*(padding+plotWidth)+padding,padding]+")";
   })
  

And we're ready to do our plotting using the nested data:

   plots.selectAll(null)
     .data(d=>d[1])
     .enter()
     .append("circle")
     ... // and so forth.

The snippet below uses d=>d[1] as this is where the array of items belonging to that group resides. d[0] is the identifier of the group. If my data array was an array of arrays, I'd simply use d=>d;.

Now we could append the x axis on each plot:

 plots.append("g")
   .attr("transform","translate("+[0,plotHeight]+")")
   .call(d3.axisBottom(x));

And a y axis:

 g.append("g")
   .attr("transform","translate("+[0,padding]+")");
   .call(d3.axisLeft(y));

I haven't added a parent x axis, depending on your use you may want to label it, or not. I also notice that I grouped your data by bib, not by run, but the principles remain the same: group the data, enter and position the parent plots, then add the data points to the parents.

Example

// Data and manipluation:
const data = d3.range(15).map(i => ({
        bib: Math.floor(i / 5) + 1,
        ratio: -1 + Math.random() * 5,
        run: [1, 2, 3, 4, 5][i % 5],
        run: [1, 2, 3, 4, 5][i % 5],
        name: ['GIRL1', 'GIRL2', 'GIRL3', 'GIRL4'][Math.floor(i / 5)]
        }));
        
const grouped = d3.group(data,d=>d.bib);


// Dimensions:
const height = 500;
const width = 500;
const margin = {
       top: 10,
       left: 50,
       right: 50,
       bottom: 50
    }
const padding = 30; // labelled `pad` in image due to space constraints

const plotWidth = (width-padding)/grouped.size - padding;
const plotHeight = height-padding*2;

const svg = d3.select("body")
  .append("svg")
  .attr("width", margin.left+width+margin.right)
  .attr("height", margin.top+height+margin.bottom);
  
const g = svg.append("g")
  .attr("transform","translate("+[margin.left,margin.top]+")");

//Scales:
 const xScale = d3.scaleLinear()
   .domain(d3.extent(data, d => d.run))
   .range([0, plotWidth]);
     
const yScale = d3.scaleLinear()
   .domain(d3.extent(data, d => d.ratio))
   .range([plotHeight, 0]);
   
// Place plots:
const plots = g.selectAll(null)
  .data(grouped)
  .enter()
  .append("g")
  .attr("transform", function(d,i) {
     return "translate("+[i*(padding+plotWidth)+padding,padding]+")";
   })
   
//Optional plot background:
plots.append("rect")
  .attr("width",plotWidth)
  .attr("height",plotHeight)
  .attr("fill","#ddd");
   

// Plot actual data
plots.selectAll(null)
     .data(d=>d[1])
     .enter()
     .append("circle")
     .attr("r", 4)
     .attr("cy", d=>yScale(d.ratio))
     .attr("cx", d=>xScale(d.run))

// Plot line if needed:
plots.append("path")
  .attr("d", function(d) {
    return d3.line()
               .x(d=>xScale(d.run))
               .y(d=>yScale(d.ratio))
               (d[1])
   })
   .attr("stroke", "#333")
   .attr("stroke-width", 1)
   .attr("fill","none")
   
// Plot names if needed:
plots.append("text")
  .attr("x", plotWidth/2)
  .attr("y", -10)
  .text(function(d) {
    return d[1][0].name;
  })
  .attr("text-anchor","middle");
   
// Plot axes     
 plots.append("g")
   .attr("transform","translate("+[0,plotHeight]+")")
   .call(d3.axisBottom(xScale).ticks(4));


 g.append("g")
   .attr("transform","translate("+[0,padding]+")")
   .call(d3.axisLeft(yScale))
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.1.0/d3.min.js"></script>

Which should produce:

enter image description here

Well for the second time today @AndrewReid beat me to it and of course his answer is very much perfect (I love the diagram).

Regardless here's my entry. The big difference is that I used a d3.scaleBand (and data binding) to place each child line chart while he did it more manually).

<!DOCTYPE html>

<html>
  <head>
    <script src="https://d3js.org/d3.v6.min.js"></script>
    <style>
       svg {
        font-family: arial
      }
      .tick line {
        stroke: white;
        stroke-opacity: 0.7;
        shape-rendering: crispEdges;
      }
    </style>
  </head>

  <body>
    <svg></svg>
    <script>
      let data = [],
        tests = ['PRETEST', 'TRENING1', 'TRENING2', 'TRENING3', 'POSTTEST'],
        courses = ['COURSE 1', 'COURSE 2', 'COURSE 3', 'STRAIGHT-GLIDING'];

      tests.forEach((i) => {
        courses.forEach((j) => {
          d3.range(5).map((k) => {
            data.push({
              test: i,
              course: j,
              run: k,
              ratio: -1 + Math.random() * 5,
            });
          });
        });
      });

      const width = 1100;
      const height = 350;

      const margin = {
        top: 20,
        right: 10,
        bottom: 20,
        left: 30,
      };

      // place wrapper g with margins
      const svg = d3
        .select('svg')
        .attr('width', width)
        .attr('height', height)
        .append('g')
        .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

      // calculate the outer scale band for each line graph
      const outerXScale = d3
        .scaleBand()
        .domain(tests)
        .range([0, width - margin.left - margin.right]);

      // inner dimensions of chart based on bandwidth of outer scale
      const innerWidth = outerXScale.bandwidth() - margin.left - margin.right;
      const innerHeight = height - margin.top - margin.bottom;

      // g for each inner chart
      const testG = svg
        .selectAll('.outer')
        .data(d3.group(data, (d) => d.test))
        .enter()
        .append('g')
        .attr('class', 'outer')
        .attr('transform', function (d, i) {
          return 'translate(' + outerXScale(d[0]) + ',' + 0 + ')';
        });

      // some styling
      testG
        .append('rect')
        .attr('width', innerWidth)
        .attr('height', innerHeight)
        .attr('fill', '#f2f2f2');

      testG
        .append('rect')
        .attr('width', innerWidth)
        .attr('height', 17)
        .attr('transform', 'translate(' + 0 + ',' + -17 + ')')
        .attr('fill', '#e6e6e6');

      // header
      testG
        .append('text')
        .text(function (d) {
          return d[0];
        })
        .attr('text-anchor', 'middle')
        .attr('transform', 'translate(' + innerWidth / 2 + ',' + -2 + ')');

      // inner scales
      const innerXScale = d3
        .scaleLinear()
        .domain(d3.extent(data, (d) => d.run))
        .range([0, innerWidth]);

      const innerYScale = d3
        .scaleLinear()
        .domain(d3.extent(data, (d) => d.ratio))
        .range([innerHeight, 0]);

      testG
        .append('g')
        .call(d3.axisBottom(innerXScale).tickSize(-innerHeight))
        .attr('transform', `translate(0,${innerHeight})`);

      testG.append('g').call(d3.axisLeft(innerYScale).tickSize(-innerWidth));

      testG
        .selectAll('.line')
        .data(function (d) {
          return d3.group(d[1], (d) => d.course);
        })
        .enter()
        .append('path')
        .attr('d', function (d) {
          return d3
            .line()
            .x((d) => innerXScale(d.run))
            .y((d) => innerYScale(d.ratio))(d[1]);
        })
        .attr('fill', 'none')
        .attr('stroke', function (d, i) {
          return d3.schemeCategory10[i];
        })
        .attr('stroke-width', 1);
    </script>
  </body>
</html>

Related