D3.js - DOM reorders after sort but chart does not update

Viewed 35

I'm a total beginner with D3 and have started by plotting some data retrieved from an API, e.g:

chartData = [
{ track: "A Kind of Magic", playcount: 2683110 },
{ track: "Another One Bites the Dust", playcount: 6425611 },
{ track: "Bohemian Rhapsody", playcount: 10167011 },
{ track: "Crazy Little Thing Called Love", playcount: 4360128},
{ track: "Don't Stop Me Now", playcount: 7762976 },
{ track: "Flash", playcount: 1248561 }];

Following some online examples, I have rendered the bar chart:

var margin = {
  top: 50,
  right: 40,
  bottom: 80,
  left: 100
},
width = 1200 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;

var svg = d3.select('#queenChart')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g').attr('transform', `translate(${margin.left}, ${margin.top})`);

var x = d3.scaleBand()
  .domain(chartData.map(d => d.track))
  .range([0, width])
  .padding(0.1);

svg.append('g')
  .attr('transform', `translate(0, ${height})`)
  .call(d3.axisBottom(x))
  .selectAll('text')
  .attr('transform', 'translate(-10,0) rotate(-45)')
  .style('text-anchor','end');

var y = d3.scaleLinear()
  .domain([0, d3.max(chartData, d => { return d.playcount; })])
  .range([height, 0]);

svg.append('g')
  .call(d3.axisLeft(y));

svg.selectAll('rect')
  .data(chartData)
  .enter()
  .append('rect')
  .style('fill', 'steelblue')
  .attr('x', d => x(d.track))
  .attr('y', d => y(d.playcount))
  .attr('height', d => { return height - y(d.playcount); })
  .attr('width', x.bandwidth ); 

The problem comes when I attempt to sort the chart:

setTimeout(() => {
  svg.selectAll('rect')
    .sort((a,b) => d3.ascending(a.playcount, b.playcount))
    .transition()
    .duration(500)
    .attr('x', d => x(d.track));
}, 2500);

After 2.5 seconds I can see that all of the <rect> elements in the DOM have indeed sorted correctly, but the chart svg itself is completely unchanged. Is there something I'm missing that gets the chart to rerender to reflect the changes in the DOM?

1 Answers

Is there something I'm missing that gets the chart to rerender to reflect the changes in the DOM?

You have re-rendered the chart and it does reflect the changes in the DOM, but the order of the DOM is immaterial to the where the chart elements are placed within the SVG because x and y properties are specified by the attributes of the element. Order does not affect x or y attributes of an element.

Order matters only with overlapping elements: the element that is covered by another and sharing the same parent is appended first.

Let's look at your code:

svg.selectAll('rect')
  .sort((a,b) => d3.ascending(a.playcount, b.playcount))
  .attr('x', d => x(d.track));

You order the DOM elements, but place them on the x axis according to the bound data. The bound data has not changed for each element (even though their order in the DOM has), so the x placement of each bar is the same as it was.

option 1

I start with this option because it is closer to what you have, though my preference would be for option 2

One option would be to recalculate the scale after sorting the elements. Using the sorted elements' bound data we can recalculate the scale's domain and since we position the data according to the scale, this will sort the bars:

  svg.selectAll('rect')
    .sort((a,b) => d3.ascending(a.playcount, b.playcount))
    .call(function(rects) {
      x.domain(rects.data().map(d => d.track));
    })
    .attr('x', d => x(d.track));

The use of .call here lets us access the sorted elements data: rects.data() and map those values to the domain, in order, overwriting the previous domain. Now we just need to position based on d.track again.

Of course we need to update the axis as well, so I've added some code to do so below:

chartData = [
{ track: "A Kind of Magic", playcount: 2683110 },
{ track: "Another One Bites the Dust", playcount: 6425611 },
{ track: "Bohemian Rhapsody", playcount: 10167011 },
{ track: "Crazy Little Thing Called Love", playcount: 4360128},
{ track: "Don't Stop Me Now", playcount: 7762976 },
{ track: "Flash", playcount: 1248561 }];

var margin = {
  top: 50,
  right: 40,
  bottom: 80,
  left: 100
},
width = 1200 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;

var svg = d3.select('body')
  .append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g').attr('transform', `translate(${margin.left}, ${margin.top})`);

var x = d3.scaleBand()
  .domain(chartData.map(d => d.track))
  .range([0, width])
  .padding(0.1);

// store g in variable
var xAxis = svg.append('g')
  .attr('transform', `translate(0, ${height})`)
  .call(d3.axisBottom(x));
 
// break method chaining so we don't store a selection of text elements
xAxis
  .selectAll('text')
  .attr('transform', 'translate(-10,0) rotate(-45)')
  .style('text-anchor','end');

var y = d3.scaleLinear()
  .domain([0, d3.max(chartData, d => { return d.playcount; })])
  .range([height, 0]);

svg.append('g')
  .call(d3.axisLeft(y));

svg.selectAll('rect')
  .data(chartData)
  .enter()
  .append('rect')
  .style('fill', 'steelblue')
  .attr('x', d => x(d.track))
  .attr('y', d => y(d.playcount))
  .attr('height', d => { return height - y(d.playcount); })
  .attr('width', x.bandwidth );
  
setTimeout(() => {
  svg.selectAll('rect')
    .sort((a,b) => d3.ascending(a.playcount, b.playcount))
    .call(function(rects) {
      x.domain(rects.data().map(d => d.track));
    })
    .transition()
    .duration(500)
    .attr('x', d => x(d.track));
    
   // transition the x axis to reflect the new data:
   xAxis.transition().call(d3.axisBottom(x));
    
    
}, 2500);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

option 2

A different option, one that drops the use of selection.sort(), would be to sort the actual data and then use that directly as the scale's domain:

 x.domain(chartData.sort((a,b)=>a.playcount-b.playcount).map(d=>d.track))

 svg.selectAll('rect')
    .attr('x', d => x(d.track));

And of course, we need to update the x axis as well, but this could look like:

chartData = [
{ track: "A Kind of Magic", playcount: 2683110 },
{ track: "Another One Bites the Dust", playcount: 6425611 },
{ track: "Bohemian Rhapsody", playcount: 10167011 },
{ track: "Crazy Little Thing Called Love", playcount: 4360128},
{ track: "Don't Stop Me Now", playcount: 7762976 },
{ track: "Flash", playcount: 1248561 }];

var margin = {
  top: 50,
  right: 40,
  bottom: 80,
  left: 100
},
width = 1200 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;

var svg = d3.select('body')
  .append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g').attr('transform', `translate(${margin.left}, ${margin.top})`);

var x = d3.scaleBand()
  .domain(chartData.map(d => d.track))
  .range([0, width])
  .padding(0.1);

// store g in variable
var xAxis = svg.append('g')
  .attr('transform', `translate(0, ${height})`)
  .call(d3.axisBottom(x));
 
// break method chaining so we don't store a selection of text elements
xAxis
  .selectAll('text')
  .attr('transform', 'translate(-10,0) rotate(-45)')
  .style('text-anchor','end');

var y = d3.scaleLinear()
  .domain([0, d3.max(chartData, d => { return d.playcount; })])
  .range([height, 0]);

svg.append('g')
  .call(d3.axisLeft(y));

svg.selectAll('rect')
  .data(chartData)
  .enter()
  .append('rect')
  .style('fill', 'steelblue')
  .attr('x', d => x(d.track))
  .attr('y', d => y(d.playcount))
  .attr('height', d => { return height - y(d.playcount); })
  .attr('width', x.bandwidth );
  
setTimeout(() => {
  x.domain(chartData.sort((a,b)=>a.playcount-b.playcount).map(d=>d.track))

  svg.selectAll('rect')
    .transition()
    .duration(500)
    .attr('x', d => x(d.track));
    
   // transition the x axis to reflect the new data:
   xAxis.transition().call(d3.axisBottom(x));
    
    
}, 2500);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

Ultimately, any solution should involve reordering the data: either through reordering the data or the elements and using the result to update the x scale's domain. This is needed because you place the elements according to their data and the DOM order won't affect their placement on the x axis.

Related