Chart.js icon indicating copy to clipboard operation
Chart.js copied to clipboard

Symmetric logarithmic (symlog) scale

Open Ryczko opened this issue 1 year ago • 1 comments

Feature Proposal

In a project that used Chart.js I came across a situation where we needed a logarithmic scale, but with the ability to display negative values ​​as well. Unfortunately, the library does not provide such an option, and I think it can be useful in many cases.

Possible Implementation

In our case this was solved by creating a symmetric logarithmic scale.

import { BubbleDataPoint, Chart, ChartTypeRegistry, Point, Scale } from 'chart.js';

const constant = 1;

const symlogTransform = (value: number) => {
  return Math.sign(value) * Math.log10(Math.abs(value) / constant + 1);
};

const symlogInverseTransform = (value: number) => {
  return Math.sign(value) * (Math.pow(10, Math.abs(value)) - 1) * constant;
};

export class SymlogScale extends Scale {
  static id = 'symlog';
  private _startValue!: number;
  private _valueRange!: number;

  constructor(cfg: {
    id: string;
    type: string;
    ctx: CanvasRenderingContext2D;
    chart: Chart<keyof ChartTypeRegistry, (number | [number, number] | Point | BubbleDataPoint | null)[], unknown>;
  }) {
    super(cfg);
  }

  parse(raw: unknown, index: number | undefined) {
    const value = super.parse(raw, index) as number;
    return symlogTransform(value);
  }

  determineDataLimits() {
    const { min, max } = this.getMinMax(true);
    this.min = symlogTransform(min);
    this.max = symlogTransform(max);
    this._startValue = this.min;
    this._valueRange = this.max - this.min;
  }

  getPixelForValue(value: number) {
    const symlogValue = symlogTransform(value);
    const decimal = (symlogValue - this._startValue) / this._valueRange;
    return this.getPixelForDecimal(decimal);
  }

  getLabelForValue(value: number) {
    return symlogInverseTransform(value).toLocaleString();
  }

  minimumButNotZero(alwaysNotZero: number, other: number) {
    if (!other || other === 0) {
      return alwaysNotZero;
    } else {
      return Math.min(alwaysNotZero, other);
    }
  }

  minDecimalPlaces(numbers: number[]) {
    if (!numbers || numbers.length == 0) {
      return 0;
    }
    numbers.sort((a, b) => a - b);

    let smallest = this.minimumButNotZero(Infinity, Math.abs(numbers[0]));
    for (let i = 0; i < numbers.length - 1; i++) {
      smallest = this.minimumButNotZero(smallest, numbers[i + 1] - numbers[i]);
      smallest = this.minimumButNotZero(smallest, Math.abs(numbers[i + 1]));
    }

    return Math.max(0, Math.ceil(-Math.log10(smallest)));
  }

  generateTickLabels(ticks: any[]) {
    const minimalDecimalPlaces = this.minDecimalPlaces(ticks.map(tick => tick.value));

    ticks.forEach((tick: { label: string; value: number }) => {
      tick.label = parseFloat(symlogInverseTransform(tick.value).toFixed(minimalDecimalPlaces)).toString();
    });
  }

  buildTicks() {
    const ticks = [];
    const tickCount = 11;
    const min = symlogInverseTransform(this.min);
    const max = symlogInverseTransform(this.max);
    const range = max - min;
    const stepSize = range / (tickCount - 1);
    for (let i = min; i <= max; i += stepSize) {
      ticks.push({ value: i });
    }
    return ticks;
  }
}

showcase

Ryczko avatar Oct 05 '24 13:10 Ryczko

I need too

chaoqi66 avatar Feb 14 '25 10:02 chaoqi66