Zero tick vertically centered on axis when all values are 0

Viewed 1365

I've noticed a change in behavior when updating from d3 version 4 to version 5. In v4, when a dataset contains all zero values for the y-axis, the "0" tick is correctly aligned to the bottom of the chart.

<head>
  <!-- load the d3.js library -->
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <style>
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 2px;
    }
  </style>
</head>

<script>
  var margin = {
      top: 50,
      right: 50,
      bottom: 50,
      left: 50
    },
    width = window.innerWidth - margin.left - margin.right,
    height = window.innerHeight - margin.top - margin.bottom;

  var n = 21;
  // An array of objects of length N. Each object has key -> value pair, the key being "y" and the value is a random number
  // var dataset = d3.range(n).map(function(d) { return {"y": d3.randomUniform(1)() } })
  var dataset = d3.range(n).map(function(d) {
    return {
      "y": 0
    }
  })

  var xScale = d3.scaleLinear()
    .domain([0, n - 1])
    .range([0, width]);

  var yScale = d3.scaleLinear()
    .domain([0, d3.max(dataset, d => d.y)])
    .range([height, 0]);

  var line = d3.line()
    .x(function(d, i) {
      return xScale(i);
    })
    .y(function(d) {
      return yScale(d.y);
    })
    .curve(d3.curveMonotoneX)

  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 + ")");

  svg.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(d3.axisBottom(xScale));

  svg.append("g")
    .attr("class", "y axis")
    .call(d3.axisLeft(yScale));

  svg.append("path")
    .datum(dataset)
    .attr("class", "line")
    .attr("d", line);
</script>

For my use case, this is the expected behavior.

In v5, under the same conditions, the "0" is aligned to the center of the y-axis.

<head>
  <!-- load the d3.js library -->     
  <script src="https://d3js.org/d3.v5.min.js"></script>
  <style>
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 2px;
    }
  </style>
</head>

<script>
var margin = {top: 50, right: 50, bottom: 50, left: 50}
  , width = window.innerWidth - margin.left - margin.right 
  , height = window.innerHeight - margin.top - margin.bottom;

var n = 21;
// An array of objects of length N. Each object has key -> value pair, the key being "y" and the value is a random number
// var dataset = d3.range(n).map(function(d) { return {"y": d3.randomUniform(1)() } })
var dataset = d3.range(n).map(function(d) { return {"y": 0 } })

var xScale = d3.scaleLinear()
    .domain([0, n-1])
    .range([0, width]);

var yScale = d3.scaleLinear()
    .domain([0, d3.max(dataset, d => d.y)])
    .range([height, 0]);

var line = d3.line()
    .x(function(d, i) { return xScale(i); }) 
    .y(function(d) { return yScale(d.y); })
    .curve(d3.curveMonotoneX)

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 + ")");

svg.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(d3.axisBottom(xScale));

svg.append("g")
    .attr("class", "y axis")
    .call(d3.axisLeft(yScale));

svg.append("path")
    .datum(dataset)
    .attr("class", "line")
    .attr("d", line);
</script>

The only difference between the two examples is the version of d3 that is loaded.

Is there any way that I can keep the same behavior exhibited in v4 in the current version (v5) of d3?

3 Answers

This is not a difference between D3 v4 and v5. Actually, this change was introduced in D3 v5.8.

Have a look here, this is your code using D3 v5.7:

<head>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.js"></script>
  <style>
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 2px;
    }

  </style>
</head>

<script>
  var margin = {
      top: 50,
      right: 50,
      bottom: 50,
      left: 50
    },
    width = window.innerWidth - margin.left - margin.right,
    height = window.innerHeight - margin.top - margin.bottom;

  var n = 21;
  // An array of objects of length N. Each object has key -> value pair, the key being "y" and the value is a random number
  // var dataset = d3.range(n).map(function(d) { return {"y": d3.randomUniform(1)() } })
  var dataset = d3.range(n).map(function(d) {
    return {
      "y": 0
    }
  })

  var xScale = d3.scaleLinear()
    .domain([0, n - 1])
    .range([0, width]);

  var yScale = d3.scaleLinear()
    .domain([0, d3.max(dataset, d => d.y)])
    .range([height, 0]);

  var line = d3.line()
    .x(function(d, i) {
      return xScale(i);
    })
    .y(function(d) {
      return yScale(d.y);
    })
    .curve(d3.curveMonotoneX)

  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 + ")");

  svg.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(d3.axisBottom(xScale));

  svg.append("g")
    .attr("class", "y axis")
    .call(d3.axisLeft(yScale));

  svg.append("path")
    .datum(dataset)
    .attr("class", "line")
    .attr("d", line);

</script>

Now the same code, using D3 v5.8:

<head>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.8.0/d3.js"></script>
  <style>
    .line {
      fill: none;
      stroke: steelblue;
      stroke-width: 2px;
    }

  </style>
</head>

<script>
  var margin = {
      top: 50,
      right: 50,
      bottom: 50,
      left: 50
    },
    width = window.innerWidth - margin.left - margin.right,
    height = window.innerHeight - margin.top - margin.bottom;

  var n = 21;
  // An array of objects of length N. Each object has key -> value pair, the key being "y" and the value is a random number
  // var dataset = d3.range(n).map(function(d) { return {"y": d3.randomUniform(1)() } })
  var dataset = d3.range(n).map(function(d) {
    return {
      "y": 0
    }
  })

  var xScale = d3.scaleLinear()
    .domain([0, n - 1])
    .range([0, width]);

  var yScale = d3.scaleLinear()
    .domain([0, d3.max(dataset, d => d.y)])
    .range([height, 0]);

  var line = d3.line()
    .x(function(d, i) {
      return xScale(i);
    })
    .y(function(d) {
      return yScale(d.y);
    })
    .curve(d3.curveMonotoneX)

  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 + ")");

  svg.append("g")
    .attr("class", "x axis")
    .attr("transform", "translate(0," + height + ")")
    .call(d3.axisBottom(xScale));

  svg.append("g")
    .attr("class", "y axis")
    .call(d3.axisLeft(yScale));

  svg.append("path")
    .datum(dataset)
    .attr("class", "line")
    .attr("d", line);

</script>

The explanation can be found on the release notes for D3 v5.8:

D3-Scale:

For collapsed domains, use midpoint of domain or range rather than start. (emphases mine)

Therefore, I'm afraid there is nothing you can do, unless moving back to D3 v5.7 or lower.

In fact, D3 v5.8 is so different from v5.7 (and not backwards compatible, see the new scale constructors, for instance, or the new join method) that in my humble opinion it should have been named D3 v6.0 (of course, following the semantic versioning it was still named v5 because there were no breaking changes). There is arguably more differences from v5.7 to v5.8 than from v4 to v5.

In your definition of the yScale domain there is only one value (zero). Therefor, the value is correctly displayed in the middle of the axis.

You can fix that by adapting the domain but then you will get more ticks on the axis.

Only one example (you could add any other number):

var yScale = d3.scaleLinear()
    .domain([0, d3.max(dataset, d => d.y)+1])
    .range([height, 0]);

Encountered this same issue in d3 v7 - the workaround is setting a valid range like previously answered and conditionally display the tick marks and labels.

const height = *HEIGHT OF YOUR CHART*
const dataMax = Math.max(...*YOUR DATA*)
const dataMin = Math.min(...*YOUR DATA*)
const dataMinMaxZero = dataMax === 0 && dataMin === 0 
const scaleMax = dataMinMaxZero ? 1 : dataMax;

const yScale = d3.scaleLinear()
  .domain([dataMin, scaleMax])
  .range([height, 0]);

const yAxis = d3.axisLeft(yScale)

d3.select("#yaxis").call(yAxis);
if (dataMinMaxZero) {
  d3.select("#yaxis").selectAll(".tick").style("opacity", 0);
} else {
  d3.select("#yaxis").selectAll(".tick").style("opacity", 1);
}
Related