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/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 asinhScaleForwardTick(v, a0) + : (v: number) => symlogScaleForwardTick(v, 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 *= effectiveBase; + } + + // 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/src/scale/Log.ts b/src/scale/Log.ts index 7fd3bd684f..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,10 +44,13 @@ 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 = { logBase: number | NullUndefined; + logMapping: 'none' | 'asinh' | 'symlog' | NullUndefined; + logLinearWidth: number | NullUndefined; breakOption: AxisBreakOption[] | NullUndefined; }; @@ -61,6 +68,34 @@ class LogScale extends Scale { 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 +120,20 @@ class LogScale extends Scale { this.parse = IntervalScale.parse; this.base = setting.logBase || 10; + this.logMapping = (setting.logMapping === 'asinh' || setting.logMapping === 'symlog') + ? setting.logMapping + : undefined; + 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[] = []; const lookupTo: number[] = []; const lookup = this._lookup = {from: lookupFrom, to: lookupTo}; @@ -93,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}); @@ -110,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(); @@ -265,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/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/log-mapping.html b/test/log-mapping.html new file mode 100644 index 0000000000..fe3d19ea93 --- /dev/null +++ b/test/log-mapping.html @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/ut/spec/scale/log.test.ts b/test/ut/spec/scale/log.test.ts new file mode 100755 index 0000000000..bce52a72be --- /dev/null +++ b/test/ut/spec/scale/log.test.ts @@ -0,0 +1,576 @@ + +/* +* 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'; +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|)`. +// `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)); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// logMappingCalcNiceTicks — tick candidate tests +// --------------------------------------------------------------------------- + +describe('LogScale — asinh tick candidates', () => { + function makeAndNice(min: number, max: number, logBase = 10, logLinearWidth = 1, splitNumber?: number) { + const s = new LogScale({ + logBase, + logMapping: 'asinh', + logLinearWidth, + breakOption: undefined, + }); + s.setExtent(min, max); + logMappingCalcNiceTicks(s, splitNumber); + 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 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]); + }); + + 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 makeSymlog(min: number, max: number, logBase = 10, logLinearWidth = 1, splitNumber?: number) { + const s = new LogScale({ + logBase, + logMapping: 'symlog', + logLinearWidth, + breakOption: undefined, + }); + s.setExtent(min, max); + logMappingCalcNiceTicks(s, splitNumber); + return s.getTicks().map(t => t.value); + } + + it('[-100, 100] → [-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(makeSymlog(-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); + }); +});