diff --git a/src/scale/Log.ts b/src/scale/Log.ts index b1f4d08b16..5f24d0bf76 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -27,19 +27,25 @@ 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 { static type = 'log'; readonly type = 'log'; @@ -47,15 +53,17 @@ class LogScale extends Scale { 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. */ @@ -63,12 +71,22 @@ class LogScale extends Scale { 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 + 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[][] { + 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; + } + + 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) + ); + 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,13 +266,19 @@ 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 { @@ -191,14 +286,27 @@ class LogScale extends Scale { 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)); } diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 3302b165ee..6cc3ba7cde 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -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) { + return 0; + } + return mathLog(Math.abs(x)) / mathLog(base); +} diff --git a/test/logScale-negative.html b/test/logScale-negative.html new file mode 100644 index 0000000000..613f64526f --- /dev/null +++ b/test/logScale-negative.html @@ -0,0 +1,134 @@ + + + + + + + + + + + +
+
+ + + + diff --git a/test/logScale.html b/test/logScale.html index def5bfdfdc..665e8a81a8 100644 --- a/test/logScale.html +++ b/test/logScale.html @@ -50,7 +50,6 @@ }, legend: { left: 'left', - //data: ['2的指数', '3的指数'] }, xAxis: [{ type: 'category', @@ -61,7 +60,19 @@ yAxis: [{ type: 'log', name: 'y', - scale: false + scale: false, + minorTick: { + show: true, + splitNumber: 10, + }, + splitLine: { + show: true, + }, + minorSplitLine: { + show: true, + }, + min: (value) => value.min, + max: (value) => value.max, }], series: [ { @@ -78,6 +89,11 @@ name: '0.1 的指数', type: 'line', data: [1, 0.1, 0.01, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8] + }, + { + name: '0.1 的指数 reversed, pass through 0', + type: 'line', + data: [1e-6, 1e-5, 1e-4, 1e-3, 0.01, 0.1, 1, 10, 100] }] }); }); @@ -91,12 +107,20 @@ var chart = echarts.init(document.getElementById('main1')); // See #13154 chart.setOption({ + tooltip: { + trigger: 'item', + formatter: '{a}
({c})' + }, xAxis: { - type: 'value', + type: 'log', }, yAxis: { type: 'log', logBase: 2, + minorTick: { + show: true, + splitNumber: 4, + }, }, series: [ { @@ -105,7 +129,11 @@ color: 'red', }, data: [ - [1, .5], + [1e-4, 2**(-8)], + [1e-2, 2**(-4)], + [1, 2**0], + [100, 2**4], + [1e4, 2**8], ], }, ], @@ -114,4 +142,4 @@ - \ No newline at end of file + diff --git a/test/ut/spec/scale/interval.test.ts b/test/ut/spec/scale/interval.test.ts index 60481156b8..91ef067a71 100755 --- a/test/ut/spec/scale/interval.test.ts +++ b/test/ut/spec/scale/interval.test.ts @@ -22,10 +22,23 @@ import { createChart, getECModel } from '../../core/utHelper'; import { EChartsType } from '@/src/echarts'; import CartesianAxisModel from '@/src/coord/cartesian/AxisModel'; import IntervalScale from '@/src/scale/Interval'; -import { intervalScaleNiceTicks } from '@/src/scale/helper'; +import { intervalScaleNiceTicks, absMathLog } from '@/src/scale/helper'; import { getPrecisionSafe } from '@/src/util/number'; +describe('helpers', function () { + it('absMathLog', function () { + expect(absMathLog(10)).toEqual(1); + expect(absMathLog(-10)).toEqual(1); + + expect(absMathLog(-1e-8)).toEqual(-8); + expect(absMathLog(1e8)).toEqual(8); + + expect(absMathLog(0)).toEqual(0); + expect(absMathLog(8, 2)).toEqual(3); + }); +}); + describe('scale_interval', function () { let chart: EChartsType;