diff --git a/doc/api/next_api_changes/2018-03-15-SS.rst b/doc/api/next_api_changes/2018-03-15-SS.rst new file mode 100644 index 000000000000..a3599cb524eb --- /dev/null +++ b/doc/api/next_api_changes/2018-03-15-SS.rst @@ -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. diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index e0d487b197cf..ae2156b865e4 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -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, @@ -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] @@ -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 + 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 + + # 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: @@ -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 + 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 else: # We went through the whole loop without breaking, default to # the last interval in the list and raise a warning @@ -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 diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 99e445863877..595fa7a8c913 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -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], diff --git a/lib/matplotlib/tests/baseline_images/test_dates/datetime_daily_overlap.png b/lib/matplotlib/tests/baseline_images/test_dates/datetime_daily_overlap.png new file mode 100644 index 000000000000..177485ecd744 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_dates/datetime_daily_overlap.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_dates/datetime_hourly_overlap.png b/lib/matplotlib/tests/baseline_images/test_dates/datetime_hourly_overlap.png new file mode 100644 index 000000000000..96f4734106f7 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_dates/datetime_hourly_overlap.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_dates/datetime_minutely_overlap.png b/lib/matplotlib/tests/baseline_images/test_dates/datetime_minutely_overlap.png new file mode 100644 index 000000000000..153ee81e3042 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_dates/datetime_minutely_overlap.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_dates/datetime_monthly_overlap.png b/lib/matplotlib/tests/baseline_images/test_dates/datetime_monthly_overlap.png new file mode 100644 index 000000000000..4387104bb142 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_dates/datetime_monthly_overlap.png differ diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index e4af37827593..011cd2ead5d5 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -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(): @@ -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', @@ -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) @@ -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) diff --git a/matplotlibrc.template b/matplotlibrc.template index d301ece5200b..37fc13c73435 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -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 + #### TICKS ## see http://matplotlib.org/api/axis_api.html#matplotlib.axis.Tick #xtick.top : False ## draw ticks on the top side