-
Notifications
You must be signed in to change notification settings - Fork 19.8k
Support negative values for log Scale. #20872
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,48 +27,66 @@ 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; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From my understanding: A new log scale method should enabled explicitly by a new optione.g., Log scale methods comparison
Additionally, consider value
Some related test cases:
After this comparison, I'm afraid the "negative log" method proposed in this PR doesn't seem sufficiently necessary to be a built-in method in echarts. Correct me if I missed any cases that require this method and can not be covered by other methods above. |
||
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 = super.getTicks(expandToNicedExtent); | ||
|
||
|
||
const ticks = intervalScaleProto.getTicks.call(this, expandToNicedExtent); | ||
// Ticks are created using the nice extent, but that can cause the first and last tick to be well outside the extent | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The span of nice extent is supposed to be greater than (or equals to) the extent calculated from series.data. |
||
if (ticks[0].value < this._extent[0]) { | ||
ticks[0].value = this._extent[0]; | ||
} | ||
if (ticks[ticks.length - 1].value > this._extent[1]) { | ||
ticks[ticks.length - 1].value = this._extent[1]; | ||
} | ||
|
||
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) | ||
|
@@ -79,27 +97,84 @@ class LogScale extends Scale { | |
: powVal; | ||
|
||
return { | ||
value: powVal | ||
value: powVal * negativeMultiplier | ||
}; | ||
}, this); | ||
} | ||
|
||
/** | ||
* Get minor ticks for log scale. Ticks are generated based on a decade so that 5 splits | ||
* between 1 and 10 would be 2, 4, 6, 8 and | ||
* between 5 and 10 would be 6, 8. | ||
* @param splitNumber Get minor ticks number. | ||
* @returns Minor ticks. | ||
*/ | ||
getMinorTicks(splitNumber: number): number[][] { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new implementation introduces a breaking change to minor ticks in logarithmic axis. The effect in this PR is based on the value that log is performed: I thinks both of approaches make some sense (though personally I think the previous one might be more meaningful to hint users the fact of logarithm). Anyway,
|
||
const ticks = this.getTicks(true); | ||
const minorTicks = []; | ||
const negativeMultiplier = this._isNegative ? -1 : 1; | ||
|
||
for (let i = 1; i < ticks.length; i++) { | ||
const nextTick = ticks[i]; | ||
const prevTick = ticks[i - 1]; | ||
const logNextTick = Math.ceil(scaleHelper.absMathLog(nextTick.value, this.base)); | ||
const logPrevTick = Math.round(scaleHelper.absMathLog(prevTick.value, this.base)); | ||
|
||
const minorTicksGroup: number[] = []; | ||
const tickDiff = logNextTick - logPrevTick; | ||
const overDecade = tickDiff > 1; | ||
|
||
if (overDecade) { | ||
// For spans over a decade, generate evenly spaced ticks in log space | ||
// For example, between 1 and 100, generate a tick at 10 | ||
const step = Math.ceil(tickDiff / splitNumber); | ||
|
||
let minorTickValue = Math.pow(this.base, logPrevTick + step); | ||
let j = 1; | ||
while (minorTickValue < nextTick.value) { | ||
minorTicksGroup.push(minorTickValue); | ||
|
||
j++; | ||
minorTickValue = Math.pow(this.base, logPrevTick + j * step) * negativeMultiplier; | ||
} | ||
} | ||
else { | ||
// For spans within a decade, generate linear subdivisions | ||
// For example, between 1 and 10 with splitNumber=5, generate ticks at 2, 4, 6, 8 | ||
const maxValue = Math.pow(this.base, logNextTick); | ||
|
||
// Divide the space linearly between min and max | ||
const step = maxValue / splitNumber; | ||
let minorTickValue = step; | ||
while (minorTickValue < nextTick.value) { | ||
minorTicksGroup.push(minorTickValue * negativeMultiplier); | ||
minorTickValue += step; | ||
} | ||
} | ||
|
||
minorTicks.push(minorTicksGroup); | ||
} | ||
|
||
return minorTicks; | ||
} | ||
|
||
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; | ||
|
@@ -113,10 +188,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; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A axis scale is commonly shared by multiple series. I think a "all negative log scale" is inherently not self-adaptable - it can be only used in the specific case where all data in series are negative. If we provide that feature, it should be explicitly declared in option - making users know that they're using that feature for that specific case. See also the comment at the header of this code review. |
||
|
||
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 { | ||
|
@@ -131,13 +215,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) | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The original implementation uses the 10-based value as interval, while this PR do not use that strategy. I think if introducing other base (e.g., 2-based ticks), it is a separate topic, and should be applied uniformly rather than only in log scale, and controlled by some new options. Moreover, this change seems to be inconsistent with the following process like |
||
|
||
const err = approxTickNum / span * interval; | ||
|
||
// Filter ticks to get closer to the desired count. | ||
|
@@ -150,10 +239,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; | ||
|
@@ -166,7 +255,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; | ||
|
@@ -177,28 +266,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)); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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); | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not fully understand the choice of
|
||
return 0; | ||
} | ||
return mathLog(Math.abs(x)) / mathLog(base); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
class Log
has been refactored in echarts v6.Therefore, this PR has some conflicts with the current codebase.