From 931b4d86ca4699518bff157ab434e3719ec64c76 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 08:55:13 -0400 Subject: [PATCH 1/6] feat(logAxis): declare logMapping and logLinearWidth options, wire to LogScale --- src/coord/axisCommonTypes.ts | 2 ++ src/coord/axisDefault.ts | 4 +++- src/coord/axisHelper.ts | 4 +++- src/scale/Log.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/coord/axisCommonTypes.ts b/src/coord/axisCommonTypes.ts index 058dea900e..f84982ec65 100644 --- a/src/coord/axisCommonTypes.ts +++ b/src/coord/axisCommonTypes.ts @@ -238,6 +238,8 @@ export interface LogAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'log'; axisLabel?: AxisLabelOption<'log'>; logBase?: number; + logMapping?: 'none' | 'asinh' | 'symlog'; + logLinearWidth?: number; } export interface TimeAxisBaseOption extends NumericAxisBaseOptionCommon { type?: 'time'; diff --git a/src/coord/axisDefault.ts b/src/coord/axisDefault.ts index ddb33ee33f..a62bac6685 100644 --- a/src/coord/axisDefault.ts +++ b/src/coord/axisDefault.ts @@ -227,7 +227,9 @@ const timeAxis: AxisBaseOption = zrUtil.merge({ }, valueAxis); const logAxis: AxisBaseOption = zrUtil.defaults({ - logBase: 10 + logBase: 10, + logMapping: 'none', + logLinearWidth: 1, }, valueAxis); diff --git a/src/coord/axisHelper.ts b/src/coord/axisHelper.ts index c61b975870..d517e9954b 100644 --- a/src/coord/axisHelper.ts +++ b/src/coord/axisHelper.ts @@ -85,7 +85,7 @@ export function createScaleByModel( // Expect `Pick`, // but be lenient for user's invalid input. {type?: string} - & Pick + & Pick & Pick > & Partial { readonly base: number; + /** + * The mapping method applied to this log scale. + * `'none'` is the standard log (positive values only). + * `'asinh'` and `'symlog'` extend support to zero and negative values — + * they also relax the positivity guards in `setExtent`, `sanitize`, and `getFilter`. + * + * NOTE: `NullUndefined` is treated as `'none'`. + * + * @see `linearWidth` + */ + readonly logMapping: 'none' | 'asinh' | 'symlog' | NullUndefined; + + /** + * The quasi-linear region half-width used by `'asinh'` and `'symlog'` mappings + * (`logLinearWidth` option, default `1`). + * Controls how wide the near-zero linear band is before the log-like curve kicks in. + * Ignored when `logMapping` is `'none'`. + * + * @see `logMapping` + */ + readonly linearWidth: number | NullUndefined; + + /** + * Cached ticks for `'asinh'`/`'symlog'` mappings, computed in `getTicks` and + * reused by `getMinorTicks`. `null` when `logMapping` is `'none'`. + */ + _mappedLogTicks: ScaleTick[] | null; + /** * `powStub` is used to save original values, i.e., values before logarithm * applied, such as raw extent and raw breaks. @@ -85,6 +115,12 @@ class LogScale extends Scale { this.parse = IntervalScale.parse; this.base = setting.logBase || 10; + this.logMapping = (setting.logMapping === 'asinh' || setting.logMapping === 'symlog') + ? setting.logMapping + : undefined; + this.linearWidth = setting.logLinearWidth || 1; + this._mappedLogTicks = null; + const lookupFrom: number[] = []; const lookupTo: number[] = []; const lookup = this._lookup = {from: lookupFrom, to: lookupTo}; From 720ccc2b80c2cc7f7fa1fa59e062675cd838ca32 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 08:55:42 -0400 Subject: [PATCH 2/6] feat(logAxis): add asinh and symlog forward/inverse transform helpers with tests --- src/scale/helper.ts | 95 +++++++++++++ test/ut/spec/scale/log.test.ts | 251 +++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100755 test/ut/spec/scale/log.test.ts diff --git a/src/scale/helper.ts b/src/scale/helper.ts index 396cef18b7..ba3ed37191 100644 --- a/src/scale/helper.ts +++ b/src/scale/helper.ts @@ -215,6 +215,101 @@ export function logScalePowTick( return mathPow(base, linearTickVal); } +/** + * Forward transform for `'asinh'` log mapping: `a0 * asinh(val / a0)`. + * Handles zero and negative values, unlike `logScaleLogTick`. + * Linear near zero (`|val| << a0`), logarithmic away from zero (`|val| >> a0`). + * + * NOTE: + * - If `val` is `0`, returns `0` exactly. + * - If `val` is negative, returns a negative result (odd-symmetric). + * - `a0` must be strictly positive. + * + * @see {asinhScaleInverseTick} + */ +export function asinhScaleForwardTick(val: number, a0: number): number { + return Math.asinh(val / a0) * a0; +} + +/** + * Inverse transform for `'asinh'` log mapping: `a0 * sinh(linearVal / a0)`. + * Converts a value from asinh-transformed space back to raw data space. + * + * The lookup table serves the same role as in `logScalePowTick`: floating-point + * drift means `sinh(asinh(x))` may not round-trip exactly to `x` for extent + * endpoints, which would cause tick labels like `99.99999999999999`. Lookups + * at known extent boundaries bypass the math and return the original raw value. + * + * [CAUTION]: + * Monotonicity may be broken on extent ends - callers must make sure it does not matter. + * + * @see {asinhScaleForwardTick} + */ +export function asinhScaleInverseTick( + linearVal: number, + a0: number, + opt: ValueTransformLookupOpt | NullUndefined +): number { + // Short-circuit at known extent boundaries to avoid floating-point drift. + // The lookup table is the same pattern used in logScalePowTick. + const lookup = opt && opt.lookup; + if (lookup) { + for (let i = 0; i < lookup.from.length; i++) { + if (linearVal === lookup.from[i]) { + return lookup.to[i]; + } + } + } + return Math.sinh(linearVal / a0) * a0; +} + +/** + * Forward transform for `'symlog'` log mapping: `sign(val) * ln(1 + |val| / C)`. + * Handles zero and negative values, unlike `logScaleLogTick`. + * Linear near zero (`|val| << C`), logarithmic away from zero (`|val| >> C`). + * + * NOTE: + * - If `val` is `0`, returns `0` exactly. + * - If `val` is negative, returns a negative result (odd-symmetric). + * - `C` must be strictly positive. + * + * @see {symlogScaleInverseTick} + */ +export function symlogScaleForwardTick(val: number, C: number): number { + return Math.sign(val) * Math.log1p(Math.abs(val) / C); +} + +/** + * Inverse transform for `'symlog'` log mapping: `sign(linearVal) * (e^|linearVal| - 1) * C`. + * Converts a value from symlog-transformed space back to raw data space. + * + * The lookup table serves the same role as in `logScalePowTick`: floating-point + * drift means `expm1(log1p(x))` may not round-trip exactly to `x` for extent + * endpoints. Lookups at known extent boundaries bypass the math and return the + * original raw value. + * + * [CAUTION]: + * Monotonicity may be broken on extent ends - callers must make sure it does not matter. + * + * @see {symlogScaleForwardTick} + */ +export function symlogScaleInverseTick( + linearVal: number, + C: number, + opt: ValueTransformLookupOpt | NullUndefined +): number { + const lookup = opt && opt.lookup; + if (lookup) { + for (let i = 0; i < lookup.from.length; i++) { + if (linearVal === lookup.from[i]) { + return lookup.to[i]; + } + } + } + return Math.sign(linearVal) * Math.expm1(Math.abs(linearVal)) * C; +} + + /** * For `IntervalScale`, convert `rawExtent` to: * - Be no non-finite number. diff --git a/test/ut/spec/scale/log.test.ts b/test/ut/spec/scale/log.test.ts new file mode 100755 index 0000000000..09a1c715b5 --- /dev/null +++ b/test/ut/spec/scale/log.test.ts @@ -0,0 +1,251 @@ + +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { + asinhScaleForwardTick, + asinhScaleInverseTick, + symlogScaleForwardTick, + symlogScaleInverseTick, +} from '@/src/scale/helper'; + +// Relative tolerance used by `approxEqual` in round-trip tests: +// `|a - b| <= tolerance * (1 + |b|)`. +// `Number.EPSILON` is too strict for chained transcendentals +// (`asinh/sinh`, `log1p/expm1`) due to floating-point drift. +// `1e-10` keeps tests stable across the covered range while still catching +// meaningful regressions. +const ROUND_TRIP_REL_TOLERANCE = 1e-10; + +function approxEqual(a: number, b: number, tolerance = ROUND_TRIP_REL_TOLERANCE): boolean { + return Math.abs(a - b) <= tolerance * (1 + Math.abs(b)); +} + +describe('asinhScaleForwardTick', () => { + const a0 = 1; + + it('maps 0 to 0', () => { + expect(asinhScaleForwardTick(0, a0)).toBe(0); + }); + + it('is odd-symmetric: f(-x) = -f(x)', () => { + for (const x of [0.1, 1, 10, 100, 1000]) { + expect(asinhScaleForwardTick(-x, a0)).toBeCloseTo( + -asinhScaleForwardTick(x, a0), 10 + ); + } + }); + + it('round-trips with asinhScaleInverseTick', () => { + for (const x of [-1000, -100, -10, -1, -0.1, 0, 0.1, 1, 10, 100, 1000]) { + const forward = asinhScaleForwardTick(x, a0); + const back = asinhScaleInverseTick(forward, a0, null); + expect(approxEqual(back, x)).toBe(true); + } + }); + + it('behaves linearly near zero (|x| << a0)', () => { + // For small x: asinh(x/a0)*a0 ≈ x + const small = 0.001; + expect(asinhScaleForwardTick(small, a0)).toBeCloseTo(small, 5); + }); + + it('uses a0 as scale parameter: f(a0, a0) = asinh(1) * a0', () => { + const expected = Math.asinh(1) * a0; + expect(asinhScaleForwardTick(a0, a0)).toBeCloseTo(expected, 10); + }); + + it('responds to a0 changes: larger a0 gives less compression for the same |x|', () => { + const x = 10; + expect(asinhScaleForwardTick(x, 10)).toBeGreaterThan(asinhScaleForwardTick(x, 1)); + }); + + it('returns NaN when a0 is zero (invalid)', () => { + expect(Number.isNaN(asinhScaleForwardTick(1, 0))).toBe(true); + }); + + it('propagates non-finite inputs', () => { + expect(Number.isNaN(asinhScaleForwardTick(NaN, a0))).toBe(true); + expect(asinhScaleForwardTick(Infinity, a0)).toBe(Infinity); + expect(asinhScaleForwardTick(-Infinity, a0)).toBe(-Infinity); + }); +}); + +describe('asinhScaleInverseTick', () => { + const a0 = 1; + + it('returns lookup value when available', () => { + const opt = { lookup: { from: [5], to: [42] } }; + expect(asinhScaleInverseTick(5, a0, opt)).toBe(42); + }); + + it('supports multiple lookup entries (extent start/end style)', () => { + const opt = { lookup: { from: [-2, 0, 2], to: [-100, 0, 100] } }; + expect(asinhScaleInverseTick(-2, a0, opt)).toBe(-100); + expect(asinhScaleInverseTick(0, a0, opt)).toBe(0); + expect(asinhScaleInverseTick(2, a0, opt)).toBe(100); + }); + + it('falls through lookup for non-matching value', () => { + const opt = { lookup: { from: [5], to: [42] } }; + const result = asinhScaleInverseTick(1, a0, opt); + expect(result).toBeCloseTo(Math.sinh(1) * a0, 10); + }); + + it('computes inverse when opt is null', () => { + const linearVal = 1; + const result = asinhScaleInverseTick(linearVal, a0, null); + expect(result).toBeCloseTo(Math.sinh(linearVal) * a0, 10); + }); + + it('round-trips very large finite values', () => { + const x = 1e300; + const forward = asinhScaleForwardTick(x, a0); + expect(Number.isFinite(forward)).toBe(true); + + const back = asinhScaleInverseTick(forward, a0, null); + expect(Number.isFinite(back)).toBe(true); + expect(approxEqual(back, x)).toBe(true); + }); + + it('returns NaN when a0 is zero (invalid)', () => { + expect(Number.isNaN(asinhScaleInverseTick(1, 0, null))).toBe(true); + }); + + it('propagates non-finite transformed values', () => { + expect(Number.isNaN(asinhScaleInverseTick(NaN, a0, null))).toBe(true); + expect(asinhScaleInverseTick(Infinity, a0, null)).toBe(Infinity); + expect(asinhScaleInverseTick(-Infinity, a0, null)).toBe(-Infinity); + }); +}); + +describe('symlogScaleForwardTick', () => { + const C = 1; + + it('maps 0 to 0', () => { + expect(symlogScaleForwardTick(0, C)).toBe(0); + }); + + it('is odd-symmetric: f(-x) = -f(x)', () => { + for (const x of [0.1, 1, 10, 100, 1000]) { + expect(symlogScaleForwardTick(-x, C)).toBeCloseTo( + -symlogScaleForwardTick(x, C), 10 + ); + } + }); + + it('round-trips with symlogScaleInverseTick', () => { + for (const x of [-1000, -100, -10, -1, -0.1, 0, 0.1, 1, 10, 100, 1000]) { + const forward = symlogScaleForwardTick(x, C); + const back = symlogScaleInverseTick(forward, C, null); + expect(approxEqual(back, x)).toBe(true); + } + }); + + it('behaves linearly near zero (|x| << C): f(x) ≈ x/C', () => { + const small = 0.001; + // log1p(small/C) ≈ small/C, times sign(x) => ≈ small + expect(symlogScaleForwardTick(small, C)).toBeCloseTo(small, 5); + }); + + it('f(C, C) = ln(2)', () => { + expect(symlogScaleForwardTick(C, C)).toBeCloseTo(Math.LN2, 10); + }); + + it('responds to C changes: larger C compresses the same |x| more', () => { + const x = 10; + expect(symlogScaleForwardTick(x, 10)).toBeLessThan(symlogScaleForwardTick(x, 1)); + }); + + it('returns non-finite when C is non-positive (invalid)', () => { + expect(symlogScaleForwardTick(1, 0)).toBe(Infinity); + expect(Number.isNaN(symlogScaleForwardTick(2, -1))).toBe(true); + }); + + it('propagates non-finite inputs', () => { + expect(Number.isNaN(symlogScaleForwardTick(NaN, C))).toBe(true); + expect(symlogScaleForwardTick(Infinity, C)).toBe(Infinity); + expect(symlogScaleForwardTick(-Infinity, C)).toBe(-Infinity); + }); +}); + +describe('symlogScaleInverseTick', () => { + const C = 1; + + it('returns lookup value when available', () => { + const opt = { lookup: { from: [2], to: [99] } }; + expect(symlogScaleInverseTick(2, C, opt)).toBe(99); + }); + + it('supports multiple lookup entries (extent start/end style)', () => { + const opt = { lookup: { from: [-2, 0, 2], to: [-100, 0, 100] } }; + expect(symlogScaleInverseTick(-2, C, opt)).toBe(-100); + expect(symlogScaleInverseTick(0, C, opt)).toBe(0); + expect(symlogScaleInverseTick(2, C, opt)).toBe(100); + }); + + it('falls through lookup for non-matching value', () => { + const opt = { lookup: { from: [2], to: [99] } }; + const result = symlogScaleInverseTick(1, C, opt); + expect(result).toBeCloseTo(Math.expm1(1) * C, 10); + }); + + it('computes inverse when opt is null', () => { + const linearVal = 1; + const result = symlogScaleInverseTick(linearVal, C, null); + expect(result).toBeCloseTo(Math.expm1(linearVal) * C, 10); + }); + + it('responds to C changes in inverse mapping', () => { + const linearVal = 1; + expect(symlogScaleInverseTick(linearVal, 10, null)) + .toBeGreaterThan(symlogScaleInverseTick(linearVal, 1, null)); + }); + + it('collapses to zero when C is zero (invalid)', () => { + expect(symlogScaleInverseTick(1, 0, null)).toBe(0); + }); + + it('propagates non-finite transformed values', () => { + expect(Number.isNaN(symlogScaleInverseTick(NaN, C, null))).toBe(true); + expect(symlogScaleInverseTick(Infinity, C, null)).toBe(Infinity); + expect(symlogScaleInverseTick(-Infinity, C, null)).toBe(-Infinity); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-transform: asinh vs symlog comparison +// --------------------------------------------------------------------------- + +describe('asinh vs symlog comparison', () => { + it('both map 0 to 0 exactly', () => { + expect(asinhScaleForwardTick(0, 1)).toBe(0); + expect(symlogScaleForwardTick(0, 1)).toBe(0); + }); + + it('both are strictly monotone (positive side)', () => { + const xs = [0.1, 1, 10, 100]; + for (let i = 1; i < xs.length; i++) { + expect(asinhScaleForwardTick(xs[i], 1)) + .toBeGreaterThan(asinhScaleForwardTick(xs[i - 1], 1)); + expect(symlogScaleForwardTick(xs[i], 1)) + .toBeGreaterThan(symlogScaleForwardTick(xs[i - 1], 1)); + } + }); +}); From 573c2af0df037a5b1c77619edba6e32f06c82c86 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 08:56:59 -0400 Subject: [PATCH 3/6] feat(logAxis): add asinh and symlog mapper methods to LogScale with scale-level tests --- src/scale/Log.ts | 226 ++++++++++++++++++++++++++++++++- test/ut/spec/scale/log.test.ts | 179 ++++++++++++++++++++++++++ 2 files changed, 402 insertions(+), 3 deletions(-) diff --git a/src/scale/Log.ts b/src/scale/Log.ts index a5f0da3ed0..97b3e35b16 100644 --- a/src/scale/Log.ts +++ b/src/scale/Log.ts @@ -29,6 +29,10 @@ import { IntervalScaleGetLabelOpt, logScaleLogTick, ValueTransformLookupOpt, + asinhScaleForwardTick, + asinhScaleInverseTick, + symlogScaleForwardTick, + symlogScaleInverseTick, } from './helper'; import { getBreaksUnsafe, getScaleBreakHelper, ParseAxisBreakOptionInwardTransformOut } from './break'; import { getMinorTicks } from './minorTicks'; @@ -40,6 +44,7 @@ import { import { map } from 'zrender/src/core/util'; import { isValidBoundsForExtent } from '../util/model'; import { isNullableNumberFinite } from '../util/number'; +import { warn } from '../util/log'; type LogScaleSetting = { @@ -118,7 +123,15 @@ class LogScale extends Scale { this.logMapping = (setting.logMapping === 'asinh' || setting.logMapping === 'symlog') ? setting.logMapping : undefined; - this.linearWidth = setting.logLinearWidth || 1; + const rawLw = setting.logLinearWidth || 1; + if (this.logMapping && (rawLw <= 0 || !isFinite(rawLw))) { + if (__DEV__) { + warn('logLinearWidth must be a finite positive number. Falling back to 1.'); + } + } + this.linearWidth = (this.logMapping && (rawLw <= 0 || !isFinite(rawLw))) + ? 1 + : rawLw; this._mappedLogTicks = null; const lookupFrom: number[] = []; @@ -129,14 +142,28 @@ class LogScale extends Scale { lookupTo[LOOKUP_IDX_EXTENT_START] = lookupTo[LOOKUP_IDX_EXTENT_END] = NaN; - decorateScaleMapper(this, LogScale.mapperMethods); + let mapperMethods = LogScale.mapperMethods; + if (this.logMapping === 'asinh') { + mapperMethods = LogScale.asinhMapperMethods; + } + else if (this.logMapping === 'symlog') { + mapperMethods = LogScale.symlogMapperMethods; + } + decorateScaleMapper(this, mapperMethods); const scaleBreakHelper = getScaleBreakHelper(); const breakOption = setting.breakOption; const out: ParseAxisBreakOptionInwardTransformOut = {lookup}; if (scaleBreakHelper) { + // TODO: axis breaks are not yet supported for mapped-log mode (asinh/symlog). + // The interaction between transformed break boundaries and the dual-stub + // architecture has not been analysed. Revisit in a follow-up PR. scaleBreakHelper.parseAxisBreakOptionInwardTransform( - breakOption, this, {noNegative: true}, LOOKUP_IDX_BREAK_START, out + this.logMapping ? undefined : breakOption, + this, + {noNegative: !this.logMapping}, + LOOKUP_IDX_BREAK_START, + out ); } this.powStub = new IntervalScale({breakParsed: out.original}); @@ -146,6 +173,13 @@ class LogScale extends Scale { } getTicks(opt?: ScaleGetTicksOpt): ScaleTick[] { + // Mapped-log ticks are pre-computed by logMappingCalcNiceTicks in + // axisNiceTicks.ts, because they are non-uniformly spaced in transformed + // space and cannot be generated by intervalStub.getTicks() (which assumes + // uniform spacing). _mappedLogTicks is set before getTicks is ever called. + if (this._mappedLogTicks) { + return this._mappedLogTicks; + } const base = this.base; const powStub = this.powStub; const scaleBreakHelper = getScaleBreakHelper(); @@ -301,6 +335,192 @@ class LogScale extends Scale { }; + static readonly asinhMapperMethods: DecoratedScaleMapperMethods = { + + needTransform() { + return true; + }, + + normalize(val) { + return this.intervalStub.normalize(asinhScaleForwardTick(val, this.linearWidth || 1)); + }, + + scale(val) { + return asinhScaleInverseTick(this.intervalStub.scale(val), this.linearWidth || 1, null); + }, + + transformIn(val, opt) { + val = asinhScaleForwardTick(val, this.linearWidth || 1); + return (opt && opt.depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformIn(val, opt); + }, + + transformOut(val, opt) { + const depth = opt ? opt.depth : null; + tmpTransformOutOpt1.depth = depth; + tmpTransformOutOpt2.lookup = this._lookup; + return asinhScaleInverseTick( + (depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformOut(val, tmpTransformOutOpt1), + this.linearWidth || 1, + tmpTransformOutOpt2 + ); + }, + + contain(val) { + return this.powStub.contain(val); + }, + + setExtent(start, end) { + this.setExtent2(SCALE_EXTENT_KIND_EFFECTIVE, start, end); + }, + + setExtent2(kind, start, end) { + if (!isValidBoundsForExtent(start, end)) { + return; + } + // No sign guard — asinh is defined for all real numbers. + const lw = this.linearWidth || 1; + let lookupTo = tmpNotUsedArr; + let lookupFrom = tmpNotUsedArr; + if (kind === SCALE_EXTENT_KIND_EFFECTIVE) { + const lookup = this._lookup; + lookupTo = lookup.to; + lookupFrom = lookup.from; + } + this.powStub.setExtent2( + kind, + (lookupTo[LOOKUP_IDX_EXTENT_START] = start), + (lookupTo[LOOKUP_IDX_EXTENT_END] = end) + ); + this.intervalStub.setExtent2( + kind, + (lookupFrom[LOOKUP_IDX_EXTENT_START] = asinhScaleForwardTick(start, lw)), + (lookupFrom[LOOKUP_IDX_EXTENT_END] = asinhScaleForwardTick(end, lw)) + ); + }, + + getFilter() { + // No positivity guard — accept all real numbers. + return {}; + }, + + sanitize(value) { + // No clamping — asinh accepts all real values including zero and negatives. + return value; + }, + + getDefaultStartValue() { + return 0; + }, + + getExtent() { + return this.powStub.getExtent(); + }, + + getExtentUnsafe(kind, depth) { + return depth === null + ? this.powStub.getExtentUnsafe(kind, null) + : this.intervalStub.getExtentUnsafe(kind, depth); + }, + + }; + + static readonly symlogMapperMethods: DecoratedScaleMapperMethods = { + + needTransform() { + return true; + }, + + normalize(val) { + return this.intervalStub.normalize(symlogScaleForwardTick(val, this.linearWidth || 1)); + }, + + scale(val) { + return symlogScaleInverseTick(this.intervalStub.scale(val), this.linearWidth || 1, null); + }, + + transformIn(val, opt) { + val = symlogScaleForwardTick(val, this.linearWidth || 1); + return (opt && opt.depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformIn(val, opt); + }, + + transformOut(val, opt) { + const depth = opt ? opt.depth : null; + tmpTransformOutOpt1.depth = depth; + tmpTransformOutOpt2.lookup = this._lookup; + return symlogScaleInverseTick( + (depth === SCALE_MAPPER_DEPTH_OUT_OF_BREAK) + ? val + : this.intervalStub.transformOut(val, tmpTransformOutOpt1), + this.linearWidth || 1, + tmpTransformOutOpt2 + ); + }, + + contain(val) { + return this.powStub.contain(val); + }, + + setExtent(start, end) { + this.setExtent2(SCALE_EXTENT_KIND_EFFECTIVE, start, end); + }, + + setExtent2(kind, start, end) { + if (!isValidBoundsForExtent(start, end)) { + return; + } + // No sign guard — symlog is defined for all real numbers. + const lw = this.linearWidth || 1; + let lookupTo = tmpNotUsedArr; + let lookupFrom = tmpNotUsedArr; + if (kind === SCALE_EXTENT_KIND_EFFECTIVE) { + const lookup = this._lookup; + lookupTo = lookup.to; + lookupFrom = lookup.from; + } + this.powStub.setExtent2( + kind, + (lookupTo[LOOKUP_IDX_EXTENT_START] = start), + (lookupTo[LOOKUP_IDX_EXTENT_END] = end) + ); + this.intervalStub.setExtent2( + kind, + (lookupFrom[LOOKUP_IDX_EXTENT_START] = symlogScaleForwardTick(start, lw)), + (lookupFrom[LOOKUP_IDX_EXTENT_END] = symlogScaleForwardTick(end, lw)) + ); + }, + + getFilter() { + // No positivity guard — accept all real numbers. + return {}; + }, + + sanitize(value) { + // No clamping — symlog accepts all real values including zero and negatives. + return value; + }, + + getDefaultStartValue() { + return 0; + }, + + getExtent() { + return this.powStub.getExtent(); + }, + + getExtentUnsafe(kind, depth) { + return depth === null + ? this.powStub.getExtentUnsafe(kind, null) + : this.intervalStub.getExtentUnsafe(kind, depth); + }, + + }; + } Scale.registerClass(LogScale); diff --git a/test/ut/spec/scale/log.test.ts b/test/ut/spec/scale/log.test.ts index 09a1c715b5..8ae0097632 100755 --- a/test/ut/spec/scale/log.test.ts +++ b/test/ut/spec/scale/log.test.ts @@ -24,6 +24,7 @@ import { symlogScaleForwardTick, symlogScaleInverseTick, } from '@/src/scale/helper'; +import LogScale from '@/src/scale/Log'; // Relative tolerance used by `approxEqual` in round-trip tests: // `|a - b| <= tolerance * (1 + |b|)`. @@ -249,3 +250,181 @@ describe('asinh vs symlog comparison', () => { } }); }); + +// --------------------------------------------------------------------------- +// LogScale — scale-level tests +// --------------------------------------------------------------------------- + +describe('LogScale — standard log (regression)', () => { + function makeLogScale(base = 10) { + return new LogScale({ + logBase: base, + logMapping: undefined, + logLinearWidth: undefined, + breakOption: undefined, + }); + } + + it('accepts positive extent', () => { + const s = makeLogScale(); + s.setExtent(1, 1000); + expect(s.getExtent()).toEqual([1, 1000]); + }); + + it('silently rejects zero in setExtent (no-op)', () => { + const s = makeLogScale(); + s.setExtent(1, 1000); + s.setExtent(0, 1000); + expect(s.getExtent()).toEqual([1, 1000]); + }); + + it('silently rejects negative in setExtent (no-op)', () => { + const s = makeLogScale(); + s.setExtent(1, 1000); + s.setExtent(-10, 1000); + expect(s.getExtent()).toEqual([1, 1000]); + }); + + it('getFilter returns positivity guard', () => { + const s = makeLogScale(); + s.setExtent(1, 1000); + const filter = s.getFilter!(); + expect(filter).toHaveProperty('g'); + expect((filter as any).g).toBeGreaterThanOrEqual(0); + }); + + it('getDefaultStartValue returns 1', () => { + const s = makeLogScale(); + expect(s.getDefaultStartValue!()).toBe(1); + }); +}); + +describe('LogScale — logMapping: asinh', () => { + function makeAsinhScale(logBase = 10, logLinearWidth = 1) { + return new LogScale({ + logBase, + logMapping: 'asinh', + logLinearWidth, + breakOption: undefined, + }); + } + + it('setExtent accepts zero (start=0)', () => { + const s = makeAsinhScale(); + expect(() => s.setExtent(0, 100)).not.toThrow(); + }); + + it('setExtent accepts negative extent', () => { + const s = makeAsinhScale(); + s.setExtent(-100, 100); + expect(s.getExtent()).toEqual([-100, 100]); + }); + + it('getFilter returns no positivity guard', () => { + const s = makeAsinhScale(); + s.setExtent(-100, 100); + expect(s.getFilter!()).not.toHaveProperty('g'); + }); + + it('sanitize does not clamp negative values', () => { + const s = makeAsinhScale(); + s.setExtent(-100, 100); + expect(s.sanitize!(-50, [-100, 100])).toBe(-50); + expect(s.sanitize!(0, [-100, 100])).toBe(0); + }); + + it('getDefaultStartValue returns 0', () => { + expect(makeAsinhScale().getDefaultStartValue!()).toBe(0); + }); + + it('normalize / scale round-trip', () => { + const s = makeAsinhScale(); + s.setExtent(-100, 100); + for (const x of [-100, -10, -1, 0, 1, 10, 100]) { + const norm = s.normalize(x); + const back = s.scale(norm); + expect(back).toBeCloseTo(x, 5); + } + }); + + it('uses pre-computed mapped ticks when `_mappedLogTicks` is set', () => { + const s = makeAsinhScale(); + const mappedTicks = [{ value: -1 }, { value: 0 }, { value: 1 }]; + s._mappedLogTicks = mappedTicks; + expect(s.getTicks()).toBe(mappedTicks); + }); + + it('invalid logLinearWidth values fall back to 1', () => { + const expected = makeAsinhScale(10, 1); + expected.setExtent(-100, 100); + + for (const invalidLw of [0, -1, Infinity, NaN]) { + const s = makeAsinhScale(10, invalidLw); + expect(s.linearWidth).toBe(1); + + s.setExtent(-100, 100); + for (const x of [-100, -10, -1, 0, 1, 10, 100]) { + expect(s.normalize(x)).toBeCloseTo(expected.normalize(x), 10); + expect(s.scale(s.normalize(x))).toBeCloseTo(expected.scale(expected.normalize(x)), 10); + } + } + }); +}); + +describe('LogScale — logMapping: symlog', () => { + function makeSymlogScale(logBase = 10, logLinearWidth = 1) { + return new LogScale({ + logBase, + logMapping: 'symlog', + logLinearWidth, + breakOption: undefined, + }); + } + + it('setExtent accepts zero and negative', () => { + const s = makeSymlogScale(); + s.setExtent(-100, 100); + expect(s.getExtent()).toEqual([-100, 100]); + }); + + it('getFilter returns no positivity guard', () => { + const s = makeSymlogScale(); + s.setExtent(-100, 100); + expect(s.getFilter!()).not.toHaveProperty('g'); + }); + + it('sanitize does not clamp negative values', () => { + const s = makeSymlogScale(); + s.setExtent(-100, 100); + expect(s.sanitize!(-50, [-100, 100])).toBe(-50); + }); + + it('getDefaultStartValue returns 0', () => { + expect(makeSymlogScale().getDefaultStartValue!()).toBe(0); + }); + + it('normalize / scale round-trip', () => { + const s = makeSymlogScale(); + s.setExtent(-100, 100); + for (const x of [-100, -10, -1, 0, 1, 10, 100]) { + const back = s.scale(s.normalize(x)); + expect(back).toBeCloseTo(x, 5); + } + }); + + it('invalid logLinearWidth values fall back to 1', () => { + const expected = makeSymlogScale(10, 1); + expected.setExtent(-100, 100); + + for (const invalidLw of [0, -1, Infinity, NaN]) { + const s = makeSymlogScale(10, invalidLw); + expect(s.linearWidth).toBe(1); + + s.setExtent(-100, 100); + for (const x of [-100, -10, -1, 0, 1, 10, 100]) { + expect(s.normalize(x)).toBeCloseTo(expected.normalize(x), 10); + expect(s.scale(s.normalize(x))).toBeCloseTo(expected.scale(expected.normalize(x)), 10); + } + } + }); +}); From 6973783203ddfd6148e6f149da00d15979998e52 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 08:59:58 -0400 Subject: [PATCH 4/6] feat(logAxis): add raw-space tick strategy for asinh/symlog axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logMappingCalcNiceTicks to axisNiceTicks.ts, which generates round tick candidates (0, ±a0, ±b*a0, ...) in raw-value space and stores them on LogScale._mappedLogTicks. The standard IntervalScale-based tick path cannot be used because asinh/symlog candidates are non-uniformly spaced in transformed space. Wire the new function into calcNiceForIntervalOrLogScale via an early branch before the existing logScaleCalcNiceTicks call. Add an alignTicks guard in axisAlignTicks.ts so that mapped-log scales fall back to independent nice-tick calculation rather than the loopIncreaseInterval path, which assumes integer spacing in log space. Add unit tests covering tick candidates, normalize/scale round-trips, and the alignTicks guard for both asinh and symlog. --- src/coord/axisAlignTicks.ts | 9 +++ src/coord/axisNiceTicks.ts | 75 +++++++++++++++++- test/ut/spec/scale/log.test.ts | 137 +++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/coord/axisAlignTicks.ts b/src/coord/axisAlignTicks.ts index 3d172521c4..7d616aab8f 100644 --- a/src/coord/axisAlignTicks.ts +++ b/src/coord/axisAlignTicks.ts @@ -63,6 +63,15 @@ export function scaleCalcAlign( // (2) `SCALE_EXTENT_KIND_MAPPING` is not considered yet. const isTargetLogScale = isLogScale(targetScale); + + // alignTicks is not supported for mapped log scales (asinh/symlog). + // loopIncreaseInterval multiplies by targetLogScaleBase, which assumes + // integer spacing in log space. That assumption does not hold for these + // transforms, so each axis calculates its own nice ticks independently. + if (isTargetLogScale && (targetScale as LogScale).logMapping) { + return; + } + const alignToScaleLinear = isLogScale(alignToScale) ? alignToScale.intervalStub : alignToScale; const targetIntervalStub = isTargetLogScale ? targetScale.intervalStub : targetScale; diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts index 563fbce7a6..0bac806f68 100644 --- a/src/coord/axisNiceTicks.ts +++ b/src/coord/axisNiceTicks.ts @@ -17,11 +17,13 @@ * under the License. */ -import { assert, noop } from 'zrender/src/core/util'; +import { assert, map, noop } from 'zrender/src/core/util'; import { ensureValidSplitNumber, getIntervalPrecision, intervalScaleEnsureValidExtent, isIntervalScale, isLogScale, isTimeScale, + asinhScaleForwardTick, + symlogScaleForwardTick, } from '../scale/helper'; import IntervalScale, { IntervalScaleConfig } from '../scale/Interval'; import { mathCeil, mathFloor, mathMax, nice, quantity, round } from '../util/number'; @@ -55,6 +57,12 @@ function calcNiceForIntervalOrLogScale( const isTargetLogScale = isLogScale(scale); const intervalStub = isTargetLogScale ? scale.intervalStub : scale; + // For mapped-log axes (asinh/symlog), use the raw-space tick strategy. + if (isTargetLogScale && (scale as LogScale).logMapping) { + logMappingCalcNiceTicks(scale as LogScale); + return; + } + const fixMinMax = opt.fixMinMax || []; const oldOutermostExtent = isTargetLogScale ? scale.getExtent() : null; const oldIntervalExtent = intervalStub.getExtent(); @@ -197,6 +205,71 @@ function logScaleCalcNiceTicks( // ------ END: LogScale Nice ------ +// ------ START: logMapping Nice ------ + +/** + * Tick strategy for `logMapping: 'asinh' | 'symlog'` axes. + * + * Standard log ticking assumes integer intervals in transformed space (integer + * log_b values = powers of b in raw space). For asinh/symlog the candidates + * `0, ±a0, ±b*a0, ...` are non-uniformly spaced in transformed space, so a + * single integer interval does not work. + * + * Strategy: choose candidates in raw-value space, store as `_mappedLogTicks`, + * then set `intervalStub` extent to the transformed range so that + * `normalize`/`scale` pixel mapping remains correct. + */ +export function logMappingCalcNiceTicks(scale: LogScale): void { + const base = scale.base; + const a0 = scale.linearWidth || 1; + const [rawMin, rawMax] = scale.getExtent(); + + const forward = scale.logMapping === 'asinh' + ? (v: number) => asinhScaleForwardTick(v, a0) + : (v: number) => symlogScaleForwardTick(v, a0); + + // Candidates: 0, ±a0, ±b*a0, ±b^2*a0, ... + const absMax = Math.max(Math.abs(rawMin), Math.abs(rawMax)); + const candidates: number[] = [0]; + let v = a0; + while (v <= absMax * 1.0001) { + candidates.push(v, -v); + v *= base; + } + + // Filter to data extent and sort ascending. + // Candidates are distinct by construction (0 once, then ±v pairs with v > 0), + // so no deduplication is needed. + const filtered: number[] = []; + candidates.sort((a, b) => a - b); + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + if (c >= rawMin && c <= rawMax) { + filtered.push(c); + } + } + const ticks = map(filtered, value => ({ value })); + + // Degenerate extent: ensure at least one tick. + if (ticks.length === 0) { + ticks.push({ value: (rawMin + rawMax) / 2 }); + } + + scale._mappedLogTicks = ticks; + + // Align intervalStub extent to the transformed data range so that + // `normalize`/`scale` (pixel mapping) covers the full data extent. + // Use the data min/max rather than the tick min/max, because the + // outermost ticks may fall inside the data range when thinning skips + // intermediate powers. + const intervalStub = scale.intervalStub; + intervalStub.setExtent(forward(rawMin), forward(rawMax)); + intervalStub.setConfig({ interval: 1 }); +} + +// ------ END: logMapping Nice ------ + + // ------ START: scaleCalcNice Entry ------ export type ScaleCalcNiceMethod = ( diff --git a/test/ut/spec/scale/log.test.ts b/test/ut/spec/scale/log.test.ts index 8ae0097632..8eae287517 100755 --- a/test/ut/spec/scale/log.test.ts +++ b/test/ut/spec/scale/log.test.ts @@ -25,6 +25,7 @@ import { symlogScaleInverseTick, } from '@/src/scale/helper'; import LogScale from '@/src/scale/Log'; +import { logMappingCalcNiceTicks } from '@/src/coord/axisNiceTicks'; // Relative tolerance used by `approxEqual` in round-trip tests: // `|a - b| <= tolerance * (1 + |b|)`. @@ -428,3 +429,139 @@ describe('LogScale — logMapping: symlog', () => { } }); }); + +// --------------------------------------------------------------------------- +// logMappingCalcNiceTicks — tick candidate tests +// --------------------------------------------------------------------------- + +describe('LogScale — asinh tick candidates', () => { + function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1) { + const s = new LogScale({ + logBase, + logMapping: 'asinh', + logLinearWidth, + breakOption: undefined, + }); + s.setExtent(min, max); + logMappingCalcNiceTicks(s); + return s.getTicks().map(t => t.value); + } + + it('[-100, 100] base 10, a0=1 → [-100,-10,-1,0,1,10,100]', () => { + expect(makeAndNice(-100, 100)).toEqual([-100, -10, -1, 0, 1, 10, 100]); + }); + + it('[0, 100] base 10, a0=1 → [0,1,10,100]', () => { + expect(makeAndNice(0, 100)).toEqual([0, 1, 10, 100]); + }); + + it('[-1000, 1000] includes 0, ±1000', () => { + const ticks = makeAndNice(-1000, 1000); + expect(ticks).toContain(0); + expect(ticks).toContain(1000); + expect(ticks).toContain(-1000); + }); + + it('[1, 1000] positive-only → [1,10,100,1000]', () => { + expect(makeAndNice(1, 1000)).toEqual([1, 10, 100, 1000]); + }); + + it('tick values are raw (not asinh-transformed)', () => { + for (const v of makeAndNice(-100, 100)) { + expect(Number.isInteger(v)).toBe(true); + } + }); + + it('normalize / scale round-trip after logMappingCalcNiceTicks', () => { + const s = new LogScale({ + logBase: 10, logMapping: 'asinh', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(-100, 100); + logMappingCalcNiceTicks(s); + for (const v of [-100, -10, -1, 0, 1, 10, 100]) { + expect(s.scale(s.normalize(v))).toBeCloseTo(v, 5); + } + }); + + it('intervalStub extent equals transformed data min/max', () => { + const s = new LogScale({ + logBase: 10, logMapping: 'asinh', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(-100, 100); + logMappingCalcNiceTicks(s); + const [lo, hi] = s.intervalStub.getExtent(); + expect(lo).toBeCloseTo(asinhScaleForwardTick(-100, 1), 10); + expect(hi).toBeCloseTo(asinhScaleForwardTick(100, 1), 10); + }); + + it('extent entirely between candidates falls back to midpoint tick', () => { + // base=10, a0=1: candidates are 0, ±1, ±10 — none fall inside [2, 9] + const s = new LogScale({ + logBase: 10, logMapping: 'asinh', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(2, 9); + logMappingCalcNiceTicks(s); + expect(s.getTicks()).toEqual([{ value: 5.5 }]); + }); + + it('[1, 8] base=2, a0=1 → [1,2,4,8]', () => { + expect(makeAndNice(1, 8, 2, 1)).toEqual([1, 2, 4, 8]); + }); + + it('[-100, 100] base=10, a0=10 → [-100,-10,0,10,100]', () => { + expect(makeAndNice(-100, 100, 10, 10)).toEqual([-100, -10, 0, 10, 100]); + }); +}); + +describe('LogScale — symlog tick candidates', () => { + function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1) { // eslint-disable-line @typescript-eslint/no-shadow + const s = new LogScale({ + logBase, + logMapping: 'symlog', + logLinearWidth, + breakOption: undefined, + }); + s.setExtent(min, max); + logMappingCalcNiceTicks(s); + return s.getTicks().map(t => t.value); + } + + it('[-100, 100] → [-100,-10,-1,0,1,10,100]', () => { + expect(makeAndNice(-100, 100)).toEqual([-100, -10, -1, 0, 1, 10, 100]); + }); + + it('tick list contains 0', () => { + expect(makeAndNice(-100, 100)).toContain(0); + }); + + it('getFilter returns no positivity guard after ticks are set', () => { + const s = new LogScale({ + logBase: 10, logMapping: 'symlog', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(-100, 100); + logMappingCalcNiceTicks(s); + expect(s.getFilter!()).not.toHaveProperty('g'); + }); + + it('intervalStub extent equals transformed data min/max', () => { + const s = new LogScale({ + logBase: 10, logMapping: 'symlog', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(-100, 100); + logMappingCalcNiceTicks(s); + const [lo, hi] = s.intervalStub.getExtent(); + expect(lo).toBeCloseTo(symlogScaleForwardTick(-100, 1), 10); + expect(hi).toBeCloseTo(symlogScaleForwardTick(100, 1), 10); + }); +}); + +describe('LogScale — alignTicks guard', () => { + it('logMappingCalcNiceTicks does not throw and produces ticks', () => { + const s = new LogScale({ + logBase: 10, logMapping: 'asinh', logLinearWidth: 1, breakOption: undefined + }); + s.setExtent(-100, 100); + expect(() => logMappingCalcNiceTicks(s)).not.toThrow(); + expect(s.getTicks().length).toBeGreaterThan(0); + }); +}); From ce63b0addfc157a812b85fd43bec06e8e849e689 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 09:00:11 -0400 Subject: [PATCH 5/6] test(logAxis): add visual test HTML for asinh/symlog log mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test/log-mapping.html with 8 chart scenarios covering the new logMapping: 'asinh' and logMapping: 'symlog' axis options: 1. Standard log baseline (regression guard) 2. asinh, positive-only (visual comparison with standard log) 3. asinh, mixed [-1000, 1000] — primary use case, zero-crossing data 4. asinh, all-negative extent 5. asinh with logBase: 2 (ticks at powers of 2) 6. asinh scatter with P&L-style mixed-sign data 7. symlog, mixed [-1000, 1000] (side-by-side with scenario 3) 8. symlog with explicit logLinearWidth: 1 (linear segment visible) Each chart title lists what to verify, serving as an inline visual checklist for reviewers. --- test/log-mapping.html | 255 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 test/log-mapping.html diff --git a/test/log-mapping.html b/test/log-mapping.html new file mode 100644 index 0000000000..cfc6da259d --- /dev/null +++ b/test/log-mapping.html @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 930c8fa0efb179b2a71301d7e287e200579ca7e5 Mon Sep 17 00:00:00 2001 From: Mike Thorn Date: Wed, 10 Jun 2026 10:38:23 -0400 Subject: [PATCH 6/6] feat(logAxis): respect splitNumber for asinh/symlog tick density logMappingCalcNiceTicks now accepts an optional splitNumber (default 5). When the number of power-of-base candidates exceeds the requested tick count, the function computes a stride k and uses base^k as the effective step between ticks. This keeps tick density manageable for small bases like 2, where the raw candidate count can be very large over wide data ranges. The splitNumber is threaded from the axis model through calcNiceForIntervalOrLogScale, consistent with how interval and standard log scales already work. --- src/coord/axisNiceTicks.ts | 27 +++++++++++++++++++++++---- test/log-mapping.html | 2 +- test/ut/spec/scale/log.test.ts | 25 +++++++++++++++++-------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/coord/axisNiceTicks.ts b/src/coord/axisNiceTicks.ts index 0bac806f68..1b8ae389ad 100644 --- a/src/coord/axisNiceTicks.ts +++ b/src/coord/axisNiceTicks.ts @@ -59,7 +59,7 @@ function calcNiceForIntervalOrLogScale( // For mapped-log axes (asinh/symlog), use the raw-space tick strategy. if (isTargetLogScale && (scale as LogScale).logMapping) { - logMappingCalcNiceTicks(scale as LogScale); + logMappingCalcNiceTicks(scale as LogScale, opt.splitNumber); return; } @@ -219,22 +219,41 @@ function logScaleCalcNiceTicks( * then set `intervalStub` extent to the transformed range so that * `normalize`/`scale` pixel mapping remains correct. */ -export function logMappingCalcNiceTicks(scale: LogScale): void { +export function logMappingCalcNiceTicks( + scale: LogScale, splitNumber?: number | NullUndefined +): void { const base = scale.base; const a0 = scale.linearWidth || 1; const [rawMin, rawMax] = scale.getExtent(); + const maxTicks = ensureValidSplitNumber(splitNumber, 5) + 1; const forward = scale.logMapping === 'asinh' ? (v: number) => asinhScaleForwardTick(v, a0) : (v: number) => symlogScaleForwardTick(v, a0); - // Candidates: 0, ±a0, ±b*a0, ±b^2*a0, ... + // Count how many powers of `base` span the extent so we can decide + // whether to step by base^1, base^2, … to stay within `splitNumber`. const absMax = Math.max(Math.abs(rawMin), Math.abs(rawMax)); + const totalSteps = absMax > a0 ? Math.ceil(Math.log(absMax / a0) / Math.log(base)) : 0; + // Account for both positive and negative sides plus zero. + const hasNeg = rawMin < 0; + const hasPos = rawMax > 0; + const sidesMultiplier = (hasNeg && hasPos) ? 2 : 1; + const estimatedTicks = totalSteps * sidesMultiplier + 1; // +1 for zero + + // Raise the effective base so tick count stays within splitNumber. + let stride = 1; + if (estimatedTicks > maxTicks && totalSteps > 0) { + stride = Math.ceil(totalSteps * sidesMultiplier / (maxTicks - 1)); + } + const effectiveBase = Math.pow(base, stride); + + // Candidates: 0, ±a0, ±a0·effectiveBase, ±a0·effectiveBase², ... const candidates: number[] = [0]; let v = a0; while (v <= absMax * 1.0001) { candidates.push(v, -v); - v *= base; + v *= effectiveBase; } // Filter to data extent and sort ascending. diff --git a/test/log-mapping.html b/test/log-mapping.html index cfc6da259d..fe3d19ea93 100644 --- a/test/log-mapping.html +++ b/test/log-mapping.html @@ -156,7 +156,7 @@ type: 'category', data: ['-64', '-32', '-16', '-8', '-4', '-2', '-1', '0', '1', '2', '4', '8', '16', '32', '64'] }, - yAxis: { type: 'log', logBase: 2, logMapping: 'asinh' }, + yAxis: { type: 'log', logBase: 2, logMapping: 'asinh', splitNumber: 5 }, series: [{ type: 'line', data: [-64, -32, -16, -8, -4, -2, -1, 0, 1, 2, 4, 8, 16, 32, 64] diff --git a/test/ut/spec/scale/log.test.ts b/test/ut/spec/scale/log.test.ts index 8eae287517..bce52a72be 100755 --- a/test/ut/spec/scale/log.test.ts +++ b/test/ut/spec/scale/log.test.ts @@ -435,7 +435,7 @@ describe('LogScale — logMapping: symlog', () => { // --------------------------------------------------------------------------- describe('LogScale — asinh tick candidates', () => { - function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1) { + function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1, splitNumber?: number) { const s = new LogScale({ logBase, logMapping: 'asinh', @@ -443,7 +443,7 @@ describe('LogScale — asinh tick candidates', () => { breakOption: undefined, }); s.setExtent(min, max); - logMappingCalcNiceTicks(s); + logMappingCalcNiceTicks(s, splitNumber); return s.getTicks().map(t => t.value); } @@ -455,13 +455,22 @@ describe('LogScale — asinh tick candidates', () => { expect(makeAndNice(0, 100)).toEqual([0, 1, 10, 100]); }); - it('[-1000, 1000] includes 0, ±1000', () => { - const ticks = makeAndNice(-1000, 1000); + it('[-1000, 1000] includes 0, ±1000 when splitNumber is large enough', () => { + const ticks = makeAndNice(-1000, 1000, 10, 1, 10); expect(ticks).toContain(0); expect(ticks).toContain(1000); expect(ticks).toContain(-1000); }); + it('splitNumber thins ticks by raising the effective base', () => { + // base=2, range [0, 1024]: without thinning would give + // 0,1,2,4,8,16,32,64,128,256,512,1024 (12 ticks). + // splitNumber=4 should produce at most 5 ticks. + const ticks = makeAndNice(0, 1024, 2, 1, 4); + expect(ticks.length).toBeLessThanOrEqual(5); + expect(ticks).toContain(0); + }); + it('[1, 1000] positive-only → [1,10,100,1000]', () => { expect(makeAndNice(1, 1000)).toEqual([1, 10, 100, 1000]); }); @@ -514,7 +523,7 @@ describe('LogScale — asinh tick candidates', () => { }); describe('LogScale — symlog tick candidates', () => { - function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1) { // eslint-disable-line @typescript-eslint/no-shadow + function makeSymlog(min: number, max: number, logBase = 10, logLinearWidth = 1, splitNumber?: number) { const s = new LogScale({ logBase, logMapping: 'symlog', @@ -522,16 +531,16 @@ describe('LogScale — symlog tick candidates', () => { breakOption: undefined, }); s.setExtent(min, max); - logMappingCalcNiceTicks(s); + logMappingCalcNiceTicks(s, splitNumber); return s.getTicks().map(t => t.value); } it('[-100, 100] → [-100,-10,-1,0,1,10,100]', () => { - expect(makeAndNice(-100, 100)).toEqual([-100, -10, -1, 0, 1, 10, 100]); + expect(makeSymlog(-100, 100)).toEqual([-100, -10, -1, 0, 1, 10, 100]); }); it('tick list contains 0', () => { - expect(makeAndNice(-100, 100)).toContain(0); + expect(makeSymlog(-100, 100)).toContain(0); }); it('getFilter returns no positivity guard after ticks are set', () => {