1
\$\begingroup\$

I'm working on a d3 project that has multiple sub-plots with updating data. I have a working example below, but I think I might be over complicating the update() pattern. I didn't include an exit() because the data is consistent (there's always the same amount of divisions, teams and values).

d3 experts: does this look right to you? What would you modify to make it more performant / maintainable? Should I invoke a function for each component of the chart? e.g. a function for building the division charts, a function for building the team charts, a function for building the bars, etc.

I'm interested in learning how to write clean maintainable d3 code, so please pass along any resources if you have any.

const dataset1 = [
 {"division": "division 1", "team": "team 1", "value": 5},
 {"division": "division 1", "team": "team 1", "value": 7},
 {"division": "division 1", "team": "team 1", "value": 9},
 {"division": "division 1", "team": "team 2", "value": 2},
 {"division": "division 1", "team": "team 2", "value": 1},
 {"division": "division 1", "team": "team 2", "value": 1},
 {"division": "division 1", "team": "team 3", "value": 3},
 {"division": "division 1", "team": "team 3", "value": 1},
 {"division": "division 1", "team": "team 3", "value": 7},
 {"division": "division 2", "team": "team 1", "value": 2},
 {"division": "division 2", "team": "team 1", "value": 7},
 {"division": "division 2", "team": "team 1", "value": 3},
 {"division": "division 2", "team": "team 2", "value": 6},
 {"division": "division 2", "team": "team 2", "value": 3},
 {"division": "division 2", "team": "team 2", "value": 2},
 {"division": "division 2", "team": "team 3", "value": 3},
 {"division": "division 2", "team": "team 3", "value": 9},
 {"division": "division 2", "team": "team 3", "value": 5},
]

const dataset2 = [
 {"division": "division 1", "team": "team 1", "value": 1},
 {"division": "division 1", "team": "team 1", "value": 4},
 {"division": "division 1", "team": "team 1", "value": 3},
 {"division": "division 1", "team": "team 2", "value": 6},
 {"division": "division 1", "team": "team 2", "value": 2},
 {"division": "division 1", "team": "team 2", "value": 9},
 {"division": "division 1", "team": "team 3", "value": 5},
 {"division": "division 1", "team": "team 3", "value": 2},
 {"division": "division 1", "team": "team 3", "value": 3},
 {"division": "division 2", "team": "team 1", "value": 8},
 {"division": "division 2", "team": "team 1", "value": 1},
 {"division": "division 2", "team": "team 1", "value": 2},
 {"division": "division 2", "team": "team 2", "value": 5},
 {"division": "division 2", "team": "team 2", "value": 3},
 {"division": "division 2", "team": "team 2", "value": 7},
 {"division": "division 2", "team": "team 3", "value": 4},
 {"division": "division 2", "team": "team 3", "value": 3},
 {"division": "division 2", "team": "team 3", "value": 8},
]

function updateChart(data) {
  
  // Divisions
  var divisions = d3.nest().key(d=>d.division).entries(data); 
  var divisionCharts = d3.select("body").selectAll("svg").data(divisions, d=>d.key);
  var divisionChartsEnter = divisionCharts.enter().append("svg").attr("width", 200).attr("height", 75);
  
  // Teams
  var teamCharts = divisionCharts.merge(divisionChartsEnter).selectAll("svg")
    .data(d => d3.nest().key(d=>d.team).entries(d.values), d=>d.key)
  var teamChartsEnter = teamCharts.enter().append("svg").attr("x", (d,i) => i*50);
  
  // Values for each team
  var bars = teamCharts.merge(teamChartsEnter).selectAll("rect").data(d => d.values);
  var barsEnter = bars.enter()
    .append("rect")
    .attr("x", (d,i)=>i*12)
    .attr("width", 10)
  bars.merge(barsEnter)
    .transition()
    .attr("height", d=>d.value*10)
  
  // Toggle dataset
  currDataset = data == dataset1 ? dataset2 : dataset1;
}

let currDataset = dataset1;
updateChart(currDataset);

d3.select("#updateData").on("click", () => updateChart(currDataset))
<p><button id="updateData">Update Data</button>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>

\$\endgroup\$
1
\$\begingroup\$

Overall, your update function is correct. You're appending several SVGs to the HTML, which is a good and very common approach when creating small multiples. Also, you have a correct (almost, see below) understanding of the update pattern and the merge() method.

Major issues

1. Merging the selections

As I said, you get the principle of merging selections right, but some details are wrong. For instance, you don't need the following selectAll in the merged selection.

So, they should be something like this (using the first one as example):

  var divisions = d3.nest()
      .key(d => d.division)
      .entries(data);

  var divisionCharts = container.selectAll("svg")
      .data(divisions, d => d.key);

  var divisionChartsEnter = divisionCharts.enter()
      .append("svg")
      .attr("width", width)
      .attr("height", height);

  divisionCharts = divisionCharts.merge(divisionChartsEnter);

And do the same for the other merged selections.

2. Appending SVGs inside SVGs

As stated above, appending multiple SVGs is a very common approach when creating small multiples. However, inside each SVG you're creating nested SVGs, one for each team... not only this is uncommon and unnecessary, but also can lead to some problems. The common approach here is creating groups.

So, instead of SVGs, let's groups (<g>) for each team:

var teamCharts = divisionCharts.selectAll("g")
    .data(d => d3.nest()
        .key(d => d.team)
        .entries(d.values), d => d.key);

var teamChartsEnter = teamCharts.enter()
    .append("g")
    .attr("transform", (d) => "translate(" + xScaleTeam(d.key) + ",0)");

teamCharts = teamCharts.merge(teamChartsEnter);

Since you're appending groups you have to translate them, instead of setting the x attribute.

3. Use scales!

This is a very important advice: use scales in a D3 code. The way you're doing right now (using absolute values and indices for positioning and size) is not correct and, the most important, it will be a hell to debug/maintain. Actually, if you look at the snippets above, you'll see that I'm already using scales on them.

So, create proper scales. In your case:

var xScaleTeam = d3.scaleBand()
    .range([0, width])
    .domain([...new Set(dataset1.map(function(d) {
        return d.team
    }))])
    .paddingOuter(0.4);

var xScaleBars = d3.scaleBand()
    .range([0, xScaleTeam.bandwidth()])
    .domain(d3.range(3))
    .paddingOuter(0.4);

var yScale = d3.scaleLinear()
    .range([height, 0])
    .domain([0, d3.max([d3.max(dataset1, function(d) {
        return d.value
    }), d3.max(dataset2, function(d) {
        return d.value
    })])]);

xScaleTeam is the outer x scale for each small multiple, xScaleBars is the inner x scale (for each team) and yScale is, obviously, the y scale.

4. Bars going up

Maybe I'm wrong, but it seems to me that you want the bar chart going up, as almost all bar charts around. That being the case, just do:

bars.attr("y", d => yScale(d.value))
    .attr("height", d => height - yScale(d.value))

Minor issues

  • Don't append the SVGs directly to the body. Instead of that, select a specific element, like a <div> with container id:

    var container = d3.select("#container");
    
  • This code works just fine if you reference the new v5 version, instead of v4 (which you're using right now). Therefore, change to v5.

  • Don't use magic numbers. For instance, define the width and height:

    var width = 200,
        height = 75;
    
  • Move the toggle logic to outside the update function: that way, you have a function that depends only on the arguments it receives.

  • As a further advice: color the bars by team. It is simple as:

    var color = d3.scaleOrdinal(d3.schemeCategory10);
    

    And then:

    teamChartsEnter.style("fill", (d)=> color(d.key));
    

Demo

Here is your code with all those changes:

const dataset1 = [{
    "division": "division 1",
    "team": "team 1",
    "value": 5
  },
  {
    "division": "division 1",
    "team": "team 1",
    "value": 7
  },
  {
    "division": "division 1",
    "team": "team 1",
    "value": 9
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 2
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 1
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 1
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 3
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 1
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 7
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 2
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 7
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 6
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 2
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 9
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 5
  },
]

const dataset2 = [{
    "division": "division 1",
    "team": "team 1",
    "value": 1
  },
  {
    "division": "division 1",
    "team": "team 1",
    "value": 4
  },
  {
    "division": "division 1",
    "team": "team 1",
    "value": 3
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 6
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 2
  },
  {
    "division": "division 1",
    "team": "team 2",
    "value": 9
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 5
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 2
  },
  {
    "division": "division 1",
    "team": "team 3",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 8
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 1
  },
  {
    "division": "division 2",
    "team": "team 1",
    "value": 2
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 5
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 2",
    "value": 7
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 4
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 3
  },
  {
    "division": "division 2",
    "team": "team 3",
    "value": 8
  },
];

var width = 200,
  height = 75;

var container = d3.select("#container");

var color = d3.scaleOrdinal(d3.schemeCategory10);

var xScaleTeam = d3.scaleBand()
  .range([0, width])
  .domain([...new Set(dataset1.map(function(d) {
    return d.team
  }))])
  .paddingOuter(0.4);

var xScaleBars = d3.scaleBand()
  .range([0, xScaleTeam.bandwidth()])
  .domain(d3.range(3))
  .paddingOuter(0.4);

var yScale = d3.scaleLinear()
  .range([height, 0])
  .domain([0, d3.max([d3.max(dataset1, function(d) {
    return d.value
  }), d3.max(dataset2, function(d) {
    return d.value
  })])]);

function updateChart(data) {

  // Divisions
  var divisions = d3.nest().key(d => d.division).entries(data);
  var divisionCharts = container.selectAll("svg").data(divisions, d => d.key);
  var divisionChartsEnter = divisionCharts.enter().append("svg").attr("width", width).attr("height", height);

  divisionCharts = divisionCharts.merge(divisionChartsEnter);

  // Teams
  var teamCharts = divisionCharts.selectAll("g")
    .data(d => d3.nest().key(d => d.team).entries(d.values), d => d.key);

  var teamChartsEnter = teamCharts.enter().append("g").attr("transform", (d) => "translate(" + xScaleTeam(d.key) + ",0)")
    .style("fill", (d) => color(d.key));

  teamCharts = teamCharts.merge(teamChartsEnter);

  // Values for each team
  var bars = teamCharts.selectAll("rect").data(d => d.values);
  var barsEnter = bars.enter()
    .append("rect")
    .attr("x", (d, i) => xScaleBars(i))
    .attr("width", 10)
    .attr("y", height)
  bars.merge(barsEnter)
    .transition()
    .attr("y", d => yScale(d.value))
    .attr("height", d => height - yScale(d.value))

}

let currDataset = dataset1;
updateChart(currDataset);

d3.select("#updateData").on("click", () => {
  currDataset = currDataset === dataset1 ? dataset2 : dataset1;
  updateChart(currDataset);
})
<p><button id="updateData">Update Data</button>
  <div id="container"></div>
  <script src="https://d3js.org/d3.v5.min.js"></script>

\$\endgroup\$
  • \$\begingroup\$ Thank you so much! This is very helpful. Glad to know that I'm (mostly) on the right track. I didn't know about the problems with nested svgs so I'll use gs instead. Another question: is there any advantage to nesting the data separately like the sample code? Or would it be better to double nest it like this: var dataNested = d3.nest().key(d => d.division).key(d => d.team)entries(data); \$\endgroup\$ – Jeff Appareti Jul 19 '18 at 22:15
  • \$\begingroup\$ It makes very little difference. But for readability I'd do two separate nests. \$\endgroup\$ – Gerardo Furtado Jul 19 '18 at 22:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.