Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nicer symlog ticks? #162

Open
jheer opened this issue Feb 1, 2019 · 15 comments
Open

Nicer symlog ticks? #162

jheer opened this issue Feb 1, 2019 · 15 comments

Comments

@jheer
Copy link
Contributor

jheer commented Feb 1, 2019

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+.

@mbostock
Copy link
Member

mbostock commented Feb 1, 2019

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!

Fil added a commit to Fil/d3-scale that referenced this issue Apr 8, 2019
@Fil
Copy link
Member

Fil commented Apr 8, 2019

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 Fil@aea855f 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

@kriestof
Copy link

kriestof commented Apr 4, 2020

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;
  }
}

@yitelee
Copy link

yitelee commented Jun 16, 2020

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

@Fil Fil mentioned this issue Jan 11, 2021
8 tasks
@tkakar
Copy link

tkakar commented Oct 31, 2022

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.

@AlleSchonWeg
Copy link

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

@gabelepoudre
Copy link

gabelepoudre commented Jan 10, 2024

@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

@curran
Copy link
Contributor

curran commented Oct 3, 2024

Here's another solution:

  const xTicks = g.selectAll('.x-axis .tick');
  let prevX = null;
  xTicks
    .filter((d) => {
      const x = xScale(d);
      if (prevX !== null && Math.abs(x - prevX) < 45)
        return true;
      prevX = x;
      return false;
    })
    .remove();

Before:

image

After:

image

Full example

https://vizhub.com/curran/0d41d2c126ab4258b2700a1d7cb04443?edit=files&file=index.js

@curran
Copy link
Contributor

curran commented Oct 3, 2024

It would be really nice to prefer "nice" values that end in multiples of 2, 5 or 10.

image

For example, I would like to see 10 and 20 here instead of 9 and 24. Not sure how to achieve this but thought I'd leave this here as an open question for further iteration on the tick filtering algorithm. Cheers!

@curran
Copy link
Contributor

curran commented Oct 3, 2024

Idea for prioritization scheme:

  • Highest priority tick: multiple of 10
  • Medium priority tick: multiple of 5 but not 10
  • Low priority tick: multiple of 2 but not 10
  • Lowest priority tick: everything else

Maybe the filtering algorithm can "look ahead" by a certain number of pixels to evaluate the alternatives, then pick the closest highest priority one.

@curran
Copy link
Contributor

curran commented Oct 3, 2024

I got closer.

image

  // Nicer symlog ticks
  const allCandidates = xScale.ticks(
    enableSymlog ? 100 : 10,
  );

  const minDistance = 25;
  const maxDistance = 131;
  const tickValues = [0];

  let i = 0;
  const n = allCandidates.length;

  let loopGuard = 0;

  while (i < n - 1 && loopGuard < 100) {
    loopGuard++;
    const candidateIndices = [];
    for (let j = i + 1; j < n - 1; j++) {
      const tickValue1 = allCandidates[i];
      const tickValue2 = allCandidates[j];

      const x1 = xScale(tickValue1);
      const x2 = xScale(tickValue2);

      const distance = x2 - x1;
      if (distance > maxDistance) break;
      if (distance > minDistance) {
        candidateIndices.push(j);
      }
    }
    if (candidateIndices.length === 0) {
      i++;
      continue;
    }

    const maxPriority = max(candidateIndices, (i) =>
      priority(allCandidates[i]),
    );
    const bestCandidateIndex = candidateIndices.find(
      (i) => priority(allCandidates[i]) === maxPriority,
    );

    tickValues.push(bestCandidateIndex);
    i = bestCandidateIndex;
  }

It's still not ideal though, because I would like to fill in those gaps with lesser priority ticks, e.g. between 10 and 20 I would like to see 15, because it would fit. Getting closer!

@curran
Copy link
Contributor

curran commented Oct 3, 2024

Holy moly! Nailed it with the help of Claude 3.5 Sonnet. Whoah AI is INSANE nowadays this is next level. The implementation is complex but seems to work perfectly.

image

Fully working example: https://vizhub.com/curran/symlog-ticks-example?edit=files&file=selectTicks.js

Here's the implementation of selectTicks.js that you can drop into your project:

// Priority levels for different types of tick values
const getPriority = (value) => {
  const absValue = Math.abs(value);
  if (absValue === 0) return 4; // Highest priority for zero
  if (absValue % 10 === 0) return 3; // High priority for multiples of 10
  if (absValue % 5 === 0) return 2; // Medium priority for multiples of 5
  if (absValue % 2 === 0) return 1; // Low priority for multiples of 2
  return 0; // Lowest priority for other numbers
};

// Find the best candidate to fill a gap
const findBestGapFiller = (
  gapStart,
  gapEnd,
  candidates,
  xScale,
  tickPositions,
  minDistance,
) => {
  // First try multiples of 5
  const fiveMultiples = candidates.filter(
    (v) => Math.abs(v) % 5 === 0,
  );
  if (fiveMultiples.length > 0) {
    // Find the most centered multiple of 5
    const middle = (gapStart + gapEnd) / 2;
    return fiveMultiples.reduce((best, current) =>
      Math.abs(current - middle) < Math.abs(best - middle)
        ? current
        : best,
    );
  }

  // If no multiples of 5 work, try multiples of 2
  const twoMultiples = candidates.filter(
    (v) => Math.abs(v) % 2 === 0,
  );
  if (twoMultiples.length > 0) {
    const middle = (gapStart + gapEnd) / 2;
    return twoMultiples.reduce((best, current) =>
      Math.abs(current - middle) < Math.abs(best - middle)
        ? current
        : best,
    );
  }

  // If nothing else works, use any available candidate
  const middle = (gapStart + gapEnd) / 2;
  return candidates.reduce((best, current) =>
    Math.abs(current - middle) < Math.abs(best - middle)
      ? current
      : best,
  );
};

// Check if a tick can be placed at a given position
const canPlaceTick = (x, existingXs, minDistance) => {
  return !existingXs.some(
    (existingX) => Math.abs(x - existingX) < minDistance,
  );
};

// Main tick selection algorithm
export const selectTicks = (xScale, options = {}) => {
  const {
    enableSymlog = true,
    minDistance = 25,
    maxDistance = 131,
    initialTickCount = 100,
    gapFillThreshold = 2.5, // If gap is this times minDistance, try to fill it
  } = options;

  const allCandidates = xScale.ticks(
    enableSymlog ? initialTickCount : 10,
  );
  const tickValues = [0];
  const tickPositions = [xScale(0)];

  let i = 0;
  const n = allCandidates.length;

  // First pass: place high-priority ticks
  while (i < n - 1) {
    const candidateIndices = [];
    for (let j = i + 1; j < n; j++) {
      const tickValue1 = allCandidates[i];
      const tickValue2 = allCandidates[j];

      const x1 = xScale(tickValue1);
      const x2 = xScale(tickValue2);

      const distance = Math.abs(x2 - x1);
      if (distance > maxDistance) break;
      if (distance >= minDistance) {
        candidateIndices.push(j);
      }
    }

    if (candidateIndices.length === 0) {
      i++;
      continue;
    }

    // Find highest priority candidate
    const maxPrio = Math.max(
      ...candidateIndices.map((idx) =>
        getPriority(allCandidates[idx]),
      ),
    );
    const bestIdx = candidateIndices.find(
      (idx) => getPriority(allCandidates[idx]) === maxPrio,
    );

    tickValues.push(allCandidates[bestIdx]);
    tickPositions.push(xScale(allCandidates[bestIdx]));
    i = bestIdx;
  }

  // Second pass: fill large gaps
  let filledGaps = true;
  while (filledGaps) {
    filledGaps = false;

    for (let i = 0; i < tickPositions.length - 1; i++) {
      const gap = tickPositions[i + 1] - tickPositions[i];

      if (gap >= minDistance * gapFillThreshold) {
        // Look for candidates to fill this gap
        const gapStart = tickValues[i];
        const gapEnd = tickValues[i + 1];

        const candidates = allCandidates.filter(
          (v) => v > gapStart && v < gapEnd,
        );

        if (candidates.length > 0) {
          const bestCandidate = findBestGapFiller(
            gapStart,
            gapEnd,
            candidates,
            xScale,
            tickPositions,
            minDistance,
          );

          const candidateX = xScale(bestCandidate);
          if (
            canPlaceTick(
              candidateX,
              tickPositions,
              minDistance,
            )
          ) {
            // Insert the new tick in the correct position
            const insertIdx = i + 1;
            tickValues.splice(insertIdx, 0, bestCandidate);
            tickPositions.splice(insertIdx, 0, candidateX);
            filledGaps = true;
            break;
          }
        }
      }
    }
  }

  return tickValues;
};

Example usage:

  const tickValues = selectTicks(xScale, {
    minDistance: 25,
    maxDistance: 131,
    gapFillThreshold: 2.5,
  });

  xAxis = axisBottom(xScale).tickValues(tickValues);

@curran
Copy link
Contributor

curran commented Oct 3, 2024

This could be made into its own library. Any interest in an NPM package that contains this?

@Fil
Copy link
Member

Fil commented Oct 3, 2024

The code checks for multiples of 10, but that's only useful in the 1—100 range. For larger magnitudes, you'll want multiples of 100, 1000, etc.

@curran
Copy link
Contributor

curran commented Oct 4, 2024

Very true! And the same goes for very small numbers as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

9 participants