-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Improve axis tick increment #4070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Just to be clear, this bug exists in our outdated version of d3-format. On newer versions, this bug is gone: https://runkit.com/embed/ooxgp8bijh5w |
Thanks for this PR @archmoj ! It already improves things quite a bit. Do you think it would be a lot of work to switch to the newer |
Thanks for the note. But that one still returns extra zeros! |
Good question. However; I think we should try that on a separate branch/PR. |
You're right! Sorry for the misinformation.
Sounds good. Well, if upgrading |
Ok, thank you @archmoj! I think I'll let @etpinard or @alexcjohnson review this one. |
src/plots/cartesian/axes.js
Outdated
// Note 1: | ||
// 0.3 != 0.1 + 0.2 but 0.3 == ((10 * 0.1) + (10 * 0.2)) / 10 | ||
// Attempt to use integer steps to increment | ||
var magic = 1 / dtick; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you have a reference explaining that "magic"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately no. But the trick appear to be working.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, do we really need this block for tickformat
values other than 'p'
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is also required e.g. with s
.
Please find the new mock added in 740574b which currently renders as:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes it is also required e.g. with
s
.
Ok so could then have
if(isNumeric(dtick) && (tickformat.indexOf('s') !== -1 || tickformat.indexOf('p') !== -1)) {
// your new logic
}
that would make this patch a lot less stressful to merge.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@antoinerg @etpinard the logic is now moved to Lib
in 916bf80 and I improved test coverage in 86b135d.
src/plots/cartesian/axes.js
Outdated
var lenX0 = ('' + x).length; | ||
var lenX1 = ('' + newX).length; | ||
|
||
if(lenX1 > lenX0 + lenDt) { // this is likey a rounding error! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@archmoj Just a thought here: if you know the number of decimals in dtick
, you also know that the number of decimals in the final number should never exceed it. So if newX.split('.')[1].length > dtickDecimalLength
then you can round with a precision of dtickDecimalLength
. I have the feeling it could fix the 55%
but I may be wrong.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@antoinerg thanks for sharing this.
That one not only depends on the dtick
but also on the previous tick value. For example if we start from 1000.0001 and dtick
=1, the next tick should be at 1001.001
.
Also I should clarify that the case of 55%
is a d3
bug which is separated from what is going to be fixed in this PR i.e. providing better raw tick values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That one not only depends on the
dtick
but also on the previous tick value. For example if we start from 1000.0001 anddtick
=1, the next tick should be at1001.001
.
Wouldn't taking the maximal number of decimals in either dtick
or the start
number work?
force magic number to be positive to avoid getting -0
|
||
module.exports = function incrementNumeric(x, delta) { | ||
if(!delta) return x; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One may simply return x + delta
here to find out various failing tests.
newX = +parseFloat(newX).toPrecision(12); | ||
} | ||
|
||
return newX; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding those tests @archmoj - I understand (a little bit) more what you're attempting.
That said, I feel like we should be extending numFormat
:
plotly.js/src/plots/cartesian/axes.js
Lines 1311 to 1408 in 96fe262
function numFormat(v, ax, fmtoverride, hover) { | |
var isNeg = v < 0; | |
// max number of digits past decimal point to show | |
var tickRound = ax._tickround; | |
var exponentFormat = fmtoverride || ax.exponentformat || 'B'; | |
var exponent = ax._tickexponent; | |
var tickformat = axes.getTickFormat(ax); | |
var separatethousands = ax.separatethousands; | |
// special case for hover: set exponent just for this value, and | |
// add a couple more digits of precision over tick labels | |
if(hover) { | |
// make a dummy axis obj to get the auto rounding and exponent | |
var ah = { | |
exponentformat: exponentFormat, | |
dtick: ax.showexponent === 'none' ? ax.dtick : | |
(isNumeric(v) ? Math.abs(v) || 1 : 1), | |
// if not showing any exponents, don't change the exponent | |
// from what we calculate | |
range: ax.showexponent === 'none' ? ax.range.map(ax.r2d) : [0, v || 1] | |
}; | |
autoTickRound(ah); | |
tickRound = (Number(ah._tickround) || 0) + 4; | |
exponent = ah._tickexponent; | |
if(ax.hoverformat) tickformat = ax.hoverformat; | |
} | |
if(tickformat) return ax._numFormat(tickformat)(v).replace(/-/g, MINUS_SIGN); | |
// 'epsilon' - rounding increment | |
var e = Math.pow(10, -tickRound) / 2; | |
// exponentFormat codes: | |
// 'e' (1.2e+6, default) | |
// 'E' (1.2E+6) | |
// 'SI' (1.2M) | |
// 'B' (same as SI except 10^9=B not G) | |
// 'none' (1200000) | |
// 'power' (1.2x10^6) | |
// 'hide' (1.2, use 3rd argument=='hide' to eg | |
// only show exponent on last tick) | |
if(exponentFormat === 'none') exponent = 0; | |
// take the sign out, put it back manually at the end | |
// - makes cases easier | |
v = Math.abs(v); | |
if(v < e) { | |
// 0 is just 0, but may get exponent if it's the last tick | |
v = '0'; | |
isNeg = false; | |
} else { | |
v += e; | |
// take out a common exponent, if any | |
if(exponent) { | |
v *= Math.pow(10, -exponent); | |
tickRound += exponent; | |
} | |
// round the mantissa | |
if(tickRound === 0) v = String(Math.floor(v)); | |
else if(tickRound < 0) { | |
v = String(Math.round(v)); | |
v = v.substr(0, v.length + tickRound); | |
for(var i = tickRound; i < 0; i++) v += '0'; | |
} else { | |
v = String(v); | |
var dp = v.indexOf('.') + 1; | |
if(dp) v = v.substr(0, dp + tickRound).replace(/\.?0+$/, ''); | |
} | |
// insert appropriate decimal point and thousands separator | |
v = Lib.numSeparate(v, ax._separators, separatethousands); | |
} | |
// add exponent | |
if(exponent && exponentFormat !== 'hide') { | |
if(isSIFormat(exponentFormat) && beyondSI(exponent)) exponentFormat = 'power'; | |
var signedExponent; | |
if(exponent < 0) signedExponent = MINUS_SIGN + -exponent; | |
else if(exponentFormat !== 'power') signedExponent = '+' + exponent; | |
else signedExponent = String(exponent); | |
if(exponentFormat === 'e' || exponentFormat === 'E') { | |
v += exponentFormat + signedExponent; | |
} else if(exponentFormat === 'power') { | |
v += '×10<sup>' + signedExponent + '</sup>'; | |
} else if(exponentFormat === 'B' && exponent === 9) { | |
v += 'B'; | |
} else if(isSIFormat(exponentFormat)) { | |
v += SIPREFIXES[exponent / 3 + 5]; | |
} | |
} | |
// put sign back in and return | |
// replace standard minus character (which is technically a hyphen) | |
// with a true minus sign | |
if(isNeg) return MINUS_SIGN + v; | |
return v; | |
} |
where instead of exiting early
plotly.js/src/plots/cartesian/axes.js
Line 1338 in 96fe262
if(tickformat) return ax._numFormat(tickformat)(v).replace(/-/g, MINUS_SIGN); |
for when tickformat
is set, we could construct the formatted number ourselves for tickformat
values that include s
and p
. To me, that sounds a lot more robust than these floating-point tricks that gets us a rounded-off tick step. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it would be interesting to fix the issue at the root cause
What is the root cause in your mind here?
@archmoj I did a little testing, and I think you may be on to something useful. AFAICT, as long as we add/subtract integers, and then divide by a power of 10 (again, explicitly rounded to be an integer), we're protected from these floating point errors over a quite wide range of values. (I tested For performance purposes I suspect we'd want to calculate up front the power of 10 that makes both Now, the question is what cases that actually fixes? We could use it to fix cases we don't handle correctly right now in our own formatting - for example But it won't generally fix Really the only solution I see is to encourage users never to use bare |
@alexcjohnson Thanks very much for testing this. |
Good point. Most of these test would fail with the master version: https://github.com/plotly/plotly.js/blob/76b036859248c43518bd4f15f5dfa35444f665ed/test/jasmine/tests/lib_increment_numeric_test.js @alexcjohnson Thank for mentioning 'e' & 'g' as well. |
In order to potentially get this PR merged:
|
This case is fixed in d3 > v3. |
@archmoj I stumbled upon https://github.com/MikeMcl/decimal.js/ - which might be of interest for this PR. |
Reworked in #5114. |
Fixes #3814.
This PR removes some precision errors happening during tick increments on axes.
Two patches are added here:
First and most importantly we use a dynamic pattern similar to (10 * 0.1 + 10 * 0.2) / 10 to get exactly
0.3
as the result not0.30000000000000004
.Then we check if the length of new x (i.e. x0 + dtick) as an string is greater than the total lengths of previous value and
dtick
we round the result to 12 digits. This helps cover few edge cases e.g. where 3 * 0.3 sums up to0.8999999999999999
not0.9
.To view before vs after changes please view 4855033?short_path=4bd0c49#diff-4bd0c497a76299dd674f7b3092fb6aad.
Also to mention it appears that there is currently a bug in d3 formatting where 0.55 in
.
p
does not return a rounded 55%.@plotly/plotly_js