Skip to content

feat(scale): extend log axis to support negative and zero values#21646

Open
netscout-mthorn wants to merge 6 commits into
apache:masterfrom
netscout-mthorn:fix-17459
Open

feat(scale): extend log axis to support negative and zero values#21646
netscout-mthorn wants to merge 6 commits into
apache:masterfrom
netscout-mthorn:fix-17459

Conversation

@netscout-mthorn

@netscout-mthorn netscout-mthorn commented Jun 10, 2026

Copy link
Copy Markdown

Brief Information

This pull request is in the type of:

  • bug fixing
  • new feature
  • others

What does this PR do?

Adds a logMapping: 'asinh' | 'symlog' option to the log axis type, enabling logarithmic
axes that correctly handle zero and negative values via symmetric transform functions.

Fixed issues

Details

Before: What was the problem?

The log axis type requires all data values to be strictly positive. Zero and negative
values are filtered out by getFilter() and rejected by setExtent(). There is no
supported way to display mixed-sign data or data containing zero on a logarithmic axis.

Previous attempts (#16547, #20872) addressed this by auto-detecting negative data or
implementing a "negative log" transform, but reviewer 100pah identified these
approaches as fragile and mathematically unsound for the general case
(see the reviewer feedback in #20872).

After: How does it behave after the fixing?

A new axis option logMapping is added to the log axis type:

yAxis: {
    type: 'log',
    logBase: 10,          // unchanged, controls tick spacing
    logMapping: 'asinh'   // new, enables the asinh transform (or 'symlog')
}

When logMapping: 'asinh' is set:

  • The forward transform uses asinh(x / a0) * a0 instead of log_b(x), where a0
    is controlled by logLinearWidth (default 1, matching d3/matplotlib conventions).
    This function is defined for all real numbers, passes smoothly through zero, and
    behaves like log for large |x|.
  • Zero and negative data values are accepted and rendered correctly.
  • The axis is odd-symmetric: f(-x) = -f(x), so negative data produces a mirrored
    log-like axis.
  • Tick marks at 0, +-a0, +-b*a0, +-b^2*a0, ... (where a0 = logLinearWidth, b = logBase) appear naturally.
  • The axis splitNumber option (default 5) is respected. When the number of
    power-of-base tick candidates exceeds the requested count, the function
    raises the effective base to b^k (skipping intermediate powers) to keep
    tick density manageable. This is especially important for small bases like 2,
    where the raw candidate count can be very large over wide data ranges.

When logMapping: 'symlog' is set:

  • The forward transform uses sign(x) * ln(1 + |x|/C), where C is the linear
    threshold controlled by logLinearWidth (same option, default 1). This
    is the symlog transform familiar to matplotlib and d3 users.
  • Behaviour is otherwise identical to 'asinh': zero and negatives accepted,
    odd-symmetric axis, tick marks at 0, +-C, +-b*C, ...

When logMapping is absent or 'none', behaviour is identical to before. The
standard log transform is used and all existing constraints apply. There are no
breaking changes.

Screenshot 2026-06-10 at 9 43 53 AM

Document Info

One of the following should be checked.

  • This PR doesn't relate to document changes
  • The document should be updated later
  • The document changes have been made in apache/echarts-doc#xxx

Misc

Security Checking

  • This PR uses security-sensitive Web APIs.

ZRender Changes

  • This PR depends on ZRender changes (ecomfe/zrender#xxx).

Related test cases or examples to use the new APIs

  • test/log-mapping.html: visual test covering positive-only, mixed-sign, all-negative,
    logBase:2, and symlog comparison scenarios
  • test/ut/spec/scale/log.test.ts: Jest unit tests covering standard log
    (regression), logMapping: 'asinh', and logMapping: 'symlog' behaviour

Merging options

  • Please squash the commits into a single one when merging.

Other information

Design note: asinh and symlog

Both transforms are provided as equals. Neither is preferred over the other in this PR.
The right choice depends on the user's data and familiarity with each transform.

asinh (asinh(x / a0) * a0):

  • Infinitely differentiable everywhere including zero, with no curvature kink.
  • Named in the ECharts scaleMapper.ts source as an anticipated extension.
  • Math.asinh / Math.sinh are ES2015 built-ins with no polyfill needed.

symlog (sign(x) * ln(1 + |x|/C)):

  • Familiar to users of matplotlib's SymmetricalLogScale and d3's scaleSymlog.
  • The linear threshold C (logLinearWidth) has a more concrete geometric meaning
    than a0 for users who want explicit control over the transition point.
  • Math.log1p / Math.expm1 are ES2015 built-ins with no polyfill needed.

Both reviewer 100pah (in the review of #20872) and matplotlib name both transforms
as valid solutions to the negative/zero log problem. Both use the same logLinearWidth
parameter and the same tick generation strategy.

Design note: logMapping: 'none' as the explicit default

The type is 'none' | 'asinh' | 'symlog' rather than just 'asinh' | 'symlog' | undefined. This follows
the ECharts convention for mode-selection options: sampling uses 'none' for its
default, as does graph.layout.
Relationship to #20872

This PR supersedes #20872. It takes a different approach (opt-in logMapping option
with asinh and symlog transforms) rather than the negative log method implemented
there, in direct response to 100pah's review feedback on that PR.

Design note: splitNumber and adaptive tick stride
Standard log axes derive tick count from splitNumber via the interval scale
stub. For asinh/symlog axes, ticks are non-uniformly spaced in transformed space,
so logMappingCalcNiceTicks was initially generating all power-of-base candidates
without limit. This commit adds splitNumber support by computing a stride k
such that ticks are placed at 0, +-a0, +-a0*b^k, +-a0*b^(2k), ..., keeping the count
within bounds while preserving "nice" power-of-base values.

Files changed

  • src/coord/axisCommonTypes.ts
  • src/coord/axisDefault.ts
  • src/coord/axisHelper.ts
  • src/coord/axisNiceTicks.ts
  • src/coord/axisAlignTicks.ts
  • src/scale/Log.ts
  • src/scale/helper.ts
  • test/ut/spec/scale/log.test.ts (new)
  • test/log-mapping.html (new)

@echarts-bot echarts-bot Bot added the PR: awaiting doc Document changes is required for this PR. label Jun 10, 2026
@echarts-bot

echarts-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown

Thanks for your contribution!
The community will review it ASAP. In the meanwhile, please checkout the coding standard and Wiki about How to make a pull request.

Please DO NOT commit the files in dist, i18n, and ssr/client/dist folders in a non-release pull request. These folders are for release use only.

Document changes are required in this PR. Please also make a PR to apache/echarts-doc for document changes and update the issue id in the PR description. When the doc PR is merged, the maintainers will remove the PR: awaiting doc label.

@netscout-mthorn

Copy link
Copy Markdown
Author

Naming feedback requested: logMapping and logLinearWidth

I introduced a new option logLinearWidth that controls the width of the
quasi-linear region around zero for both asinh and symlog transforms. The
name is my best candidate, but I am open to alternatives and would welcome
maintainer input.

What it does

For logMapping: 'asinh', the transform is f(x) = asinh(x / a0) * a0.
For logMapping: 'symlog', the transform is f(x) = sign(x) * ln(1 + |x| / C).
In both cases, logLinearWidth supplies a0 / C: the scale of the region
near zero where the axis behaves approximately linearly. The default is 1,
matching d3 scaleSymlog and matplotlib AsinhScale.

Precedent in reference implementations

Library Transform Parameter name Default
matplotlib AsinhScale linear_width 1.0
matplotlib SymmetricalLogScale linthresh 2
d3-scale scaleSymlog constant 1 (exposed via .constant(c) chainable method)
ECharts (proposed) both logLinearWidth 1

matplotlib uses different parameter names for the two transforms (linear_width
vs linthresh), reflecting the different mathematical character of each:

  • linear_width (asinh): a continuous scale parameter with no hard boundary
  • linthresh (symlog): a hard threshold at which the piecewise formula transitions

I chose a single unified name logLinearWidth because:

  1. ECharts exposes a single logMapping option for both transforms, so a
    single companion parameter is consistent.
  2. logLinearWidth is descriptive for both: it is the width (scale) of the
    linear region in both cases.
  3. It follows the logBase naming convention already in ECharts.

Alternatives considered

Name Notes
logLinearWidth Chosen. Descriptive, consistent with logBase naming.
logLinthresh Borrows from matplotlib symlog naming; less accurate for asinh (no hard threshold).
logThreshold Accurate for symlog; misleading for asinh (no threshold).
logLinearScale Could be confused with "linear scale" as a transform type.
logLinearRange Reasonable; slightly less precise than "width".
logC Matches d3's internal naming but opaque without context.

Naming convention research

I surveyed existing ECharts mode-selection options before choosing logMapping
and logLinearWidth:

  • sampling: 'none' | 'average' | 'min' | 'max' | ... enables an alternative
    data-reduction method; 'none' is the explicit default.
  • graph.layout: 'none' | 'force' | 'circular' selects a graph layout algorithm.
  • smooth: boolean | number, boundaryGap: boolean | [string, string] are mixed
    union types with non-string sentinels.

logMapping follows the sampling/layout pattern directly: a string union with
'none' as the explicit default, each value naming an alternative mode. I considered
scaleMapping, mappingMethod, and mapping (all suggested by reviewer 100pah in
#20872) but prefer logMapping for consistency with the existing logBase option name.

Question for reviewers

Is logLinearWidth acceptable? Is there a naming convention in ECharts that
I should follow instead? I am also open to separate parameter names for
asinh and symlog if the maintainers prefer consistency with matplotlib
(e.g. logLinthresh for symlog and logLinearWidth for asinh), though a
single unified parameter seems simpler.

Also: is logMapping the preferred option name, or would one of the names
suggested in #20872 (scaleMapping, mappingMethod, mapping) be preferred?

@netscout-mthorn netscout-mthorn marked this pull request as ready for review June 10, 2026 13:59
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.
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.
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.
@netscout-mthorn

netscout-mthorn commented Jun 10, 2026

Copy link
Copy Markdown
Author

One issue I am seeing when using this feature in practice: tick crowding near zero when splitNumber thinning is active.

With logMapping: 'symlog', logBase: 10, logLinearWidth: 1, splitNumber: 5, and a data range of roughly -168M to 303M, the stride calculation produces effectiveBase = 10^4 = 10000. The candidate ticks that fall within the extent are:

-100M, -10K, -1, 0, 1, 10K, 100M

In transformed (pixel) space, the gaps between consecutive ticks are highly unequal:

Gap Fraction of axis height
-100M → -10K ~25%
-10K → -1 ~23%
-1 → 0 ~2%
0 → 1 ~2%
1 → 10K ~23%
10K → 100M ~25%

The -1, 0, and 1 labels are all crammed into roughly 4% of the axis, making them unreadable. The screenshot below shows this with hideOverlap disabled, then with it enabled.

Screenshot 2026-06-10 at 11 46 15 AM

The workaround I am currently using is axisLabel.hideOverlap: true, which suppresses the crowded labels. It works well enough in practice, which raises the question of whether this is actually a problem worth fixing at the tick generation level, or whether hideOverlap is simply the right tool for this situation and the tick placement is correct by design.

If it is worth addressing, two approaches come to mind:

1. Skip a0 when stride > 1. When thinning is active, +-a0 is negligibly close to zero relative to the rest of the axis. Starting the candidate sequence at +-a0 * effectiveBase instead of +-a0 would ensure the first tick past zero is always spaced consistently with its neighbors. The downside is losing the explicit linear/log boundary marker, though in practice most users are unlikely to notice it.

2. Post-process by minimum transformed gap. After generating candidates, compute the spacing between adjacent ticks in transformed space and drop any whose gap to its neighbor falls below some threshold fraction of the total axis span. More general than (1) and would also catch edge cases where the data extent itself produces crowding, but introduces an arbitrary threshold that would need to be a new option or a sensible constant.

Not sure whether either of these belongs in this PR or a follow-up. Happy to hear thoughts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PR: awaiting doc Document changes is required for this PR. PR: awaiting review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant