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

Support negative values for log Scale. #20872

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 94 additions & 51 deletions src/scale/Log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,48 +27,57 @@ import IntervalScale from './Interval';
import SeriesData from '../data/SeriesData';
import { DimensionName, ScaleTick } from '../util/types';

const scaleProto = Scale.prototype;
// FIXME:TS refactor: not good to call it directly with `this`?
const intervalScaleProto = IntervalScale.prototype;

const roundingErrorFix = numberUtil.round;

const mathFloor = Math.floor;
const mathCeil = Math.ceil;
const mathPow = Math.pow;

const mathLog = Math.log;

class LogScale extends Scale {
const mathMax = Math.max;
const mathRound = Math.round;

// LogScale does not have any specific settings
type LogScaleSetting = {};

/**
* LogScale is a scale that maps values to a logarithmic range.
*
* Support for negative values is implemented by inverting the extents and first handling values as absolute values.
* Then in tick generation, the tick values are multiplied by -1 back to the original values and the normalize function
* uses a reverse extent to get the correct negative values in plot with smaller values at the top of Y axis.
*/
class LogScale extends IntervalScale<LogScaleSetting> {
static type = 'log';
readonly type = 'log';

base = 10;

private _originalScale: IntervalScale = new IntervalScale();

/**
* Whether the original input values are negative.
*
* @type {boolean}
* @private
*/
private _isNegative: boolean = false;

private _fixMin: boolean;
private _fixMax: boolean;

// FIXME:TS actually used by `IntervalScale`
private _interval: number = 0;
// FIXME:TS actually used by `IntervalScale`
private _niceExtent: [number, number];


/**
* @param Whether expand the ticks to niced extent.
*/
getTicks(expandToNicedExtent?: boolean): ScaleTick[] {
const originalScale = this._originalScale;
const extent = this._extent;
const originalExtent = originalScale.getExtent();
const negativeMultiplier = this._isNegative ? -1 : 1;

const ticks = intervalScaleProto.getTicks.call(this, expandToNicedExtent);
const ticks = super.getTicks(expandToNicedExtent);

return zrUtil.map(ticks, function (tick) {
const val = tick.value;
let powVal = numberUtil.round(mathPow(this.base, val));
let powVal = mathPow(this.base, val);

// Fix #4158
powVal = (val === extent[0] && this._fixMin)
Expand All @@ -79,27 +88,28 @@ class LogScale extends Scale {
: powVal;

return {
value: powVal
value: powVal * negativeMultiplier
};
}, this);
}

setExtent(start: number, end: number): void {
const base = mathLog(this.base);
// Assume the start and end can be infinity
// log(-Infinity) is NaN, so safe guard here
start = mathLog(Math.max(0, start)) / base;
end = mathLog(Math.max(0, end)) / base;
intervalScaleProto.setExtent.call(this, start, end);
if (start < Infinity) {
start = scaleHelper.absMathLog(start, this.base);
}
if (end > -Infinity) {
end = scaleHelper.absMathLog(end, this.base);
}

super.setExtent(start, end);
}

/**
* @return {number} end
*/
getExtent() {
const base = this.base;
const extent = scaleProto.getExtent.call(this);
extent[0] = mathPow(base, extent[0]);
extent[1] = mathPow(base, extent[1]);
getExtent(): [number, number] {
const extent = super.getExtent();
extent[0] = mathPow(this.base, extent[0]);
extent[1] = mathPow(this.base, extent[1]);

// Fix #4158
const originalScale = this._originalScale;
Expand All @@ -113,10 +123,19 @@ class LogScale extends Scale {
unionExtent(extent: [number, number]): void {
this._originalScale.unionExtent(extent);

const base = this.base;
extent[0] = mathLog(extent[0]) / mathLog(base);
extent[1] = mathLog(extent[1]) / mathLog(base);
scaleProto.unionExtent.call(this, extent);
if (extent[0] < 0 && extent[1] < 0) {
// If both extent are negative, switch to plotting negative values.
// If there are only some negative values, they will be plotted incorrectly as positive values.
this._isNegative = true;
}

const [logStart, logEnd] = this.getLogExtent(extent[0], extent[1]);

extent[0] = logStart;
extent[1] = logEnd;

extent[0] < this._extent[0] && (this._extent[0] = extent[0]);
extent[1] > this._extent[1] && (this._extent[1] = extent[1]);
}

unionExtentFromData(data: SeriesData, dim: DimensionName): void {
Expand All @@ -131,13 +150,18 @@ class LogScale extends Scale {
*/
calcNiceTicks(approxTickNum: number): void {
approxTickNum = approxTickNum || 10;
const extent = this._extent;
const span = extent[1] - extent[0];

const span = this._extent[1] - this._extent[0];

if (span === Infinity || span <= 0) {
return;
}

let interval = numberUtil.quantity(span);
let interval = mathMax(
1,
mathRound(span / approxTickNum)
);

const err = approxTickNum / span * interval;

// Filter ticks to get closer to the desired count.
Expand All @@ -150,10 +174,10 @@ class LogScale extends Scale {
interval *= 10;
}

const niceExtent = [
numberUtil.round(mathCeil(extent[0] / interval) * interval),
numberUtil.round(mathFloor(extent[1] / interval) * interval)
] as [number, number];
const niceExtent: [number, number] = [
mathFloor(this._extent[0] / interval) * interval,
mathCeil(this._extent[1] / interval) * interval
];

this._interval = interval;
this._niceExtent = niceExtent;
Expand All @@ -166,7 +190,7 @@ class LogScale extends Scale {
minInterval?: number,
maxInterval?: number
}): void {
intervalScaleProto.calcNiceExtent.call(this, opt);
super.calcNiceExtent(opt);

this._fixMin = opt.fixMin;
this._fixMax = opt.fixMax;
Expand All @@ -177,28 +201,47 @@ class LogScale extends Scale {
}

contain(val: number): boolean {
val = mathLog(val) / mathLog(this.base);
val = scaleHelper.absMathLog(val, this.base);
return scaleHelper.contain(val, this._extent);
}

normalize(val: number): number {
val = mathLog(val) / mathLog(this.base);
return scaleHelper.normalize(val, this._extent);
normalize(inputVal: number): number {
const val = scaleHelper.absMathLog(inputVal, this.base);
let ex: [number, number] = [this._extent[0], this._extent[1]];

if (this._isNegative) {
// Invert the extent for normalize calculations as the extent is inverted for negative values.
ex = [this._extent[1], this._extent[0]];
}
return scaleHelper.normalize(val, ex);
}

scale(val: number): number {
val = scaleHelper.scale(val, this._extent);
return mathPow(this.base, val);
}

getMinorTicks: IntervalScale['getMinorTicks'];
getLabel: IntervalScale['getLabel'];
/**
* Get the extent of the log scale.
* @param start - The start value of the extent.
* @param end - The end value of the extent.
* @returns The extent of the log scale. The extent is reversed for negative values.
*/
getLogExtent(start: number, end: number): [number, number] {
// Invert the extent but use absolute values
if (this._isNegative) {
const logStart = scaleHelper.absMathLog(Math.abs(end), this.base);
const logEnd = scaleHelper.absMathLog(Math.abs(start), this.base);
return [logStart, logEnd];
}
else {
const logStart = scaleHelper.absMathLog(start, this.base);
const logEnd = scaleHelper.absMathLog(end, this.base);
return [logStart, logEnd];
}
}
}

const proto = LogScale.prototype;
proto.getMinorTicks = intervalScaleProto.getMinorTicks;
proto.getLabel = intervalScaleProto.getLabel;

function fixRoundingError(val: number, originalVal: number): number {
return roundingErrorFix(val, numberUtil.getPrecision(originalVal));
}
Expand Down
19 changes: 19 additions & 0 deletions src/scale/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type intervalScaleNiceTicksResult = {
niceTickExtent: [number, number]
};

const mathLog = Math.log;

export function isValueNice(val: number) {
const exp10 = Math.pow(10, quantityExponent(Math.abs(val)));
const f = Math.abs(val / exp10);
Expand Down Expand Up @@ -136,3 +138,20 @@ export function normalize(val: number, extent: [number, number]): number {
export function scale(val: number, extent: [number, number]): number {
return val * (extent[1] - extent[0]) + extent[0];
}

/**
* Calculates the absolute logarithm of a number with a specified base.
* Handles edge cases by:
* - Returning 0 for values very close to 0 (within Number.EPSILON)
* - Taking the absolute value of the input to handle negative numbers
*
* @param x - The number to calculate the logarithm of
* @param base - The base of the logarithm (defaults to 10)
* @returns The absolute logarithm value, or 0 if x is very close to 0
*/
export function absMathLog(x: number, base = 10): number {
if (Math.abs(x) < Number.EPSILON) {
return 0;
}
return mathLog(Math.abs(x)) / mathLog(base);
}
Loading