Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to write a d3 positive and negative logarithmic scale

Question

I have positive and negative values, and I'd like to plot them on a "logarithmic" scale.

Imagine a scale with evenly spaced ticks for the following values:

-1000, -100, -10, -1, 0, 1, 10, 100, 1000

I want 0 in there, which is defined to be -Inf by logarithms, complicating this further.

However, I don't think this request is unreasonable. This seems like a sensible scale any data scientist might want to plot strongly divergent values against.

How do you create such a scale and axis in d3?

Thoughts

It might be possible to do this cleverly with 2 d3.scaleLog()s or maybe 3 scales if you use a technique like this one.

I was hoping there might be an easy way to fit this in a d3.scalePow() with .exponent(0.1) but unless I've got my log rules mixed up, you can't get a .scaleLog() out of a .scalePow() (though you can probably approximate it okay for some ranges).

like image 571
Alex Lenail Avatar asked Mar 06 '23 17:03

Alex Lenail


1 Answers

We can't have a true log scale like this, or even a combination of two log scales like this as. We need to set a cut off for zeroish values, and this is where error might be introduced depending on your data. Otherwise, to make a scale function like this is fairly straightforward, just call a different scale for negative and positive while setting zero-ish values to zero.

This combination of scales might look like:

var positive = d3.scaleLog()
  .domain([1e-6,1000])
  .range([height/2,0])

var negative = d3.scaleLog()
  .domain([-1000,-1e-6])
  .range([height,height/2])

var scale = function(x) {
  if (x > 1e-6) return positive(x);
  else if (x < -1e-6) return negative(x);
  else return height/2; // zero value.
}

And an example:

var width = 500;
var height = 300;

var positive = d3.scaleLog()
  .domain([1e-1,1000])
  .range([height/2,0])
  
var negative = d3.scaleLog()
  .domain([-1000,-1e-1])
  .range([height,height/2])
  
var scale = function(x) {
  if (x > 1e-6) return positive(x);
  else if (x < -1e-6) return negative(x);
  else return height/2; // zero value.
}

var line = d3.line()
  .y(function(d) { return scale(d) })
  .x(function(d,i) { return (i); })
  
var svg = d3.select("body")
  .append("svg")
  .attr("width",width)
  .attr("height",height)
  
var data = d3.range(width).map(function(d) {
  return (d - 250) * 4;
})

svg.append("path")
  .attr("d", line(data) );
path {
  fill: none;
  stroke: steelblue;
  stroke-width: 2px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>

Creating a single scale

The above is a proof of concept maybe.

Now the trickier part is making an axis. We could make axes for both the scales above, leaving zero with some sort of manual correction. But it will be easier to create a scale with our own interpolotor using the above as an exmaple. This gives us one scale which we can create an axis for. Our interpolator might look like:

// Interpolate an output value:
var interpolator = function(a,b) {
  var y0 = a;
  var y1 = b;
  var yd = b-a;
  var k = 0.0001; 

  var positive = d3.scaleLog()
    .domain([k,1])
    .range([(y0 + y1)/2 ,y1])

  var negative = d3.scaleLog()
    .domain([-1,-k])
    .range([y0, (y1 + y0)/2])

  return function(t) { 
    t = (t - 0.5) * 2; // for an easy range of -1 to 1.
    if (t > k) return positive(t);
    if (t < -1 + k) return y0;
    if (t < -k) return negative(t);
    else return (y0 + y1) /2;
  }
}

And then we can apply that to a regular old d3 linear scale:

d3.scaleLinear().interpolate(interpolator)...

This will interpolate numbers in the domain to the range as we've specified. It largely takes the above and adopts it for use as a d3 interpolator: a,b are the domain limits, t is a normalized domain between 0 and 1, and k defines the zeroish values. More on k below.

To get the ticks, assuming an nice round domain that only has nice round base ten numbers we could use:

// Set the ticks:
var ticks = [0];
scale.domain().forEach(function(d) {
  while (Math.abs(d) >= 1) {
    ticks.push(d); d /= 10;
  }
})

Applying this we get:

var margin = {left: 40, top: 10, bottom: 10}
var width = 500;
var height = 300;

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

var data = d3.range(width).map(function(d) {
  return (d - 250) * 4;
})

// Interpolate an output value:
var interpolator = function(a,b) {
  var y0 = a;
  var y1 = b;
  var yd = b-a;
  var k = 0.0001; 
 
  var positive = d3.scaleLog()
    .domain([k,1])
	.range([(y0 + y1)/2 ,y1])

  var negative = d3.scaleLog()
    .domain([-1,-k])
	.range([y0, (y1 + y0)/2])
	
  return function(t) { 
	t = (t - 0.5) * 2; // for an easy range of -1 to 1.
	if (t > k) return positive(t);
	if (t < -1 + k) return y0;
	if (t < -k) return negative(t);
	else return (y0 + y1) /2;
  }
}

// Create a scale using it:
var scale = d3.scaleLinear()
  .range([height,0])
  .domain([-1000,1000])
  .interpolate(interpolator);
  
// Set the ticks:
var ticks = [0];
scale.domain().forEach(function(d) {
  while (Math.abs(d) >= 1) {
	ticks.push(d); d /= 10;
  }
})

// Apply the scale:
var line = d3.line()
  .y(function(d) { return scale(d) })
  .x(function(d,i) { return (i); })
  
// Draw a line:
svg.append("path")
  .attr("d", line(data) )
  .attr("class","line");

// Add an axis:
var axis = d3.axisLeft()
  .scale(scale)
  .tickValues(ticks)
  
svg.append("g").call(axis);
.line {
  fill: none;
  stroke: steelblue;
  stroke-width: 2px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>

Modifying the k value

Ok, what's up about k. It is needed to set the zeroish values. k also changes the shape of the graph. If showing regularly spaced ticks, increasing k ten fold increases the magnitude of the minimum magnitude ticks (other than zero) ten fold. In my exmaples above multiplying k by ten pushes the ticks with magnitude one overtop of the zero tick. Dividing it by ten would create room for a 0.1 tick (of course that requires modifying the tick generator to show that tick). k is hard to explain so I hope I did ok there.

I'll demonstrate to try and communicate it a bit better. Let's set the minimum magnitude ticks to be 0.1 using the above, we'll want to modify the tick function and k:

var margin = {left: 40, top: 10, bottom: 10}
var width = 500;
var height = 300;

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

var data = d3.range(width).map(function(d) {
  return (d - 250) * 4;
})

// Interpolate an output value:
var interpolator = function(a,b) {
  var y0 = a;
  var y1 = b;
  var yd = b-a;
  var k = 0.00001; 
 
  var positive = d3.scaleLog()
    .domain([k,1])
	.range([(y0 + y1)/2 ,y1])

  var negative = d3.scaleLog()
    .domain([-1,-k])
	.range([y0, (y1 + y0)/2])
	
  return function(t) { 
	t = (t - 0.5) * 2; // for an easy range of -1 to 1.
	if (t > k) {return positive(t)};
	if (t < -1 + k) return y0;
	if (t < -k) return negative(t);
	else return (y0 + y1) /2 //yd;
  }
}

// Create a scale using it:
var scale = d3.scaleLinear()
  .range([height,0])
  .domain([-1000,1000])
  .interpolate(interpolator);
  
// Set the ticks:
var ticks = [0];
scale.domain().forEach(function(d) {
  while (Math.abs(d) >= 0.1) {
	ticks.push(d); d /= 10;
  }
})

// Apply the scale:
var line = d3.line()
  .y(function(d) { return scale(d) })
  .x(function(d,i) { return (i); })
  
// Draw a line:
svg.append("path")
  .attr("d", line(data) )
  .attr("class","line");

// Add an axis:
var axis = d3.axisLeft()
  .scale(scale)
  .tickValues(ticks)
  .ticks(10,".1f")
  
svg.append("g").call(axis);
.line {
  fill: none;
  stroke: steelblue;
  stroke-width: 2px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>

If you have a domain of +/- 1000, and you want the minimum magnitude tick to be 1 (not including zero) you want a k of 0.0001, or 0.1/1000.

If positive and negative limits of the domain are different, then we would need two k values, one for the negative cut off and one for the positive.

Lastly,

k sets values that are zeroish, in my example, t values that are between -k and +k are set to be the same - 0. Ideally this won't be many values in the dataset, but if it is, you might get a line such as:

enter image description here

Each input value is different, but the there are many zero output values, producing a visual artifact due to the bounds of what I haved considered to be zeroish. If there is only one value in the zeroish bounds, like in my examples above (but not the above picture), we get a much nicer:

enter image description here

like image 141
Andrew Reid Avatar answered Mar 09 '23 07:03

Andrew Reid