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

Symmetric logarithmic (symlog) scale #11919

Open
Ryczko opened this issue Oct 5, 2024 · 0 comments
Open

Symmetric logarithmic (symlog) scale #11919

Ryczko opened this issue Oct 5, 2024 · 0 comments

Comments

@Ryczko
Copy link

Ryczko commented Oct 5, 2024

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

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

No branches or pull requests

1 participant