Skip to content

Commit c763629

Browse files
committed
Fix AutoFormatter nonzero num-correction, add locale-awareness
1 parent 143836c commit c763629

File tree

1 file changed

+131
-59
lines changed

1 file changed

+131
-59
lines changed

proplot/ticker.py

Lines changed: 131 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
Various `~matplotlib.ticker.Locator` and `~matplotlib.ticker.Formatter`
44
classes.
55
"""
6+
import re
67
import numpy as np
78
import matplotlib.ticker as mticker
9+
import locale
810
from fractions import Fraction
911
from .internals import ic # noqa: F401
1012
from .internals import _not_none
@@ -16,33 +18,23 @@
1618
'SimpleFormatter',
1719
]
1820

19-
20-
def _sanitize_label(string, zerotrim=False):
21-
"""
22-
Sanitize tick label strings.
23-
"""
24-
if zerotrim and '.' in string:
25-
string = string.rstrip('0').rstrip('.')
26-
string = string.replace('-', '\N{MINUS SIGN}')
27-
if string == '\N{MINUS SIGN}0':
28-
string = '0'
29-
return string
21+
REGEX_ZERO = re.compile('\\A[-\N{MINUS SIGN}]?0(.0*)?\\Z')
22+
REGEX_MINUS = re.compile('\\A[-\N{MINUS SIGN}]\\Z')
23+
REGEX_MINUS_ZERO = re.compile('\\A[-\N{MINUS SIGN}]0(.0*)?\\Z')
3024

3125

3226
class AutoFormatter(mticker.ScalarFormatter):
3327
"""
34-
The new default formatter, a simple wrapper around
35-
`~matplotlib.ticker.ScalarFormatter`. Differs from
36-
`~matplotlib.ticker.ScalarFormatter` in the following ways:
37-
38-
1. Trims trailing zeros if any exist.
39-
2. Allows user to specify *range* within which major tick marks
40-
are labelled.
41-
3. Allows user to add arbitrary prefix or suffix to every
42-
tick label string.
28+
The new default formatter. Differs from `~matplotlib.ticker.ScalarFormatter`
29+
in the following ways:
30+
31+
1. Trims trailing decimal zeros by default.
32+
2. Permits specifying *range* within which major tick marks are labeled.
33+
3. Permits adding arbitrary prefix or suffix to every tick label string.
34+
4. Permits adding "negative" and "positive" indicator.
4335
"""
4436
def __init__(
45-
self, *args,
37+
self,
4638
zerotrim=None, tickrange=None,
4739
prefix=None, suffix=None, negpos=None, **kwargs
4840
):
@@ -63,7 +55,7 @@ def __init__(
6355
6456
Other parameters
6557
----------------
66-
*args, **kwargs
58+
**kwargs
6759
Passed to `~matplotlib.ticker.ScalarFormatter`.
6860
6961
Warning
@@ -75,7 +67,7 @@ def __init__(
7567
this behavior with a patch.
7668
"""
7769
tickrange = tickrange or (-np.inf, np.inf)
78-
super().__init__(*args, **kwargs)
70+
super().__init__(**kwargs)
7971
from .config import rc
8072
zerotrim = _not_none(zerotrim, rc['axes.formatter.zerotrim'])
8173
self._zerotrim = zerotrim
@@ -96,55 +88,135 @@ def __call__(self, x, pos=None):
9688
The position.
9789
"""
9890
# Tick range limitation
99-
eps = abs(x) / 1000
100-
tickrange = self._tickrange
101-
if (x + eps) < tickrange[0] or (x - eps) > tickrange[1]:
102-
return '' # avoid some ticks
91+
if self._outside_tick_range(x, self._tickrange):
92+
return ''
10393

10494
# Negative positive handling
105-
if not self._negpos or x == 0:
106-
tail = ''
107-
elif x > 0:
108-
tail = self._negpos[1]
109-
else:
110-
x *= -1
111-
tail = self._negpos[0]
95+
x, tail = self._neg_pos_format(x, self._negpos)
11296

11397
# Default string formatting
11498
string = super().__call__(x, pos)
115-
string = _sanitize_label(string, zerotrim=self._zerotrim)
11699

100+
# Fix issue where non-zero string is formatted as zero
101+
string = self._fix_small_number(x, string)
102+
103+
# Custom string formatting
104+
string = self._minus_format(string)
105+
if self._zerotrim:
106+
string = self._trim_trailing_zeros(string, self.get_useLocale())
107+
108+
# Prefix and suffix
109+
string = self._add_prefix_suffix(string, self._prefix, self._suffix)
110+
string = string + tail # add negative-positive indicator
111+
return string
112+
113+
@staticmethod
114+
def _add_prefix_suffix(string, prefix=None, suffix=None):
115+
"""
116+
Add prefix and suffix to string.
117+
"""
118+
sign = ''
119+
prefix = prefix or ''
120+
suffix = suffix or ''
121+
if string and REGEX_MINUS.match(string[0]):
122+
sign, string = string[0], string[1:]
123+
return sign + prefix + string + suffix
124+
125+
@staticmethod
126+
def _fix_small_number(x, string, offset=2):
127+
"""
128+
Fix formatting for non-zero number that gets formatted as zero. The `offset`
129+
controls the offset from the true floating point precision at which we want
130+
to limit maximum precision of the string.
131+
"""
117132
# Add just enough precision for small numbers. Default formatter is
118133
# only meant to be used for linear scales and cannot handle the wide
119134
# range of magnitudes in e.g. log scales. To correct this, we only
120135
# truncate if value is within one order of magnitude of the float
121136
# precision. Common issue is e.g. levels=plot.arange(-1, 1, 0.1).
122137
# This choice satisfies even 1000 additions of 0.1 to -100.
123-
# Example code:
124-
# def add(x, decimals=1, type_=np.float64):
125-
# step = type_(10 ** -decimals)
126-
# y = type_(x) + step
127-
# if np.round(y, decimals) == 0:
128-
# return y
129-
# else:
130-
# return add(y, decimals)
131-
# num = abs(add(-200, 1, float))
132-
# precision = abs(np.log10(num) // 1) - 1
133-
# ('{:.%df}' % precision).format(num)
134-
if string == '0' and x != 0:
135-
string = (
136-
'{:.%df}' % min(
137-
int(abs(np.log10(abs(x)) // 1)),
138-
np.finfo(type(x)).precision - 1
139-
)
140-
).format(x)
141-
string = _sanitize_label(string, zerotrim=self._zerotrim)
138+
match = REGEX_ZERO.match(string)
139+
decimal_point = AutoFormatter._get_decimal_point()
142140

143-
# Prefix and suffix
144-
sign = ''
145-
if string and string[0] == '\N{MINUS SIGN}':
146-
sign, string = string[0], string[1:]
147-
return sign + self._prefix + string + self._suffix + tail
141+
if match and x != 0:
142+
# Get initial precision spit out by algorithm
143+
decimals, = match.groups()
144+
if decimals:
145+
precision_init = len(decimals.lstrip(decimal_point))
146+
else:
147+
precision_init = 0
148+
149+
# Format with precision below floating point error
150+
precision_true = int(abs(np.log10(abs(x)) // 1))
151+
precision_max = np.finfo(type(x)).precision - offset
152+
precision = min(precision_true, precision_max)
153+
string = ('{:.%df}' % precision).format(x)
154+
155+
# If number is zero after ignoring floating point error, generate
156+
# zero with precision matching original string.
157+
if REGEX_ZERO.match(string):
158+
string = ('{:.%df}' % precision_init).format(0)
159+
160+
# Fix decimal point
161+
string = string.replace('.', decimal_point)
162+
163+
return string
164+
165+
@staticmethod
166+
def _get_decimal_point(use_locale=None):
167+
"""
168+
Get decimal point symbol for current locale (e.g. in Europe will be comma).
169+
"""
170+
from .config import rc
171+
use_locale = _not_none(use_locale, rc['axes.formatter.use_locale'])
172+
if use_locale:
173+
return locale.localeconv()['decimal_point']
174+
else:
175+
return '.'
176+
177+
@staticmethod
178+
def _minus_format(string):
179+
"""
180+
Format the minus sign and avoid "negative zero," e.g. ``-0.000``.
181+
"""
182+
from .config import rc
183+
if rc['axes.unicode_minus'] and not rc['text.usetex']:
184+
string = string.replace('-', '\N{MINUS SIGN}')
185+
if REGEX_MINUS_ZERO.match(string):
186+
string = string[1:]
187+
return string
188+
189+
@staticmethod
190+
def _neg_pos_format(x, negpos):
191+
"""
192+
Permit suffixes indicators for "negative" and "positive" numbers.
193+
"""
194+
if not negpos or x == 0:
195+
tail = ''
196+
elif x > 0:
197+
tail = negpos[1]
198+
else:
199+
x *= -1
200+
tail = negpos[0]
201+
return x, tail
202+
203+
@staticmethod
204+
def _outside_tick_range(x, tickrange):
205+
"""
206+
Return whether point is outside tick range up to some precision.
207+
"""
208+
eps = abs(x) / 1000
209+
return (x + eps) < tickrange[0] or (x - eps) > tickrange[1]
210+
211+
@staticmethod
212+
def _trim_trailing_zeros(string, use_locale=None):
213+
"""
214+
Sanitize tick label strings.
215+
"""
216+
decimal_point = AutoFormatter._get_decimal_point()
217+
if decimal_point in string:
218+
string = string.rstrip('0').rstrip(decimal_point)
219+
return string
148220

149221

150222
def SigFigFormatter(sigfig=1, zerotrim=False):

0 commit comments

Comments
 (0)