Chartjs-Does chartjs support negative values with logarithmic scale?

0👍

Having a look to the code, the logarithmic scale is setting minimum value to 0 (for min and max).

https://github.com/chartjs/Chart.js/blob/master/src/scales/scale.logarithmic.js#L85-L96

0👍

I faced the same issue recently, where I initially was using this Log2 axis implementation, and according to this github issue, they don’t plan to support negative values on the log axis.

I had my own idea of doing this, but couldn’t find a similar solution after some research, so I decided to make my own Log2Axis version, and it works pretty well in the latest version v3.9.1 (especially with the parameter beginAtZero). My approach is simple :

  • For positive values > 2 >> no change
  • Values between "-2" and base "2" will be transformed to [-1,1] to preserve continuity while transitioning from log2 to y = x / 2 (read more below), and thus, Zero will be 0 (not that "log(0) = 0", but since the main reason to use log axis is to view big and small values together, it doesn’t make sense to be bound by log limitations for negative values !).
  • Negative values below -2 will simply be transformed into positive, while preserving their sign like this : Math.sign(v) * Math.log2( Math.abs(v) )

This way, we can have both positive and negative, big and small values in the same area, and here is a live example how it works :

//LogAxis definition
class LogAxis extends Chart.Scale{
  constructor(cfg){ super(cfg); this._startValue = undefined; this._valueRange = 0;
    const log = cfg.chart.config.options.scales.y.log || [2,Math.log2];
    this._base = log[0]; this._log = log[1];}
  parse(raw, index){
    const value = Chart.LinearScale.prototype.parse.apply(this, [raw, index]);
    return isFinite(value) ? value : null;
  }
  determineDataLimits(){
    const {min, max} = this.getMinMax(true);
    this.min = isFinite(min) ? min : null; this.max = isFinite(max) ? max : null;
  }
  buildTicks(){
    const ticks = [], aMin=Math.abs(this.min), aMax=Math.abs(this.max);
    if(aMin<=this._base){ticks.push({value: this._base*Math.sign(this.min)});}
    let v, power = Math.floor( (aMin>this._base ? Math.sign(this.min):1)*this._log( this.options.beginAtZero && this.min>0 ? 1 : (aMin>this._base ? aMin : 1) )),
      maxPower = Math.ceil( (aMax>this._base ? Math.sign(this.max):1)*this._log( this.options.beginAtZero && this.max<0 ? 1 : (aMax>this._base ? aMax : 1) ));
    while(power <= maxPower){
      ticks.push({value: Math.sign(power)*Math.pow(this._base, Math.abs(power)) }); power += 1;
    }
    if(aMax<=this._base){ticks.push({value: this._base*Math.sign(this.max)});}      
    v=ticks.map(x=>x.value);
    this.min = Math.min(...v); this.max = Math.max(...v);
    return ticks;
  }
  getLogVal(v){ var aV=Math.abs(v); return aV>this._base ? Math.sign(v)*this._log(aV) : v/this._base;}
  configure(){/* @protected*/
    const start = this.min; super.configure();
    this._startValue = this.getLogVal(start);
    this._valueRange = this.getLogVal(this.max) - this.getLogVal(start);
  }
  getPixelForValue(value){
    if(value === undefined){value = this.min;}
    return this.getPixelForDecimal( (this.getLogVal(value) - this._startValue) / this._valueRange);
  } 
  getValueForPixel(pixel){
    const decimal = this.getLogVal(this.getDecimalForPixel(pixel));
    return Math.pow(2, this._startValue + decimal * this._valueRange);
  }
} LogAxis.id = 'mylog'; LogAxis.defaults = {}; Chart.register(LogAxis);

//Utils and button handlers
const Utils={
  RandomNumbers:function(num,min,max){ var i,nums=[];
    for(i=0;i<num;i++){
      nums.push( min+Math.round( (max-min)*Math.random() ));
    }
    return nums;
  }, Randomize:function(canvId,params){
    var chart = Chart.getChart(canvId), min= params[0], max= params[1];
    chart.data.datasets.forEach( (d,i) => {
      d.data = Utils.RandomNumbers(d.data.length,min,max);
    });
    chart.update();
  }
};
var maxLog2=10000, log2D0=0;
bRandData1.onclick= function(e){Utils.Randomize('chart',[0,maxLog2]);};
bRandData2.onclick= function(e){Utils.Randomize('chart',[-maxLog2,0]);};
bRandData3.onclick= function(e){Utils.Randomize('chart',[-maxLog2,maxLog2]);};
bToggle1.onclick= function(e){
  var chart=Chart.getChart("chart"), d0=chart.data.datasets[0].data[0];
  if(d0!=0){log2D0=d0}
  chart.data.datasets[0].data[0]= d0==0?log2D0:0;
  chart.update();
};
bToggle2.onclick= function(e){
  var chart=Chart.getChart("chart");
  chart.config.options.scales.y.beginAtZero = !chart.config.options.scales.y.beginAtZero;
  chart.update();
};

//Chart config
const data ={
  labels:['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
  datasets:[ { label:'My Log2 Dataset',
    data: Utils.RandomNumbers(12,0,1000),
    backgroundColor:'blue',
    borderColor:'blue',
    borderWidth:2}
  ]
}, config ={ type:'line', data:data,
  options:{ responsive:true,
    plugins:{
      title: {
        display:true,
        text:'Log Derived Axis Type',
        color:'#bb8899'
      }, tooltip: {
        interaction: {intersect:false, mode:'nearest', axis:'x'}
      }
    }, scales:{ x:{display:true},
      y:{
        display:true,
        beginAtZero:false,
        type:'mylog', // <-- you have to set the scale id 'mylog' to use LogAxis
        log: [2, Math.log2] // <-- a config array to change the log type directly from the chart options, without changing the class definition.
        // If omitted, it's [2, Math.log2] by default, but you can change it to [10, Math.log10] to use log10 or other log functions.
      }
    }
  }
};
const ctx = document.getElementById('chart').getContext('2d');
new Chart(ctx,config);
body{white-space:nowrap;}
button{margin:3px 3px 3px 0;}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>

<canvas id="chart"></canvas>
<br>
Randomize : <button id="bRandData1">Positive</button>
<button id="bRandData2">Negative</button> 
<button id="bRandData3">Both</button> | 
Toggle : <button id="bToggle1">data[0]=0</button>
<button id="bToggle2">beginAtZero</button>

EDIT
My initial solution didn’t work for values between [-base,base] (where base is the log base 2, 10, etc), but only after some thorough investigation, I could finally make it work by modifying the transformation function, which I extended to work with any custom log function, not just log2 (read below), and here is the log2 version :

x => Math.sign(x) * Math.log2( |x| ) , for |x| >= 2
x => x/2 , for |x| <= 2

Now you can just set the "log" parameter inside your options > scales > y, without touching the initial class LogAxis and it will work the same way, and I find log2 more convenient as it has more visibility for small values compared to log10.

For more insight how this works, you can play with this online graph maker, where a slider helps visualize the interval [-base,base] along with the log function and the "filler" function y = x / l.

Log class logic

After linking all 3 pieces, we end up with this function.

final link function

And to make the middle part smoother, you can modify y=x/l inside getLogVal function to something like this :

smoother function

The only difference is that values near 0 will be much closer to each other, but it’s important to use only monotone functions inside [-base,base], verifying f(0)=0 to preserve 0 and the values sign !

Leave a comment