Skip to content

Fix for overlapping datetime intervals #10779

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

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/api/next_api_changes/2018-03-15-SS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
``AutoDateLocator.get_locator`` now contains reduced datetime overlaps
````````````````````````````````````````````````````````````````````````

Due to issue #7712, the interval frequency of datetime ticks gets reduced due to
avoid overlapping tick labels.
63 changes: 58 additions & 5 deletions lib/matplotlib/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -1208,12 +1208,20 @@ def __init__(self, tz=None, minticks=5, maxticks=None,
The interval is used to specify multiples that are appropriate for
the frequency of ticking. For instance, every 7 days is sensible
for daily ticks, but for minutes/seconds, 15 or 30 make sense.
You can customize this dictionary by doing::
You can customize this dictionary by doing:

locator = AutoDateLocator()
locator.intervald[HOURLY] = [3] # only show every 3 hours

In order to avoid overlapping dates, another dictionary was
created to map date intervals to the format of the date used in
rcParams. In addition, the figsize and font is used from the axis,
and a new setting in rcparams['autodatelocator.spacing'] is added
and used to let the user decide when spacing should be used. This
was done because rotation at this point in runtime is not known.
"""
DateLocator.__init__(self, tz)

self._locator = YearLocator()
self._freq = YEARLY
self._freqs = [YEARLY, MONTHLY, DAILY, HOURLY, MINUTELY,
Expand Down Expand Up @@ -1242,6 +1250,16 @@ def __init__(self, tz=None, minticks=5, maxticks=None,
MICROSECONDLY: [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000,
5000, 10000, 20000, 50000, 100000, 200000, 500000,
1000000]}

self.eachtick = {
YEARLY: rcParams['date.autoformatter.year'],
MONTHLY: rcParams['date.autoformatter.month'],
DAILY: rcParams['date.autoformatter.day'],
HOURLY: rcParams['date.autoformatter.hour'],
MINUTELY: rcParams['date.autoformatter.minute'],
SECONDLY: rcParams['date.autoformatter.second'],
MICROSECONDLY: rcParams['date.autoformatter.microsecond']}

self._byranges = [None, range(1, 13), range(1, 32),
range(0, 24), range(0, 60), range(0, 60), None]

Expand Down Expand Up @@ -1314,11 +1332,34 @@ def get_locator(self, dmin, dmax):
# bysecond, unused (for microseconds)]
byranges = [None, 1, 1, 0, 0, 0, None]

# Required attributes to get width from figure's normal points
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should all be wrapped in if spacing:

I don't get why you are using getattr for all these. If these attributes don't exist, you might as well error here because you weren't passed an axis...

Copy link
Author

@shadidsh shadidsh Mar 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we raise exceptions it would break existing tests using mock classes that might have aaxis attributes as none.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which tests? I don't understand what a test would be doing down in here w/o the axes being set (for instance).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example test_dates.py::def test_auto_date_locator(), this uses dummy axis to specifically check values returned from autodatelocator. It has a few none axis attributes

https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/tests/test_dates.py#L320

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, well I'd just check if whichever one of these is None and set spacing=False if that happens

axis_name = getattr(self.axis, 'axis_name', '')
axes = getattr(self.axis, 'axes', None)
label = getattr(self.axis, 'label', None)
figure = getattr(self.axis, 'figure', None)
transfig = getattr(figure, 'transFigure', None)
maxwid = rcParams['figure.figsize'][0]

# estimated font ratio on font size 10
if label is not None:
font_ratio = label.get_fontsize() / 10
else:
font_ratio = rcParams['font.size'] / 10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure the ticks have a fontsize associated with them beyond the label fontsize. THats the fontsize you should querry, and I'm pretty sure it exists.


# a ratio of 8 date characters per inch is 'estimated'
if (axes is not None and transfig is not None):
bbox = axes.get_position(original=False)
figwidth = transfig.transform(bbox.get_points())[1][0]
dpi = figure.get_dpi()
maxwid = (figwidth / dpi) * 8

spacing = (rcParams["autodatelocator.spacing"] == "generous")

# Loop over all the frequencies and try to find one that gives at
# least a minticks tick positions. Once this is found, look for
# an interval from an list specific to that frequency that gives no
# more than maxticks tick positions. Also, set up some ranges
# (bymonth, etc.) as appropriate to be passed to rrulewrapper.
# (bymonth, etc.) as appropriate to be passed to rrulewrapper
for i, (freq, num) in enumerate(zip(self._freqs, nums)):
# If this particular frequency doesn't give enough ticks, continue
if num < self.minticks:
Expand All @@ -1328,11 +1369,24 @@ def get_locator(self, dmin, dmax):
byranges[i] = None
continue

# Compute at runtime the size of date label with given format
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, this should only happen if spacing is on.

if (axis_name == 'x'):
datelen_ratio = \
len(dmin.strftime(self.eachtick[freq])) * font_ratio
else:
datelen_ratio = 1 * font_ratio

# Find the first available interval that doesn't give too many
# ticks
for interval in self.intervald[freq]:
if num <= interval * (self.maxticks[freq] - 1):
break

# Using an estmation of characters per inch, reduce
# intervals untill we get no overlaps
apply_spread = (not spacing or
((num/interval) * datelen_ratio) <= maxwid)
if (num <= interval * (self.maxticks[freq] - 1)):
if (apply_spread):
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??? I don't understand why you only break if apply_spread=True here.

Copy link
Author

@shadidsh shadidsh Mar 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When users have rotated labels we shouldn't apply spread, it applies too much spacing. Unfortunately this is why i added to Rcparams in the first place

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you need "interval" to be correct for byranges, below.

else:
# We went through the whole loop without breaking, default to
# the last interval in the list and raise a warning
Expand All @@ -1344,7 +1398,6 @@ def get_locator(self, dmin, dmax):

# Set some parameters as appropriate
self._freq = freq

if self._byranges[i] and self.interval_multiples:
byranges[i] = self._byranges[i][::interval]
interval = 1
Expand Down
3 changes: 3 additions & 0 deletions lib/matplotlib/rcsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,9 @@ def _validate_linestyle(ls):
'date.autoformatter.second': ['%H:%M:%S', validate_string],
'date.autoformatter.microsecond': ['%M:%S.%f', validate_string],

# To avoid overlapping date invervals, we can set the spacing in advance
# 'generous' is set to avoid overlapping, otherwise 'tight' by default
'autodatelocator.spacing' : ['tight', validate_string],
#legend properties
'legend.fancybox': [True, validate_bool],
'legend.loc': ['best', validate_legend_loc],
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 51 additions & 7 deletions lib/matplotlib/tests/test_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from matplotlib.testing.decorators import image_comparison
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib import rcParams


def test_date_numpyx():
Expand Down Expand Up @@ -405,12 +406,12 @@ def _create_auto_date_locator(date1, date2):
'2180-01-01 00:00:00+00:00', '2200-01-01 00:00:00+00:00']
],
[datetime.timedelta(weeks=52),
['1997-01-01 00:00:00+00:00', '1997-02-01 00:00:00+00:00',
'1997-03-01 00:00:00+00:00', '1997-04-01 00:00:00+00:00',
'1997-05-01 00:00:00+00:00', '1997-06-01 00:00:00+00:00',
'1997-07-01 00:00:00+00:00', '1997-08-01 00:00:00+00:00',
'1997-09-01 00:00:00+00:00', '1997-10-01 00:00:00+00:00',
'1997-11-01 00:00:00+00:00', '1997-12-01 00:00:00+00:00']
['1997-01-01 00:00:00+00:00', '1997-02-01 00:00:00+00:00',
'1997-03-01 00:00:00+00:00', '1997-04-01 00:00:00+00:00',
'1997-05-01 00:00:00+00:00', '1997-06-01 00:00:00+00:00',
'1997-07-01 00:00:00+00:00', '1997-08-01 00:00:00+00:00',
'1997-09-01 00:00:00+00:00', '1997-10-01 00:00:00+00:00',
'1997-11-01 00:00:00+00:00', '1997-12-01 00:00:00+00:00']
],
[datetime.timedelta(days=141),
['1997-01-01 00:00:00+00:00', '1997-01-22 00:00:00+00:00',
Expand Down Expand Up @@ -453,7 +454,6 @@ def _create_auto_date_locator(date1, date2):
'1997-01-01 00:00:00.001500+00:00']
],
)

for t_delta, expected in results:
d2 = d1 + t_delta
locator = _create_auto_date_locator(d1, d2)
Expand Down Expand Up @@ -612,3 +612,47 @@ def test_tz_utc():
def test_num2timedelta(x, tdelta):
dt = mdates.num2timedelta(x)
assert dt == tdelta


@image_comparison(baseline_images=['datetime_daily_overlap'],
extensions=['png'])
def test_datetime_daily_overlap():
# issue 7712 for overlapping daily dates
plt.rcParams['date.autoformatter.day'] = "%Y-%m-%d"
plt.rcParams["autodatelocator.spacing"] = "generous"
dates = [datetime.datetime(2018, 1, i) for i in range(1, 30)]
values = list(range(1, 30))
plt.plot(dates, values)


@image_comparison(baseline_images=['datetime_monthly_overlap'],
extensions=['png'])
def test_datetime_monthly_overlap():
# issue 7712 for overlapping monthly dates
plt.rcParams['date.autoformatter.month'] = '%Y-%m'
plt.rcParams["autodatelocator.spacing"] = "generous"
dates = [datetime.datetime(2018, i, 1) for i in range(1, 11)]
values = list(range(1, 11))
plt.plot(dates, values)


@image_comparison(baseline_images=['datetime_hourly_overlap'],
extensions=['png'])
def test_datetime_hourly_overlap():
# issue 7712 for overlapping hourly dates
plt.rcParams['date.autoformatter.hour'] = '%m-%d %H'
plt.rcParams["autodatelocator.spacing"] = "generous"
dates = [datetime.datetime(2018, 1, 1, i) for i in range(1, 20)]
values = list(range(1, 20))
plt.plot(dates, values)


@image_comparison(baseline_images=['datetime_minutely_overlap'],
extensions=['png'])
def test_datetime_minutely_overlap():
# issue 7712 for overlapping date ticks in minutely intervals
plt.rcParams['date.autoformatter.minute'] = '%d %H:%M'
plt.rcParams["autodatelocator.spacing"] = "generous"
dates = [datetime.datetime(2018, 1, 1, 1, i) for i in range(1, 55)]
values = list(range(1, 55))
plt.plot(dates, values)
2 changes: 2 additions & 0 deletions matplotlibrc.template
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ backend : $TEMPLATE_BACKEND
#date.autoformatter.second : %H:%M:%S
#date.autoformatter.microsecond : %M:%S.%f

#autodatelocator.spacing : tight ## if generous, add extra spacing to avoid overlap, otherwise tight
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe autodatelocator.generousspacing : False True/False would be a cleaner API?


#### TICKS
## see http://matplotlib.org/api/axis_api.html#matplotlib.axis.Tick
#xtick.top : False ## draw ticks on the top side
Expand Down