8000 outfactor main parts of ConciseDateFormatter, add ConciseTimedeltaFor… · matplotlib/matplotlib@7cbc56b · GitHub
[go: up one dir, main page]

Skip to content

Commit 7cbc56b

Browse files
committed
outfactor main parts of ConciseDateFormatter, add ConciseTimedeltaFormatter
1 parent 821ff13 commit 7cbc56b

File tree

2 files changed

+252
-76
lines changed

2 files changed

+252
-76
lines changed

lib/matplotlib/dates.py

Lines changed: 193 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,8 @@ def strftimedelta(td, fmt_str):
625625
fmt_str : str
626626
format string
627627
"""
628+
# TODO: make as compatible as possible with strftime format strings,
629+
# remove %day
628630
# *_t values are not partially consumed by there next larger unit
629631
# e.g. for timedelta(days=1.5): d=1, h=12, H=36
630632
s_t = td.total_seconds()
@@ -726,7 +728,114 @@ def set_tzinfo(self, tz):
726728
self.tz = _get_tzinfo(tz)
727729

728730

729-
class ConciseDateFormatter(ticker.Formatter):
731+
class _ConciseTimevalueFormatter(ticker.Formatter):
732+
733+
def __init__(self, locator, show_offset=True, *, usetex=None):
734+
self._locator = locator
735+
self.offset_string = ''
736+
self.show_offset = show_offset
737+
self._usetex = mpl._val_or_rc(usetex, 'text.usetex')
738+
739+
self.formats = list()
740+
self.zero_formats = list()
741+
self.offset_formats = list()
742+
743+
def __call__(self, x, pos=None):
744+
return NotImplemented
745+
746+
def _get_formats(self):
747+
return self.formats, self.zero_formats, self.offset_formats
748+
749+
def _format_ticks(self, tickvalue, ticktuple):
750+
# basic algorithm:
751+
# 1) only display a part of the date if it changes over the ticks.
752+
# 2) don't display the smaller part of the date if:
753+
# it is always the same or if it is the start of the
754+
# year, month, day etc.
755+
fmts, zerofmts, offsetfmts = self._get_formats()
756+
# fmts: format for most ticks at this level
757+
# zerofmts: format beginnings of days, months, years, etc.
758+
# offsetfmts: offset fmt are for the offset in the upper left of the
759+
# or lower right of the axis.
760+
show_offset = self.show_offset
761+
762+
# determine the level we will label at:
763+
# mostly 0: years, 1: months, 2: days,
764+
# 3: hours, 4: minutes, 5: seconds, 6: microseconds
765+
for level in range(5, -1, -1):
766+
unique = np.unique(ticktuple[:, level])
767+
if len(unique) > 1:
768+
# if 1 is included in unique, the year is shown in ticks
769+
if level < 2 and np.any(unique == 1):
770+
show_offset = False
771+
break
772+
elif level == 0:
773+
# all tickdate are the same, so only micros might be different
774+
# set to the most precise (6: microseconds doesn't exist...)
775+
level = 5
776+
777+
# level is the basic level we will label at.
778+
# now loop through and decide the actual ticklabels
779+
zerovals = [0, 1, 1, 0, 0, 0, 0]
780+
labels = [''] * len(ticktuple)
781+
for nn in range(len(ticktuple)):
782+
if level < 5:
783+
if ticktuple[nn][level] == zerovals[level]:
784+
fmt = zerofmts[level]
785+
else:
786+
fmt = fmts[level]
787+
else:
788+
# special handling for seconds + microseconds
789+
if (isinstance(tickvalue[nn], datetime.timedelta)
790+
and (tickvalue[nn].total_seconds() % 60 == 0.0)):
791+
fmt = zerofmts[level]
792+
elif (isinstance(tickvalue[nn], datetime.datetime)
793+
and (tickvalue[nn].second
794+
== tickvalue[nn].microsecond == 0)):
795+
fmt = zerofmts[level]
796+
else:
797+
fmt = fmts[level]
798+
labels[nn] = self._format_string(tickvalue[nn], fmt)
799+
800+
# special handling of seconds and microseconds:
801+
# strip extra zeros and decimal if possible.
802+
# this is complicated by two factors. 1) we have some level-4 strings
803+
# here (i.e. 03:00, '0.50000', '1.000') 2) we would like to have the
804+
# same number of decimals for each string (i.e. 0.5 and 1.0).
805+
if level >= 5:
806+
trailing_zeros = min(
807+
(len(s) - len(s.rstrip('0')) for s in labels if '.' in s),
808+
default=None)
809+
if trailing_zeros:
810+
for nn in range(len(labels)):
811+
if '.' in labels[nn]:
812+
labels[nn] = labels[nn][:-trailing_zeros].rstrip('.')
813+
814+
if show_offset:
815+
# set the offset string:
816+
self.offset_string = self._format_string(tickvalue[-1],
817+
offsetfmts[level])
818+
if self._usetex:
819+
self.offset_string = _wrap_in_tex(self.offset_string)
820+
else:
821+
self.offset_string = ''
822+
823+
if self._usetex:
824+
return [_wrap_in_tex(l) for l in labels]
825+
else:
826+
return labels
827+
828+
def get_offset(self):
829+
return self.offset_string
830+
831+
def format_data_short(self, value):
832+
return NotImplemented
833+
834+
def _format_string(self, value, fmt):
835+
return NotImplemented
836+
837+
838+
class ConciseDateFormatter(_ConciseTimevalueFormatter):
730839
"""
731840
A `.Formatter` which attempts to figure out the best format to use for the
732841
date, and to make it as compact as possible, but still be complete. This is
@@ -803,7 +912,7 @@ def __init__(self, locator, tz=None, formats=None, offset_formats=None,
803912
Autoformat the date labels. The default format is used to form an
804913
initial string, and then redundant elements are removed.
805914
"""
806-
self._locator = locator
915+
super().__init__(locator, show_offset=show_offset, usetex=usetex)
807916
self._tz = tz
808917
self.defaultfmt = '%Y'
809918
# there are 6 levels with each level getting a specific format
@@ -851,9 +960,6 @@ def __init__(self, locator, tz=None, formats=None, offset_formats=None,
851960
'%Y-%b-%d',
852961
'%Y-%b-%d',
853962
'%Y-%b-%d %H:%M']
854-
self.offset_string = ''
855-
self.show_offset = show_offset
856-
self._usetex = mpl._val_or_rc(usetex, 'text.usetex')
857963

858964
def __call__(self, x, pos=None):
859965
formatter = DateFormatter(self.defaultfmt, self._tz,
@@ -862,88 +968,99 @@ def __call__(self, x, pos=None):
862968

863969
def format_ticks(self, values):
864970
tickdatetime = [num2date(value, tz=self._tz) for value in values]
865-
tickdate = np.array([tdt.timetuple()[:6] for tdt in tickdatetime])
971+
ticktuple = np.array([tdt.timetuple()[:6] for tdt in tickdatetime])
972+
return super()._format_ticks(tickdatetime, ticktuple)
866973

867-
# basic algorithm:
868-
# 1) only display a part of the date if it changes over the ticks.
869-
# 2) don't display the smaller part of the date if:
870-
# it is always the same or if it is the start of the
871-
# year, month, day etc.
872-
# fmt for most ticks at this level
873-
fmts = self.formats
874-
# format beginnings of days, months, years, etc.
875-
zerofmts = self.zero_formats
876-
# offset fmt are for the offset in the upper left of the
877-
# or lower right of the axis.
878-
offsetfmts = self.offset_formats
879-
show_offset = self.show_offset
974+
def format_data_short(self, value):
975+
return num2date(value, tz=self._tz).strftime('%Y-%m-%d %H:%M:%S')
880976

881-
# determine the level we will label at:
882-
# mostly 0: years, 1: months, 2: days,
883-
# 3: hours, 4: minutes, 5: seconds, 6: microseconds
884-
for level in range(5, -1, -1):
885-
unique = np.unique(tickdate[:, level])
886-
if len(unique) > 1:
887-
# if 1 is included in unique, the year is shown in ticks
888-
if level < 2 and np.any(unique == 1):
889-
show_offset = False
890-
break
891-
elif level == 0:
892-
# all tickdate are the same, so only micros might be different
893-
# set to the most precise (6: microseconds doesn't exist...)
894-
level = 5
977+
def _format_string(self, value, fmt):
978+
return value.strftime(fmt)
895979

896-
# level is the basic level we will label at.
897-
# now loop through and decide the actual ticklabels
898-
zerovals = [0, 1, 1, 0, 0, 0, 0]
899-
labels = [''] * len(tickdate)
900-
for nn in range(len(tickdate)):
901-
if level < 5:
902-
if tickdate[nn][level] == zerovals[level]:
903-
fmt = zerofmts[level]
904-
else:
905-
fmt = fmts[level]
906-
else:
907-
# special handling for seconds + microseconds
908-
if (tickdatetime[nn].second == tickdatetime[nn].microsecond
909-
== 0):
910-
fmt = zerofmts[level]
911-
else:
912-
fmt = fmts[level]
913-
labels[nn] = tickdatetime[nn].strftime(fmt)
914980

915-
# special handling of seconds and microseconds:
916-
# strip extra zeros and decimal if possible.
917-
# this is complicated by two factors. 1) we have some level-4 strings
918-
# here (i.e. 03:00, '0.50000', '1.000') 2) we would like to have the
919-
# same number of decimals for each string (i.e. 0.5 and 1.0).
920-
if level >= 5:
921-
trailing_zeros = min(
922-
(len(s) - len(s.rstrip('0')) for s in labels if '.' in s),
923-
default=None)
924-
if trailing_zeros:
925-
for nn in range(len(labels)):
926-
if '.' in labels[nn]:
927-
labels[nn] = labels[nn][:-trailing_zeros].rstrip('.')
981+
class ConciseTimedeltaFormatter(_ConciseTimevalueFormatter):
982+
# TODO: add docs
928983

929-
if show_offset:
930-
# set the offset string:
931-
self.offset_string = tickdatetime[-1].strftime(offsetfmts[level])
932-
if self._usetex:
933-
self.offset_string = _wrap_in_tex(self.offset_string)
984+
def __init__(self, locator, formats=None, offset_formats=None,
985+
zero_formats=None, show_offset=True, *, usetex=None):
986+
"""
987+
Autoformat the date labels. The default format is used to form an
988+
initial string, and then redundant elements are removed.
989+
"""
990+
super().__init__(locator, show_offset=show_offset, usetex=usetex)
991+
self.defaultfmt = '%{d}D'
992+
# there are 6 levels with each level getting a specific format
993+
# 0: mostly years, 1: months, 2: days,
994+
# 3: hours, 4: minutes, 5: seconds
995+
# level 0 and 1 are unsupported for timedelta and skipped here
996+
if formats:
997+
if len(formats) != 4:
998+
raise ValueError('formats argument must be a list of '
999+
'4 format strings (or None)')
1000+
self.formats = formats
9341001
else:
935-
self.offset_string = ''
1002+
self.formats = ['%{d}D', # days
1003+
'%h:%m', # hours
1004+
'%h:%m', # minutes
1005+
'%s.%ms%us', # secs
1006+
]
1007+
# fmt for zeros ticks at this level. These are
1008+
# ticks that should be labeled w/ info the level above.
1009+
# like 02:02:00 can just be labeled 02:02.
1010+
if zero_formats 10000 :
1011+
if len(zero_formats) != 4:
1012+
raise ValueError('zero_formats argument must be a list of '
1013+
'4 format strings (or4 None)')
1014+
self.zero_formats = zero_formats
1015+
else:
1016+
# use the users formats for the zero tick formats
1017+
self.zero_formats = [''] + self.formats[:-1]
9361018

937-
if self._usetex:
938-
return [_wrap_in_tex(l) for l in labels]
1019+
if offset_formats:
1020+
if len(offset_formats) != 4:
1021+
raise ValueError('offset_formats argument must be a list of '
1022+
'4 format strings (or None)')
1023+
self.offset_formats = offset_formats
9391024
else:
940-
return labels
1025+
self.offset_formats = ['',
1026+
'',
1027+
'%{d}D',
1028+
'%{d}D %h:%m']
9411029

942-
def get_offset(self):
943-
return self.offset_string
1030+
def __call__(self, x, pos=None):
1031+
formatter = TimedeltaFormatter(self.defaultfmt, usetex=self._usetex)
1032+
return formatter(x, pos=pos)
1033+
1034+
def _make_timetuple(self, td):
1035+
# returns a tuple similar in structure to datetime.timetuple
1036+
# all values are rounded to integer precision
1037+
s_t = td.total_seconds()
1038+
d, s = divmod(s_t, SEC_PER_DAY)
1039+
m_t, s = divmod(s, SEC_PER_MIN)
1040+
h, m = divmod(m_t, MIN_PER_HOUR)
1041+
1042+
# year, month not supported for timedelta, therefore zero
1043+
return 0, 0, d, h, m, s, td.microseconds
1044+
1045+
def _get_formats(self):
1046+
# extend list of format strings by two emtpy (and unused) strings for
1047+
# year and month (necessary for compatibility with base class)
1048+
ret = list()
1049+
for fmts in (self.formats, self.zero_formats, self.offset_formats):
1050+
ret.append(["", "", *fmts])
1051+
return ret
1052+
1053+
def format_ticks(self, values):
1054+
ticktimedelta = [num2timedelta(value) for value in values]
1055+
ticktuple = np.array([self._make_timetuple(tdt)
1056+
for tdt in ticktimedelta])
1057+
return super()._format_ticks(ticktimedelta, ticktuple)
9441058

9451059
def format_data_short(self, value):
946-
return num2date(value, tz=self._tz).strftime('%Y-%m-%d %H:%M:%S')
1060+
return strftimedelta(num2timedelta(value), '%{d}D %h:%m:%s')
1061+
1062+
def _format_string(self, value, fmt):
1063+
return strftimedelta(value, fmt)
9471064

9481065

9491066
class _AutoTimevalueFormatter(ticker.Formatter):

lib/matplotlib/tests/test_dates.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,3 +1642,62 @@ def test_timedelta_formatter_usetex(delta, expected):
16421642
formatter = mdates.AutoTimedeltaFormatter(locator, usetex=True)
16431643
assert [formatter(loc) for loc in locator()] == [
16441644
r'{\fontfamily{\familydefault}\selectfont %s}' % s for s in expected]
1645+
1646+
1647+
@pytest.mark.parametrize('t_delta, expected', [
1648+
[datetime.timedelta(days=141), # label on days
1649+
['100D', '120D', '140D', '160D', '180D', '200D', '220D', '240D', '260D']
1650+
],
1651+
[datetime.timedelta(hours=40), # label on hh:mm, zero format on days
1652+
['100D', '06:00', '12:00', '18:00', '101D', '06:00', '12:00',
1653+
'18:00', '102D']
1654+
],
1655+
[datetime.timedelta(minutes=30), # label on hh:mm, same for zero format
1656+
['03:40', '03:45', '03:50', '03:55', '04:00', '04:05', '04:10',
1657+
'04:15', '04:20']
1658+
],
1659+
[datetime.timedelta(seconds=30), # label on seconds, zero format hh:mm
1660+
['03:45', '05', '10', '15', '20', '25', '30', '35']
1661+
],
1662+
[datetime.timedelta(seconds=2), # label on seconds.f, zero format hh:mm
1663+
['03:45', '00.5', '01.0', '01.5', '02.0', '02.5']
1664+
],
1665+
])
1666+
def test_concise_timedelta_formatter(t_delta, expected):
1667+
t1 = datetime.timedelta(days=100, hours=3, minutes=45)
1668+
t2 = t1 + t_delta
1669+
1670+
fig, ax = plt.subplots()
1671+
1672+
locator = mdates.AutoTimedeltaLocator()
1673+
formatter = mdates.ConciseTimedeltaFormatter(locator)
1674+
ax.yaxis.set_major_locator(locator)
1675+
ax.yaxis.set_major_formatter(formatter)
1676+
ax.set_ylim(t1, t2)
1677+
fig.canvas.draw()
1678+
strings = [st.get_text() for st in ax.get_yticklabels()]
1679+
1680+
assert strings == expected
1681+
1682+
1683+
@pytest.mark.parametrize('t_delta, expected', [
1684+
[datetime.timedelta(days=141), ""],
1685+
[datetime.timedelta(hours=40), ""],
1686+
[datetime.timedelta(minutes=30), "100D"],
1687+
[datetime.timedelta(seconds=30), "100D 03:45"],
1688+
[datetime.timedelta(seconds=2), "100D 03:45"],
1689+
])
1690+
def test_concise_timedelta_formatter_show_offset(t_delta, expected):
1691+
t1 = datetime.timedelta(days=100, hours=3, minutes=45)
1692+
t2 = t1 + t_delta
1693+
1694+
fig, ax = plt.subplots()
1695+
1696+
locator = mdates.AutoTimedeltaLocator()
1697+
formatter = mdates.ConciseTimedeltaFormatter(locator)
1698+
ax.yaxis.set_major_locator(locator)
1699+
ax.yaxis.set_major_formatter(formatter)
1700+
ax.set_ylim(t1, t2)
1701+
fig.canvas.draw()
1702+
1703+
assert formatter.get_offset() == expected

0 commit comments

Comments
 (0)
0