1import contextlib
2import datetime as pydt
3from datetime import datetime, timedelta, tzinfo
4import functools
5from typing import Any, Dict, List, Optional, Tuple
6
7from dateutil.relativedelta import relativedelta
8import matplotlib.dates as dates
9from matplotlib.ticker import AutoLocator, Formatter, Locator
10from matplotlib.transforms import nonsingular
11import matplotlib.units as units
12import numpy as np
13
14from pandas._libs import lib
15from pandas._libs.tslibs import Timestamp, to_offset
16from pandas._libs.tslibs.dtypes import FreqGroup
17from pandas._libs.tslibs.offsets import BaseOffset
18
19from pandas.core.dtypes.common import (
20    is_float,
21    is_float_dtype,
22    is_integer,
23    is_integer_dtype,
24    is_nested_list_like,
25)
26
27from pandas import Index, Series, get_option
28import pandas.core.common as com
29from pandas.core.indexes.datetimes import date_range
30from pandas.core.indexes.period import Period, PeriodIndex, period_range
31import pandas.core.tools.datetimes as tools
32
33# constants
34HOURS_PER_DAY = 24.0
35MIN_PER_HOUR = 60.0
36SEC_PER_MIN = 60.0
37
38SEC_PER_HOUR = SEC_PER_MIN * MIN_PER_HOUR
39SEC_PER_DAY = SEC_PER_HOUR * HOURS_PER_DAY
40
41MUSEC_PER_DAY = 1e6 * SEC_PER_DAY
42
43_mpl_units = {}  # Cache for units overwritten by us
44
45
46def get_pairs():
47    pairs = [
48        (Timestamp, DatetimeConverter),
49        (Period, PeriodConverter),
50        (pydt.datetime, DatetimeConverter),
51        (pydt.date, DatetimeConverter),
52        (pydt.time, TimeConverter),
53        (np.datetime64, DatetimeConverter),
54    ]
55    return pairs
56
57
58def register_pandas_matplotlib_converters(func):
59    """
60    Decorator applying pandas_converters.
61    """
62
63    @functools.wraps(func)
64    def wrapper(*args, **kwargs):
65        with pandas_converters():
66            return func(*args, **kwargs)
67
68    return wrapper
69
70
71@contextlib.contextmanager
72def pandas_converters():
73    """
74    Context manager registering pandas' converters for a plot.
75
76    See Also
77    --------
78    register_pandas_matplotlib_converters : Decorator that applies this.
79    """
80    value = get_option("plotting.matplotlib.register_converters")
81
82    if value:
83        # register for True or "auto"
84        register()
85    try:
86        yield
87    finally:
88        if value == "auto":
89            # only deregister for "auto"
90            deregister()
91
92
93def register():
94    pairs = get_pairs()
95    for type_, cls in pairs:
96        # Cache previous converter if present
97        if type_ in units.registry and not isinstance(units.registry[type_], cls):
98            previous = units.registry[type_]
99            _mpl_units[type_] = previous
100        # Replace with pandas converter
101        units.registry[type_] = cls()
102
103
104def deregister():
105    # Renamed in pandas.plotting.__init__
106    for type_, cls in get_pairs():
107        # We use type to catch our classes directly, no inheritance
108        if type(units.registry.get(type_)) is cls:
109            units.registry.pop(type_)
110
111    # restore the old keys
112    for unit, formatter in _mpl_units.items():
113        if type(formatter) not in {DatetimeConverter, PeriodConverter, TimeConverter}:
114            # make it idempotent by excluding ours.
115            units.registry[unit] = formatter
116
117
118def _to_ordinalf(tm: pydt.time) -> float:
119    tot_sec = tm.hour * 3600 + tm.minute * 60 + tm.second + float(tm.microsecond / 1e6)
120    return tot_sec
121
122
123def time2num(d):
124    if isinstance(d, str):
125        parsed = tools.to_datetime(d)
126        if not isinstance(parsed, datetime):
127            raise ValueError(f"Could not parse time {d}")
128        return _to_ordinalf(parsed.time())
129    if isinstance(d, pydt.time):
130        return _to_ordinalf(d)
131    return d
132
133
134class TimeConverter(units.ConversionInterface):
135    @staticmethod
136    def convert(value, unit, axis):
137        valid_types = (str, pydt.time)
138        if isinstance(value, valid_types) or is_integer(value) or is_float(value):
139            return time2num(value)
140        if isinstance(value, Index):
141            return value.map(time2num)
142        if isinstance(value, (list, tuple, np.ndarray, Index)):
143            return [time2num(x) for x in value]
144        return value
145
146    @staticmethod
147    def axisinfo(unit, axis) -> Optional[units.AxisInfo]:
148        if unit != "time":
149            return None
150
151        majloc = AutoLocator()
152        majfmt = TimeFormatter(majloc)
153        return units.AxisInfo(majloc=majloc, majfmt=majfmt, label="time")
154
155    @staticmethod
156    def default_units(x, axis) -> str:
157        return "time"
158
159
160# time formatter
161class TimeFormatter(Formatter):
162    def __init__(self, locs):
163        self.locs = locs
164
165    def __call__(self, x, pos=0) -> str:
166        """
167        Return the time of day as a formatted string.
168
169        Parameters
170        ----------
171        x : float
172            The time of day specified as seconds since 00:00 (midnight),
173            with up to microsecond precision.
174        pos
175            Unused
176
177        Returns
178        -------
179        str
180            A string in HH:MM:SS.mmmuuu format. Microseconds,
181            milliseconds and seconds are only displayed if non-zero.
182        """
183        fmt = "%H:%M:%S.%f"
184        s = int(x)
185        msus = int(round((x - s) * 1e6))
186        ms = msus // 1000
187        us = msus % 1000
188        m, s = divmod(s, 60)
189        h, m = divmod(m, 60)
190        _, h = divmod(h, 24)
191        if us != 0:
192            return pydt.time(h, m, s, msus).strftime(fmt)
193        elif ms != 0:
194            return pydt.time(h, m, s, msus).strftime(fmt)[:-3]
195        elif s != 0:
196            return pydt.time(h, m, s).strftime("%H:%M:%S")
197
198        return pydt.time(h, m).strftime("%H:%M")
199
200
201# Period Conversion
202
203
204class PeriodConverter(dates.DateConverter):
205    @staticmethod
206    def convert(values, units, axis):
207        if is_nested_list_like(values):
208            values = [PeriodConverter._convert_1d(v, units, axis) for v in values]
209        else:
210            values = PeriodConverter._convert_1d(values, units, axis)
211        return values
212
213    @staticmethod
214    def _convert_1d(values, units, axis):
215        if not hasattr(axis, "freq"):
216            raise TypeError("Axis must have `freq` set to convert to Periods")
217        valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64)
218        if isinstance(values, valid_types) or is_integer(values) or is_float(values):
219            return get_datevalue(values, axis.freq)
220        elif isinstance(values, PeriodIndex):
221            return values.asfreq(axis.freq).asi8
222        elif isinstance(values, Index):
223            return values.map(lambda x: get_datevalue(x, axis.freq))
224        elif lib.infer_dtype(values, skipna=False) == "period":
225            # https://github.com/pandas-dev/pandas/issues/24304
226            # convert ndarray[period] -> PeriodIndex
227            return PeriodIndex(values, freq=axis.freq).asi8
228        elif isinstance(values, (list, tuple, np.ndarray, Index)):
229            return [get_datevalue(x, axis.freq) for x in values]
230        return values
231
232
233def get_datevalue(date, freq):
234    if isinstance(date, Period):
235        return date.asfreq(freq).ordinal
236    elif isinstance(date, (str, datetime, pydt.date, pydt.time, np.datetime64)):
237        return Period(date, freq).ordinal
238    elif (
239        is_integer(date)
240        or is_float(date)
241        or (isinstance(date, (np.ndarray, Index)) and (date.size == 1))
242    ):
243        return date
244    elif date is None:
245        return None
246    raise ValueError(f"Unrecognizable date '{date}'")
247
248
249# Datetime Conversion
250class DatetimeConverter(dates.DateConverter):
251    @staticmethod
252    def convert(values, unit, axis):
253        # values might be a 1-d array, or a list-like of arrays.
254        if is_nested_list_like(values):
255            values = [DatetimeConverter._convert_1d(v, unit, axis) for v in values]
256        else:
257            values = DatetimeConverter._convert_1d(values, unit, axis)
258        return values
259
260    @staticmethod
261    def _convert_1d(values, unit, axis):
262        def try_parse(values):
263            try:
264                return dates.date2num(tools.to_datetime(values))
265            except Exception:
266                return values
267
268        if isinstance(values, (datetime, pydt.date, np.datetime64, pydt.time)):
269            return dates.date2num(values)
270        elif is_integer(values) or is_float(values):
271            return values
272        elif isinstance(values, str):
273            return try_parse(values)
274        elif isinstance(values, (list, tuple, np.ndarray, Index, Series)):
275            if isinstance(values, Series):
276                # https://github.com/matplotlib/matplotlib/issues/11391
277                # Series was skipped. Convert to DatetimeIndex to get asi8
278                values = Index(values)
279            if isinstance(values, Index):
280                values = values.values
281            if not isinstance(values, np.ndarray):
282                values = com.asarray_tuplesafe(values)
283
284            if is_integer_dtype(values) or is_float_dtype(values):
285                return values
286
287            try:
288                values = tools.to_datetime(values)
289            except Exception:
290                pass
291
292            values = dates.date2num(values)
293
294        return values
295
296    @staticmethod
297    def axisinfo(unit: Optional[tzinfo], axis) -> units.AxisInfo:
298        """
299        Return the :class:`~matplotlib.units.AxisInfo` for *unit*.
300
301        *unit* is a tzinfo instance or None.
302        The *axis* argument is required but not used.
303        """
304        tz = unit
305
306        majloc = PandasAutoDateLocator(tz=tz)
307        majfmt = PandasAutoDateFormatter(majloc, tz=tz)
308        datemin = pydt.date(2000, 1, 1)
309        datemax = pydt.date(2010, 1, 1)
310
311        return units.AxisInfo(
312            majloc=majloc, majfmt=majfmt, label="", default_limits=(datemin, datemax)
313        )
314
315
316class PandasAutoDateFormatter(dates.AutoDateFormatter):
317    def __init__(self, locator, tz=None, defaultfmt="%Y-%m-%d"):
318        dates.AutoDateFormatter.__init__(self, locator, tz, defaultfmt)
319
320
321class PandasAutoDateLocator(dates.AutoDateLocator):
322    def get_locator(self, dmin, dmax):
323        """Pick the best locator based on a distance."""
324        delta = relativedelta(dmax, dmin)
325
326        num_days = (delta.years * 12.0 + delta.months) * 31.0 + delta.days
327        num_sec = (delta.hours * 60.0 + delta.minutes) * 60.0 + delta.seconds
328        tot_sec = num_days * 86400.0 + num_sec
329
330        if abs(tot_sec) < self.minticks:
331            self._freq = -1
332            locator = MilliSecondLocator(self.tz)
333            locator.set_axis(self.axis)
334
335            locator.set_view_interval(*self.axis.get_view_interval())
336            locator.set_data_interval(*self.axis.get_data_interval())
337            return locator
338
339        return dates.AutoDateLocator.get_locator(self, dmin, dmax)
340
341    def _get_unit(self):
342        return MilliSecondLocator.get_unit_generic(self._freq)
343
344
345class MilliSecondLocator(dates.DateLocator):
346
347    UNIT = 1.0 / (24 * 3600 * 1000)
348
349    def __init__(self, tz):
350        dates.DateLocator.__init__(self, tz)
351        self._interval = 1.0
352
353    def _get_unit(self):
354        return self.get_unit_generic(-1)
355
356    @staticmethod
357    def get_unit_generic(freq):
358        unit = dates.RRuleLocator.get_unit_generic(freq)
359        if unit < 0:
360            return MilliSecondLocator.UNIT
361        return unit
362
363    def __call__(self):
364        # if no data have been set, this will tank with a ValueError
365        try:
366            dmin, dmax = self.viewlim_to_dt()
367        except ValueError:
368            return []
369
370        # We need to cap at the endpoints of valid datetime
371        nmax, nmin = dates.date2num((dmax, dmin))
372
373        num = (nmax - nmin) * 86400 * 1000
374        max_millis_ticks = 6
375        for interval in [1, 10, 50, 100, 200, 500]:
376            if num <= interval * (max_millis_ticks - 1):
377                self._interval = interval
378                break
379            else:
380                # We went through the whole loop without breaking, default to 1
381                self._interval = 1000.0
382
383        estimate = (nmax - nmin) / (self._get_unit() * self._get_interval())
384
385        if estimate > self.MAXTICKS * 2:
386            raise RuntimeError(
387                "MillisecondLocator estimated to generate "
388                f"{estimate:d} ticks from {dmin} to {dmax}: exceeds Locator.MAXTICKS"
389                f"* 2 ({self.MAXTICKS * 2:d}) "
390            )
391
392        interval = self._get_interval()
393        freq = f"{interval}L"
394        tz = self.tz.tzname(None)
395        st = dmin.replace(tzinfo=None)
396        ed = dmin.replace(tzinfo=None)
397        all_dates = date_range(start=st, end=ed, freq=freq, tz=tz).astype(object)
398
399        try:
400            if len(all_dates) > 0:
401                locs = self.raise_if_exceeds(dates.date2num(all_dates))
402                return locs
403        except Exception:  # pragma: no cover
404            pass
405
406        lims = dates.date2num([dmin, dmax])
407        return lims
408
409    def _get_interval(self):
410        return self._interval
411
412    def autoscale(self):
413        """
414        Set the view limits to include the data range.
415        """
416        # We need to cap at the endpoints of valid datetime
417        dmin, dmax = self.datalim_to_dt()
418
419        vmin = dates.date2num(dmin)
420        vmax = dates.date2num(dmax)
421
422        return self.nonsingular(vmin, vmax)
423
424
425def _from_ordinal(x, tz: Optional[tzinfo] = None) -> datetime:
426    ix = int(x)
427    dt = datetime.fromordinal(ix)
428    remainder = float(x) - ix
429    hour, remainder = divmod(24 * remainder, 1)
430    minute, remainder = divmod(60 * remainder, 1)
431    second, remainder = divmod(60 * remainder, 1)
432    microsecond = int(1e6 * remainder)
433    if microsecond < 10:
434        microsecond = 0  # compensate for rounding errors
435    dt = datetime(
436        dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond
437    )
438    if tz is not None:
439        dt = dt.astimezone(tz)
440
441    if microsecond > 999990:  # compensate for rounding errors
442        dt += timedelta(microseconds=1e6 - microsecond)
443
444    return dt
445
446
447# Fixed frequency dynamic tick locators and formatters
448
449# -------------------------------------------------------------------------
450# --- Locators ---
451# -------------------------------------------------------------------------
452
453
454def _get_default_annual_spacing(nyears) -> Tuple[int, int]:
455    """
456    Returns a default spacing between consecutive ticks for annual data.
457    """
458    if nyears < 11:
459        (min_spacing, maj_spacing) = (1, 1)
460    elif nyears < 20:
461        (min_spacing, maj_spacing) = (1, 2)
462    elif nyears < 50:
463        (min_spacing, maj_spacing) = (1, 5)
464    elif nyears < 100:
465        (min_spacing, maj_spacing) = (5, 10)
466    elif nyears < 200:
467        (min_spacing, maj_spacing) = (5, 25)
468    elif nyears < 600:
469        (min_spacing, maj_spacing) = (10, 50)
470    else:
471        factor = nyears // 1000 + 1
472        (min_spacing, maj_spacing) = (factor * 20, factor * 100)
473    return (min_spacing, maj_spacing)
474
475
476def period_break(dates: PeriodIndex, period: str) -> np.ndarray:
477    """
478    Returns the indices where the given period changes.
479
480    Parameters
481    ----------
482    dates : PeriodIndex
483        Array of intervals to monitor.
484    period : string
485        Name of the period to monitor.
486    """
487    current = getattr(dates, period)
488    previous = getattr(dates - 1 * dates.freq, period)
489    return np.nonzero(current - previous)[0]
490
491
492def has_level_label(label_flags: np.ndarray, vmin: float) -> bool:
493    """
494    Returns true if the ``label_flags`` indicate there is at least one label
495    for this level.
496
497    if the minimum view limit is not an exact integer, then the first tick
498    label won't be shown, so we must adjust for that.
499    """
500    if label_flags.size == 0 or (
501        label_flags.size == 1 and label_flags[0] == 0 and vmin % 1 > 0.0
502    ):
503        return False
504    else:
505        return True
506
507
508def _daily_finder(vmin, vmax, freq: BaseOffset):
509    dtype_code = freq._period_dtype_code
510
511    periodsperday = -1
512
513    if dtype_code >= FreqGroup.FR_HR:
514        if dtype_code == FreqGroup.FR_NS:
515            periodsperday = 24 * 60 * 60 * 1000000000
516        elif dtype_code == FreqGroup.FR_US:
517            periodsperday = 24 * 60 * 60 * 1000000
518        elif dtype_code == FreqGroup.FR_MS:
519            periodsperday = 24 * 60 * 60 * 1000
520        elif dtype_code == FreqGroup.FR_SEC:
521            periodsperday = 24 * 60 * 60
522        elif dtype_code == FreqGroup.FR_MIN:
523            periodsperday = 24 * 60
524        elif dtype_code == FreqGroup.FR_HR:
525            periodsperday = 24
526        else:  # pragma: no cover
527            raise ValueError(f"unexpected frequency: {dtype_code}")
528        periodsperyear = 365 * periodsperday
529        periodspermonth = 28 * periodsperday
530
531    elif dtype_code == FreqGroup.FR_BUS:
532        periodsperyear = 261
533        periodspermonth = 19
534    elif dtype_code == FreqGroup.FR_DAY:
535        periodsperyear = 365
536        periodspermonth = 28
537    elif FreqGroup.get_freq_group(dtype_code) == FreqGroup.FR_WK:
538        periodsperyear = 52
539        periodspermonth = 3
540    else:  # pragma: no cover
541        raise ValueError("unexpected frequency")
542
543    # save this for later usage
544    vmin_orig = vmin
545
546    (vmin, vmax) = (
547        Period(ordinal=int(vmin), freq=freq),
548        Period(ordinal=int(vmax), freq=freq),
549    )
550    span = vmax.ordinal - vmin.ordinal + 1
551    dates_ = period_range(start=vmin, end=vmax, freq=freq)
552    # Initialize the output
553    info = np.zeros(
554        span, dtype=[("val", np.int64), ("maj", bool), ("min", bool), ("fmt", "|S20")]
555    )
556    info["val"][:] = dates_.asi8
557    info["fmt"][:] = ""
558    info["maj"][[0, -1]] = True
559    # .. and set some shortcuts
560    info_maj = info["maj"]
561    info_min = info["min"]
562    info_fmt = info["fmt"]
563
564    def first_label(label_flags):
565        if (label_flags[0] == 0) and (label_flags.size > 1) and ((vmin_orig % 1) > 0.0):
566            return label_flags[1]
567        else:
568            return label_flags[0]
569
570    # Case 1. Less than a month
571    if span <= periodspermonth:
572        day_start = period_break(dates_, "day")
573        month_start = period_break(dates_, "month")
574
575        def _hour_finder(label_interval, force_year_start):
576            _hour = dates_.hour
577            _prev_hour = (dates_ - 1 * dates_.freq).hour
578            hour_start = (_hour - _prev_hour) != 0
579            info_maj[day_start] = True
580            info_min[hour_start & (_hour % label_interval == 0)] = True
581            year_start = period_break(dates_, "year")
582            info_fmt[hour_start & (_hour % label_interval == 0)] = "%H:%M"
583            info_fmt[day_start] = "%H:%M\n%d-%b"
584            info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
585            if force_year_start and not has_level_label(year_start, vmin_orig):
586                info_fmt[first_label(day_start)] = "%H:%M\n%d-%b\n%Y"
587
588        def _minute_finder(label_interval):
589            hour_start = period_break(dates_, "hour")
590            _minute = dates_.minute
591            _prev_minute = (dates_ - 1 * dates_.freq).minute
592            minute_start = (_minute - _prev_minute) != 0
593            info_maj[hour_start] = True
594            info_min[minute_start & (_minute % label_interval == 0)] = True
595            year_start = period_break(dates_, "year")
596            info_fmt = info["fmt"]
597            info_fmt[minute_start & (_minute % label_interval == 0)] = "%H:%M"
598            info_fmt[day_start] = "%H:%M\n%d-%b"
599            info_fmt[year_start] = "%H:%M\n%d-%b\n%Y"
600
601        def _second_finder(label_interval):
602            minute_start = period_break(dates_, "minute")
603            _second = dates_.second
604            _prev_second = (dates_ - 1 * dates_.freq).second
605            second_start = (_second - _prev_second) != 0
606            info["maj"][minute_start] = True
607            info["min"][second_start & (_second % label_interval == 0)] = True
608            year_start = period_break(dates_, "year")
609            info_fmt = info["fmt"]
610            info_fmt[second_start & (_second % label_interval == 0)] = "%H:%M:%S"
611            info_fmt[day_start] = "%H:%M:%S\n%d-%b"
612            info_fmt[year_start] = "%H:%M:%S\n%d-%b\n%Y"
613
614        if span < periodsperday / 12000.0:
615            _second_finder(1)
616        elif span < periodsperday / 6000.0:
617            _second_finder(2)
618        elif span < periodsperday / 2400.0:
619            _second_finder(5)
620        elif span < periodsperday / 1200.0:
621            _second_finder(10)
622        elif span < periodsperday / 800.0:
623            _second_finder(15)
624        elif span < periodsperday / 400.0:
625            _second_finder(30)
626        elif span < periodsperday / 150.0:
627            _minute_finder(1)
628        elif span < periodsperday / 70.0:
629            _minute_finder(2)
630        elif span < periodsperday / 24.0:
631            _minute_finder(5)
632        elif span < periodsperday / 12.0:
633            _minute_finder(15)
634        elif span < periodsperday / 6.0:
635            _minute_finder(30)
636        elif span < periodsperday / 2.5:
637            _hour_finder(1, False)
638        elif span < periodsperday / 1.5:
639            _hour_finder(2, False)
640        elif span < periodsperday * 1.25:
641            _hour_finder(3, False)
642        elif span < periodsperday * 2.5:
643            _hour_finder(6, True)
644        elif span < periodsperday * 4:
645            _hour_finder(12, True)
646        else:
647            info_maj[month_start] = True
648            info_min[day_start] = True
649            year_start = period_break(dates_, "year")
650            info_fmt = info["fmt"]
651            info_fmt[day_start] = "%d"
652            info_fmt[month_start] = "%d\n%b"
653            info_fmt[year_start] = "%d\n%b\n%Y"
654            if not has_level_label(year_start, vmin_orig):
655                if not has_level_label(month_start, vmin_orig):
656                    info_fmt[first_label(day_start)] = "%d\n%b\n%Y"
657                else:
658                    info_fmt[first_label(month_start)] = "%d\n%b\n%Y"
659
660    # Case 2. Less than three months
661    elif span <= periodsperyear // 4:
662        month_start = period_break(dates_, "month")
663        info_maj[month_start] = True
664        if dtype_code < FreqGroup.FR_HR:
665            info["min"] = True
666        else:
667            day_start = period_break(dates_, "day")
668            info["min"][day_start] = True
669        week_start = period_break(dates_, "week")
670        year_start = period_break(dates_, "year")
671        info_fmt[week_start] = "%d"
672        info_fmt[month_start] = "\n\n%b"
673        info_fmt[year_start] = "\n\n%b\n%Y"
674        if not has_level_label(year_start, vmin_orig):
675            if not has_level_label(month_start, vmin_orig):
676                info_fmt[first_label(week_start)] = "\n\n%b\n%Y"
677            else:
678                info_fmt[first_label(month_start)] = "\n\n%b\n%Y"
679    # Case 3. Less than 14 months ...............
680    elif span <= 1.15 * periodsperyear:
681        year_start = period_break(dates_, "year")
682        month_start = period_break(dates_, "month")
683        week_start = period_break(dates_, "week")
684        info_maj[month_start] = True
685        info_min[week_start] = True
686        info_min[year_start] = False
687        info_min[month_start] = False
688        info_fmt[month_start] = "%b"
689        info_fmt[year_start] = "%b\n%Y"
690        if not has_level_label(year_start, vmin_orig):
691            info_fmt[first_label(month_start)] = "%b\n%Y"
692    # Case 4. Less than 2.5 years ...............
693    elif span <= 2.5 * periodsperyear:
694        year_start = period_break(dates_, "year")
695        quarter_start = period_break(dates_, "quarter")
696        month_start = period_break(dates_, "month")
697        info_maj[quarter_start] = True
698        info_min[month_start] = True
699        info_fmt[quarter_start] = "%b"
700        info_fmt[year_start] = "%b\n%Y"
701    # Case 4. Less than 4 years .................
702    elif span <= 4 * periodsperyear:
703        year_start = period_break(dates_, "year")
704        month_start = period_break(dates_, "month")
705        info_maj[year_start] = True
706        info_min[month_start] = True
707        info_min[year_start] = False
708
709        month_break = dates_[month_start].month
710        jan_or_jul = month_start[(month_break == 1) | (month_break == 7)]
711        info_fmt[jan_or_jul] = "%b"
712        info_fmt[year_start] = "%b\n%Y"
713    # Case 5. Less than 11 years ................
714    elif span <= 11 * periodsperyear:
715        year_start = period_break(dates_, "year")
716        quarter_start = period_break(dates_, "quarter")
717        info_maj[year_start] = True
718        info_min[quarter_start] = True
719        info_min[year_start] = False
720        info_fmt[year_start] = "%Y"
721    # Case 6. More than 12 years ................
722    else:
723        year_start = period_break(dates_, "year")
724        year_break = dates_[year_start].year
725        nyears = span / periodsperyear
726        (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
727        major_idx = year_start[(year_break % maj_anndef == 0)]
728        info_maj[major_idx] = True
729        minor_idx = year_start[(year_break % min_anndef == 0)]
730        info_min[minor_idx] = True
731        info_fmt[major_idx] = "%Y"
732
733    return info
734
735
736def _monthly_finder(vmin, vmax, freq):
737    periodsperyear = 12
738
739    vmin_orig = vmin
740    (vmin, vmax) = (int(vmin), int(vmax))
741    span = vmax - vmin + 1
742
743    # Initialize the output
744    info = np.zeros(
745        span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
746    )
747    info["val"] = np.arange(vmin, vmax + 1)
748    dates_ = info["val"]
749    info["fmt"] = ""
750    year_start = (dates_ % 12 == 0).nonzero()[0]
751    info_maj = info["maj"]
752    info_fmt = info["fmt"]
753
754    if span <= 1.15 * periodsperyear:
755        info_maj[year_start] = True
756        info["min"] = True
757
758        info_fmt[:] = "%b"
759        info_fmt[year_start] = "%b\n%Y"
760
761        if not has_level_label(year_start, vmin_orig):
762            if dates_.size > 1:
763                idx = 1
764            else:
765                idx = 0
766            info_fmt[idx] = "%b\n%Y"
767
768    elif span <= 2.5 * periodsperyear:
769        quarter_start = (dates_ % 3 == 0).nonzero()
770        info_maj[year_start] = True
771        # TODO: Check the following : is it really info['fmt'] ?
772        info["fmt"][quarter_start] = True
773        info["min"] = True
774
775        info_fmt[quarter_start] = "%b"
776        info_fmt[year_start] = "%b\n%Y"
777
778    elif span <= 4 * periodsperyear:
779        info_maj[year_start] = True
780        info["min"] = True
781
782        jan_or_jul = (dates_ % 12 == 0) | (dates_ % 12 == 6)
783        info_fmt[jan_or_jul] = "%b"
784        info_fmt[year_start] = "%b\n%Y"
785
786    elif span <= 11 * periodsperyear:
787        quarter_start = (dates_ % 3 == 0).nonzero()
788        info_maj[year_start] = True
789        info["min"][quarter_start] = True
790
791        info_fmt[year_start] = "%Y"
792
793    else:
794        nyears = span / periodsperyear
795        (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
796        years = dates_[year_start] // 12 + 1
797        major_idx = year_start[(years % maj_anndef == 0)]
798        info_maj[major_idx] = True
799        info["min"][year_start[(years % min_anndef == 0)]] = True
800
801        info_fmt[major_idx] = "%Y"
802
803    return info
804
805
806def _quarterly_finder(vmin, vmax, freq):
807    periodsperyear = 4
808    vmin_orig = vmin
809    (vmin, vmax) = (int(vmin), int(vmax))
810    span = vmax - vmin + 1
811
812    info = np.zeros(
813        span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
814    )
815    info["val"] = np.arange(vmin, vmax + 1)
816    info["fmt"] = ""
817    dates_ = info["val"]
818    info_maj = info["maj"]
819    info_fmt = info["fmt"]
820    year_start = (dates_ % 4 == 0).nonzero()[0]
821
822    if span <= 3.5 * periodsperyear:
823        info_maj[year_start] = True
824        info["min"] = True
825
826        info_fmt[:] = "Q%q"
827        info_fmt[year_start] = "Q%q\n%F"
828        if not has_level_label(year_start, vmin_orig):
829            if dates_.size > 1:
830                idx = 1
831            else:
832                idx = 0
833            info_fmt[idx] = "Q%q\n%F"
834
835    elif span <= 11 * periodsperyear:
836        info_maj[year_start] = True
837        info["min"] = True
838        info_fmt[year_start] = "%F"
839
840    else:
841        years = dates_[year_start] // 4 + 1
842        nyears = span / periodsperyear
843        (min_anndef, maj_anndef) = _get_default_annual_spacing(nyears)
844        major_idx = year_start[(years % maj_anndef == 0)]
845        info_maj[major_idx] = True
846        info["min"][year_start[(years % min_anndef == 0)]] = True
847        info_fmt[major_idx] = "%F"
848
849    return info
850
851
852def _annual_finder(vmin, vmax, freq):
853    (vmin, vmax) = (int(vmin), int(vmax + 1))
854    span = vmax - vmin + 1
855
856    info = np.zeros(
857        span, dtype=[("val", int), ("maj", bool), ("min", bool), ("fmt", "|S8")]
858    )
859    info["val"] = np.arange(vmin, vmax + 1)
860    info["fmt"] = ""
861    dates_ = info["val"]
862
863    (min_anndef, maj_anndef) = _get_default_annual_spacing(span)
864    major_idx = dates_ % maj_anndef == 0
865    info["maj"][major_idx] = True
866    info["min"][(dates_ % min_anndef == 0)] = True
867    info["fmt"][major_idx] = "%Y"
868
869    return info
870
871
872def get_finder(freq: BaseOffset):
873    dtype_code = freq._period_dtype_code
874    fgroup = (dtype_code // 1000) * 1000
875
876    if fgroup == FreqGroup.FR_ANN:
877        return _annual_finder
878    elif fgroup == FreqGroup.FR_QTR:
879        return _quarterly_finder
880    elif dtype_code == FreqGroup.FR_MTH:
881        return _monthly_finder
882    elif (dtype_code >= FreqGroup.FR_BUS) or fgroup == FreqGroup.FR_WK:
883        return _daily_finder
884    else:  # pragma: no cover
885        raise NotImplementedError(f"Unsupported frequency: {dtype_code}")
886
887
888class TimeSeries_DateLocator(Locator):
889    """
890    Locates the ticks along an axis controlled by a :class:`Series`.
891
892    Parameters
893    ----------
894    freq : {var}
895        Valid frequency specifier.
896    minor_locator : {False, True}, optional
897        Whether the locator is for minor ticks (True) or not.
898    dynamic_mode : {True, False}, optional
899        Whether the locator should work in dynamic mode.
900    base : {int}, optional
901    quarter : {int}, optional
902    month : {int}, optional
903    day : {int}, optional
904    """
905
906    def __init__(
907        self,
908        freq,
909        minor_locator=False,
910        dynamic_mode=True,
911        base=1,
912        quarter=1,
913        month=1,
914        day=1,
915        plot_obj=None,
916    ):
917        freq = to_offset(freq)
918        self.freq = freq
919        self.base = base
920        (self.quarter, self.month, self.day) = (quarter, month, day)
921        self.isminor = minor_locator
922        self.isdynamic = dynamic_mode
923        self.offset = 0
924        self.plot_obj = plot_obj
925        self.finder = get_finder(freq)
926
927    def _get_default_locs(self, vmin, vmax):
928        """Returns the default locations of ticks."""
929        if self.plot_obj.date_axis_info is None:
930            self.plot_obj.date_axis_info = self.finder(vmin, vmax, self.freq)
931
932        locator = self.plot_obj.date_axis_info
933
934        if self.isminor:
935            return np.compress(locator["min"], locator["val"])
936        return np.compress(locator["maj"], locator["val"])
937
938    def __call__(self):
939        """Return the locations of the ticks."""
940        # axis calls Locator.set_axis inside set_m<xxxx>_formatter
941
942        vi = tuple(self.axis.get_view_interval())
943        if vi != self.plot_obj.view_interval:
944            self.plot_obj.date_axis_info = None
945        self.plot_obj.view_interval = vi
946        vmin, vmax = vi
947        if vmax < vmin:
948            vmin, vmax = vmax, vmin
949        if self.isdynamic:
950            locs = self._get_default_locs(vmin, vmax)
951        else:  # pragma: no cover
952            base = self.base
953            (d, m) = divmod(vmin, base)
954            vmin = (d + 1) * base
955            locs = list(range(vmin, vmax + 1, base))
956        return locs
957
958    def autoscale(self):
959        """
960        Sets the view limits to the nearest multiples of base that contain the
961        data.
962        """
963        # requires matplotlib >= 0.98.0
964        (vmin, vmax) = self.axis.get_data_interval()
965
966        locs = self._get_default_locs(vmin, vmax)
967        (vmin, vmax) = locs[[0, -1]]
968        if vmin == vmax:
969            vmin -= 1
970            vmax += 1
971        return nonsingular(vmin, vmax)
972
973
974# -------------------------------------------------------------------------
975# --- Formatter ---
976# -------------------------------------------------------------------------
977
978
979class TimeSeries_DateFormatter(Formatter):
980    """
981    Formats the ticks along an axis controlled by a :class:`PeriodIndex`.
982
983    Parameters
984    ----------
985    freq : {int, string}
986        Valid frequency specifier.
987    minor_locator : bool, default False
988        Whether the current formatter should apply to minor ticks (True) or
989        major ticks (False).
990    dynamic_mode : bool, default True
991        Whether the formatter works in dynamic mode or not.
992    """
993
994    def __init__(
995        self,
996        freq,
997        minor_locator: bool = False,
998        dynamic_mode: bool = True,
999        plot_obj=None,
1000    ):
1001        freq = to_offset(freq)
1002        self.format = None
1003        self.freq = freq
1004        self.locs: List[Any] = []  # unused, for matplotlib compat
1005        self.formatdict: Optional[Dict[Any, Any]] = None
1006        self.isminor = minor_locator
1007        self.isdynamic = dynamic_mode
1008        self.offset = 0
1009        self.plot_obj = plot_obj
1010        self.finder = get_finder(freq)
1011
1012    def _set_default_format(self, vmin, vmax):
1013        """Returns the default ticks spacing."""
1014        if self.plot_obj.date_axis_info is None:
1015            self.plot_obj.date_axis_info = self.finder(vmin, vmax, self.freq)
1016        info = self.plot_obj.date_axis_info
1017
1018        if self.isminor:
1019            format = np.compress(info["min"] & np.logical_not(info["maj"]), info)
1020        else:
1021            format = np.compress(info["maj"], info)
1022        self.formatdict = {x: f for (x, _, _, f) in format}
1023        return self.formatdict
1024
1025    def set_locs(self, locs):
1026        """Sets the locations of the ticks"""
1027        # don't actually use the locs. This is just needed to work with
1028        # matplotlib. Force to use vmin, vmax
1029
1030        self.locs = locs
1031
1032        (vmin, vmax) = vi = tuple(self.axis.get_view_interval())
1033        if vi != self.plot_obj.view_interval:
1034            self.plot_obj.date_axis_info = None
1035        self.plot_obj.view_interval = vi
1036        if vmax < vmin:
1037            (vmin, vmax) = (vmax, vmin)
1038        self._set_default_format(vmin, vmax)
1039
1040    def __call__(self, x, pos=0) -> str:
1041
1042        if self.formatdict is None:
1043            return ""
1044        else:
1045            fmt = self.formatdict.pop(x, "")
1046            if isinstance(fmt, np.bytes_):
1047                fmt = fmt.decode("utf-8")
1048            return Period(ordinal=int(x), freq=self.freq).strftime(fmt)
1049
1050
1051class TimeSeries_TimedeltaFormatter(Formatter):
1052    """
1053    Formats the ticks along an axis controlled by a :class:`TimedeltaIndex`.
1054    """
1055
1056    @staticmethod
1057    def format_timedelta_ticks(x, pos, n_decimals: int) -> str:
1058        """
1059        Convert seconds to 'D days HH:MM:SS.F'
1060        """
1061        s, ns = divmod(x, 1e9)
1062        m, s = divmod(s, 60)
1063        h, m = divmod(m, 60)
1064        d, h = divmod(h, 24)
1065        decimals = int(ns * 10 ** (n_decimals - 9))
1066        s = f"{int(h):02d}:{int(m):02d}:{int(s):02d}"
1067        if n_decimals > 0:
1068            s += f".{decimals:0{n_decimals}d}"
1069        if d != 0:
1070            s = f"{int(d):d} days {s}"
1071        return s
1072
1073    def __call__(self, x, pos=0) -> str:
1074        (vmin, vmax) = tuple(self.axis.get_view_interval())
1075        n_decimals = int(np.ceil(np.log10(100 * 1e9 / abs(vmax - vmin))))
1076        if n_decimals > 9:
1077            n_decimals = 9
1078        return self.format_timedelta_ticks(x, pos, n_decimals)
1079