Chart.js
Chart.js copied to clipboard
Symmetric logarithmic (symlog) scale
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;
}
}
I need too