Calculating the length of a segment in a logarithmic scale

Viewed 1310

I want to calculate the length of a line for a series of events.

I'm doing this with the following code.

var maxLineLength = 20;
var lineLen = function(x, max) {
    return maxLineLength * (x / max);
}
var events = [0.1, 1, 5, 20, 50];
var max = Math.max.apply(null, events);
events.map(function (x) {
    console.log(lineLen(x, max));
});

This works, but I'm using linear scaling, while I'd like to use logarithms, because I don't want small events to become too small numbers when big ones are present.

I modified the lineLen function as you can see below, but - obviously - it doesn't work for events equals to one, because the log of one is zero. I want to show events equals to one (opportunely scaled) and not make them become zero. I also need positive numbers to remain positive (0.1 becomes a negative number)

How should I modify lineLen to use a logarithmic scale?

var maxLineLength = 20;
var lineLen = function(x, max) {
   return maxLineLength * (Math.log(x) / Math.log(max));
}
var events = [0.1, 1, 5, 20, 50];
var max = Math.max.apply(null, events);
events.map(function (x) {
    console.log(lineLen(x, max));
});

6 Answers

You can use an expression like Math.pow(x, 0.35) instead of Math.log(x). It keeps all values positive, and gives the behavior that you want for small ratios. You can experiment with different exponent values in the range (0,1) to find the one that fits your needs.

var maxLineLength = 20;
var exponent = 0.35;
var lineLen = function(x, max) {
   return maxLineLength * Math.pow(x/max, exponent);
}
var events = [0, 0.01, 0.1, 1, 5, 20, 50];
var max = Math.max.apply(null, events);
events.map(function (x) {
    console.log(lineLen(x, max));
});

You can take log(x+1) instead of log(x), that doesn't change the value too much and the ratios are maintained for smaller numbers.

var maxLineLength = 20;
var lineLen = (x, max) => maxLineLength * Math.log(x+1)/Math.log(max+1);
var events = [ 0.1, 1, 5, 20, 50];
var visualizer = function(events){
    var max = Math.max.apply(null, events);
    return events.reduce((y, x) => {
         y.push(lineLen(x, max));
         return y;
    }, []);
};

console.log(visualizer(events));

You could decrement maxLineLength and add one at the end of the calculation.

For values smaller than one, you could use a factor which normalizes all values relative to the first value. The start value is always one, or in terms of logaritmic view, zero.

var maxLineLength = 20,
    lineLen = function(max) {
        return function (x) {
            return (maxLineLength - 1) * Math.log(x) / Math.log(max) + 1;
        };
    },
    events = [0.1, 1, 5, 20, 50],
    normalized = events.map((v, _, a) => v / a[0]),
    max = Math.max.apply(null, normalized),
    result = normalized.map(lineLen(max));

console.log(result);
console.log(normalized);
.as-console-wrapper { max-height: 100% !important; top: 0; }

Shorter but not too short; longer but not too long.

Actually I met the same problem years ago, and gave up for this (maybe?). I've just read your question here and now, and I think I've just found the solution: shifting.

const log = (base, value) => (Math.log(value) / Math.log(base));

const weights = [0, 0.1, 1, 5, 20, 50, 100];
const base = Math.E; // Setting

const shifted = weights.map(x => x + base);
const logged = shifted.map(x => log(base, x));
const unshifted = logged.map(x => x - 1);

const total = unshifted.reduce((a, b) => a + b, 0);
const ratio = unshifted.map(x => x / total);
const percents = ratio.map(x => x * 100);

console.log(percents);
// [
//   0,
//   0.35723375538333857,
//   3.097582209424984,
//   10.3192042142806,
//   20.994247877004888,
//   29.318026542735115,
//   35.91370540117108
// ]

Visualization

The smaller the logarithmic base is, the more they are adjusted; and vice versa. Actually I don't know the reason. XD

<!DOCTYPE html>
<html>
 <head>
  <meta name="author" content="K.">

  <title>Shorter but not too short; longer but not too long.</title>

  <style>
   canvas
   {
    background-color: whitesmoke;
   }
  </style>
 </head>

 <body>
  <canvas id="canvas" height="5"></canvas>

  <label>Weights: <input id="weights" type="text" value="[0, 0.1, 100, 1, 5, 20, 2.718, 50]">.</label>
  <label>Base: <input id="base" type="number" value="2.718281828459045">.</label>
  <button id="draw" type="button">Draw</button>

  <script>
   const input = new Proxy({}, {
    get(_, thing)
    {
     return eval(document.getElementById(thing).value);
    }
   });
   const color = ["tomato", "black"];
   const canvas_element = document.getElementById("canvas");
   const canvas_context = canvas_element.getContext("2d");
   canvas_element.width = document.body.clientWidth;

   document.getElementById("draw").addEventListener("click", _ => {
    const log = (base, value) => (Math.log(value) / Math.log(base));

    const weights = input.weights;
    const base = input.base;

    const orig_total = weights.reduce((a, b) => a + b, 0);
    const orig_percents = weights.map(x => x / orig_total * 100);

    const adjusted = weights.map(x => x + base);
    const logged = adjusted.map(x => log(base, x));
    const rebased = logged.map(x => x - 1);

    const total = rebased.reduce((a, b) => a + b, 0);
    const ratio = rebased.map(x => x / total);
    const percents = ratio.map(x => x * 100);
    const result = percents.map((percent, index) => `${weights[index]} | ${orig_percents[index]}% --> ${percent}% (${color[index & 1]})`);
    console.info(result);

    let position = 0;
    ratio.forEach((rate, index) => {
     canvas_context.beginPath();
     canvas_context.moveTo(position, 0);
     position += (canvas_element.width * rate);
     canvas_context.lineTo(position, 0);
     canvas_context.lineWidth = 10;
     canvas_context.strokeStyle = color[index & 1];
     canvas_context.stroke();
    });
   });
  </script>
 </body>
</html>

You are scaling these numbers. Your starting set is the domain, what you end up with is the range. The shape of that transformation, it sounds like, will be either log or follow a power.

It turns out this is a very common problem in many fields, especially data visualization. That is why D3.js - THE data visualization tool - has everything you need to do this easily.

 const x = d3.scale.log().range([0, events]);

It's the right to for the job. And if you need to do some graphs, you're all set!

As long as your events are > 0 you can avoid shifting by scaling the events so the minimum value is above 1. The scalar can be calculated based on a minimum line length in addition to the maximum line length you already have.

// generates an array of line lengths between minLineLength and maxLineLength
// assumes events contains only values > 0 and 0 < minLineLength < maxLineLength
function generateLineLengths(events, minLineLength, maxLineLength) {
  var min = Math.min.apply(null, events);
  var max = Math.max.apply(null, events);

  //calculate scalar that sets line length for the minimum value in events to minLineLength
  var mmr = minLineLength / maxLineLength;
  var scalar = Math.pow(Math.pow(max, mmr) / min, 1 / (1 - mmr));

  function lineLength(x) {
    return maxLineLength * (Math.log(x * scalar) / Math.log(max * scalar));
  }

  return events.map(lineLength)
}

var events = [0.1, 1, 5, 20, 50];

console.log('Between 1 and 20')
generateLineLengths(events, 1, 20).forEach(function(x) {
  console.log(x)
})
// 1
// 8.039722549519123
// 12.960277450480875
// 17.19861274759562
// 20

console.log('Between 5 and 25')
generateLineLengths(events, 5, 25).forEach(function(x) {
  console.log(x)
})
// 5
// 12.410234262651711
// 17.58976573734829
// 22.05117131325855
// 25

Related