d3-scale icon indicating copy to clipboard operation
d3-scale copied to clipboard

Nicer symlog ticks?

Open jheer opened this issue 6 years ago • 16 comments

The new symlog scale appears to use the same tick generation logic as the linear scale. This can result in axes with large gaps when spanning multiple orders of magnitude. It would be nice to have ticks more akin to those provided by log scales, providing a better guide for large value spans!

We've had requests for symlog in Vega-Lite and Altair, so I'm anticipating this same request will hit our repos once we release new versions using d3-scale 2.2+.

jheer avatar Feb 01 '19 01:02 jheer

Yea, I thought about this, and I think the Webber paper does specify how to choose an appropriate bound for the power based on the constant. But clearly I didn’t spend the time to implement it. Contributions welcome!

mbostock avatar Feb 01 '19 16:02 mbostock

I've spent a few hours today trying to get this working, but I'm still not sure that my method is correct — and the integration is clearly not finished.

Pushing to https://github.com/Fil/d3-scale/commit/aea855fe52f661a07dc437ba269ac33c68e51c68 in order to maybe help the next person who wants to volunteer — but so many things are still not working; and tests.

Capture d’écran 2019-04-08 à 21 24 02

Fil avatar Apr 08 '19 19:04 Fil

I have made little modification to scaleLog ticks and it seems to work pretty well. It basically updates logs and pows functions to use log1p and expm1 and also account for sign.

I am not so much in d3 internal code and also have not written tests so far so I leave it here for now.

function ticksSymlog(count) {
    logp = (x) => Math.sign(x) * Math.log1p(math.abs(x))
    powp = (x) => Math.sign(x) * Math.expm1(math.abs(x))

    var d = domain(),
        u = d[0],
        v = d[d.length - 1],
        r;
    base = Math.E
    if (r = v < u) i = u, u = v, v = i;

    var i = logp(u),
        j = logp(v),
        p,
        k,
        t,
        n = count == null ? 10 : +count,
        z = [];

    if (!(base % 1) && j - i < n) {
      i = Math.floor(i), j = Math.ceil(j);
      if (u > 0) for (; i <= j; ++i) {
        for (k = 1, p = powp(i); k < base; ++k) {
          t = p * k;
          if (t < u) continue;
          if (t > v) break;
          z.push(t);
        }
      } else for (; i <= j; ++i) {
        for (k = base - 1, p = powp(i); k >= 1; --k) {
          t = p * k;
          if (t < u) continue;
          if (t > v) break;
          z.push(t);
        }
      }
      if (z.length * 2 < n) z = ticks(u, v, n);
    } else {
      z = ticks(i, j, Math.min(j - i, n)).map(powp);
    }

    return r ? z.reverse() : z;
  }
}

kriestof avatar Apr 04 '20 14:04 kriestof

While waiting for the new symlog scale, a quick workaround is to keep linear scale, symlog the data values then manipulate the axis tick format as:

function transformSymlog(c, base) {
  return function(x) {
    return Math.sign(x) * Math.log(1 + Math.abs(x / c)) / Math.log(base);
  };
}

var base = 10;
var sl = transformSymlog(1, base); //tune C and Base for your need

//transform x data
var newdata = data.map(o => Object.assign({}, o, {x: sl(o['x'])}));

var xdom = d3.extent(newdata.map(o => o['x']));

var x = d3.scaleLinear()
//var x = d3.scaleSymlog()
  .domain(xdom)
  .range([ 0, width ]);

var xra = d3.range(Math.round(x.domain()[0]), Math.round(x.domain()[1]) + 1, 2);

var xAxis0 = d3.axisBottom(x)
//.tickValues(xra) //customize your ticks
.ticks(5) //or assign a number
.tickFormat(function(d) {
  //format it to whatever you like
  return d3.format(".0n")(Math.sign(d) * Math.pow(base, Math.abs(d)));
});

var xAxis = svg.append("g")
  .attr("transform", "translate(0," + height + ")")
  .call(xAxis0);

..............
var dots = svg.append('g')
  .selectAll("circle")
  .data(newdata) //supply the transformed data
  .enter()
  .append("circle")
    .attr("cx", function (d) { return x(d.x); } )
    .attr("cy", function (d) { return y(d.y); } )
    .attr("r", 1);

yitelee avatar Jun 16 '20 05:06 yitelee

Hi. I came across the same issue, d3.scalesymlog() producing same ticks as the linear scale. Is there any update on this issue? or a workaround that works. I'm trying to display log for + and - values. I could not make @yitelee solution work for my case? Appreciate any pointers here.

tkakar avatar Oct 31 '22 18:10 tkakar

Hi, anybody knows a function which produces a "nice" array for the tickValues? Based on min and max values.

AlleSchonWeg avatar Nov 02 '23 15:11 AlleSchonWeg

@AlleSchonWeg

Hi, anybody knows a function which produces a "nice" array for the tickValues? Based on min and max values.

I can comment on what some people and I at my company did for a scaleSequentialSymlog, since it was much more pain than it should be. Here are some revelations that will hopefully help you out

  • "ticks" are just an array of numbers so producing our own and putting them on the plot is possible
  • You can define multiple scales and not use them for any kind of plotting
  • The ideal-ish ticks in the positive direction would just be what the normal log scale returns for it's .ticks()
  • The ideal-ish ticks in the negative direction would just be what the normal log scale would return for .ticks() if you multiplied the values by -1

And the rest is just messing around to make the ticks look better. Basically, here is the psuedo-code

getTicks(min, max) {
  // for simplicity we assume the min is neg and max is pos
  negRange = d3.scaleLog([-min, 1], [color1, color2])  // note: we found we needed colors despite not using them...
  posRange = d3.scaleLog([1, max], [color1, color2])
  n_ticks  = negRange.ticks()
  p_ticks = posRange.ticks()
  n_ticks = makeNegativeAndReverse(n_ticks)
  return [...n_ticks , 0, ...p_ticks ]
}

I'd post the non-psuedocode, but it's filled with complexities of our implementation

you may also want to clean up the points that aren't a power of your base, so that their values don't get squishy. You'd use a method like the following within a different tickFormat function

  // don't claim this is a good algorithm, it works (base 10)
  keepTickValue(tick: number): boolean {
    if (tick === -1 || tick === 0 || tick === 1) {
      return true;
    } else {
      let remainder = tick % 10;
      if (remainder === 0) {
        if (Math.abs(tick) > 10) {
          return this.keepTickValue(tick / 10);
        } else {
          return true;
        }
      } else {
        return false;
      }
    }
  }

then you plot, using the scaleSequentialSymlog

      // Draw legend
      this.legend({
        color: this.color,
        title: this.title,
        width: this.width - 200,
        tickFormat: (x) => this.formatTick(x),
        tickValues: this.my_ticks,
      });

Here is what ours ended up looking like

image_2024-01-10_110745854

gabelepoudre avatar Jan 10 '24 16:01 gabelepoudre