From f1829b8dcaeed308c9028163fbb3e395bb83f73f Mon Sep 17 00:00:00 2001 From: Antti Palola Date: Thu, 27 Mar 2025 00:06:52 +0200 Subject: [PATCH 1/4] Support negative values for log Scale. Support implemented by inverting the extents at input and handling the input as absolute value and finally mapping everything back to negative axes in inverted order. Added test cases for passing through 0 power and new a new file ogScale-negative.html. --- src/scale/Log.ts | 109 ++++++++++++++++------ src/scale/helper.ts | 19 ++++ test/logScale-negative.html | 134 ++++++++++++++++++++++++++++ test/logScale.html | 22 +++-- test/ut/spec/scale/interval.test.ts | 15 +++- 5 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 test/logScale-negative.html diff --git a/src/scale/Log.ts b/src/scale/Log.ts index b1f4d08b16..f835df5932 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -36,9 +36,16 @@ const roundingErrorFix = numberUtil.round; const mathFloor = Math.floor; const mathCeil = Math.ceil; const mathPow = Math.pow; - -const mathLog = Math.log; - +const mathMax = Math.max; +const mathRound = Math.round; + +/** + * 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 Scale { static type = 'log'; readonly type = 'log'; @@ -47,6 +54,14 @@ 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; @@ -63,12 +78,13 @@ class LogScale extends Scale { 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); 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,16 +95,21 @@ 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; + if (start < Infinity) { + start = scaleHelper.absMathLog(start, this.base); + } + if (end > -Infinity) { + end = scaleHelper.absMathLog(end, this.base); + } + intervalScaleProto.setExtent.call(this, start, end); } @@ -96,10 +117,9 @@ class LogScale extends Scale { * @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]); + extent[0] = mathPow(this.base, extent[0]); + extent[1] = mathPow(this.base, extent[1]); // Fix #4158 const originalScale = this._originalScale; @@ -113,9 +133,17 @@ 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); + 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; + scaleProto.unionExtent.call(this, extent); } @@ -131,13 +159,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 +183,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; @@ -177,13 +210,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 { @@ -193,6 +232,26 @@ class LogScale extends Scale { 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; 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..a2403acbc1 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,7 @@ yAxis: [{ type: 'log', name: 'y', - scale: false + scale: false, }], series: [ { @@ -78,6 +77,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,8 +95,12 @@ 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', @@ -105,7 +113,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 +126,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; From c4e4c52d74adfb7cd58c773ab8aeffeab125dc3f Mon Sep 17 00:00:00 2001 From: Antti Palola Date: Fri, 28 Mar 2025 09:47:33 +0200 Subject: [PATCH 2/4] Fix log scale prototype strangeness Properly inherit IntervalScale and inline the only thing that really was required from Scale base class `unionExtent`. --- src/scale/Log.ts | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/src/scale/Log.ts b/src/scale/Log.ts index f835df5932..3da75a7fff 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -27,10 +27,6 @@ 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; @@ -39,6 +35,9 @@ const mathPow = Math.pow; 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. * @@ -46,7 +45,7 @@ const mathRound = Math.round; * 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 Scale { +class LogScale extends IntervalScale { static type = 'log'; readonly type = 'log'; @@ -65,12 +64,6 @@ class LogScale extends Scale { 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. */ @@ -80,7 +73,7 @@ class LogScale extends Scale { 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; @@ -110,14 +103,11 @@ class LogScale extends Scale { end = scaleHelper.absMathLog(end, this.base); } - intervalScaleProto.setExtent.call(this, start, end); + super.setExtent(start, end); } - /** - * @return {number} end - */ - getExtent() { - const extent = scaleProto.getExtent.call(this); + getExtent(): [number, number] { + const extent = super.getExtent(); extent[0] = mathPow(this.base, extent[0]); extent[1] = mathPow(this.base, extent[1]); @@ -144,7 +134,8 @@ class LogScale extends Scale { extent[0] = logStart; extent[1] = logEnd; - scaleProto.unionExtent.call(this, extent); + 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 { @@ -199,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; @@ -230,9 +221,6 @@ 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. @@ -254,10 +242,6 @@ class LogScale extends Scale { } } -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)); } From b6aeb019f4855b97d3f918dfeb3267e0e3be07b8 Mon Sep 17 00:00:00 2001 From: Antti Palola Date: Fri, 25 Apr 2025 13:47:45 +0300 Subject: [PATCH 3/4] Fix log scale tick generation Add limit to major tick generation to stop at base extents. If log base 10 range is from 4 to 200, previously ticks overflowed from top and bottom to 1-1000. Create minor ticks within decade linearly spaced as before but if the extent is not within even log steps, stop generating at extent end. When major ticks are more than one decade apart, generate a minor tick for each decade up to the split number times. --- src/scale/Log.ts | 65 ++++++++++++++++++++++++++++++++++++++++++++++ test/logScale.html | 16 ++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/scale/Log.ts b/src/scale/Log.ts index 3da75a7fff..5f24d0bf76 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -75,6 +75,15 @@ class LogScale extends IntervalScale { const ticks = super.getTicks(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 = mathPow(this.base, val); @@ -93,6 +102,62 @@ class LogScale extends IntervalScale { }, 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 { // Assume the start and end can be infinity // log(-Infinity) is NaN, so safe guard here diff --git a/test/logScale.html b/test/logScale.html index a2403acbc1..665e8a81a8 100644 --- a/test/logScale.html +++ b/test/logScale.html @@ -61,6 +61,18 @@ type: 'log', name: 'y', scale: false, + minorTick: { + show: true, + splitNumber: 10, + }, + splitLine: { + show: true, + }, + minorSplitLine: { + show: true, + }, + min: (value) => value.min, + max: (value) => value.max, }], series: [ { @@ -105,6 +117,10 @@ yAxis: { type: 'log', logBase: 2, + minorTick: { + show: true, + splitNumber: 4, + }, }, series: [ { From d26ccd353861a69aa8ffc403d59f294d3465b6d2 Mon Sep 17 00:00:00 2001 From: Antti Palola Date: Wed, 12 Nov 2025 15:12:25 +0200 Subject: [PATCH 4/4] Use Number.MIN_VALUE instead of EPSILON Epsilon at ~e-16 is clearly not the right choice. --- src/scale/helper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 6cc3ba7cde..8b8c878cc7 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -142,7 +142,7 @@ export function scale(val: number, extent: [number, number]): number { /** * 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) + * - Returning 0 for values very close to 0 (within Number.MIN_VALUE) * - Taking the absolute value of the input to handle negative numbers * * @param x - The number to calculate the logarithm of @@ -150,7 +150,7 @@ export function scale(val: number, extent: [number, number]): number { * @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) { + if (Math.abs(x) < Number.MIN_VALUE) { return 0; } return mathLog(Math.abs(x)) / mathLog(base);