Skip to content
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

BUG: Handle overlapping line and bar on the same plot #61173

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions doc/source/whatsnew/v3.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@ Plotting
- Bug in :meth:`DataFrame.plot.bar` with ``stacked=True`` where labels on stacked bars with zero-height segments were incorrectly positioned at the base instead of the label position of the previous segment (:issue:`59429`)
- Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`)
- Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`)
- Bug in :meth:`Series.plot` preventing a line and bar from being aligned on the same plot (:issue:`61161`)
- Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`)

Groupby/resample/rolling
Expand Down
24 changes: 14 additions & 10 deletions pandas/plotting/_matplotlib/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,20 @@ def __call__(self, x, pos: int | None = 0) -> str:
class PeriodConverter(mdates.DateConverter):
@staticmethod
def convert(values, units, axis):
if not hasattr(axis, "freq"):
raise TypeError("Axis must have `freq` set to convert to Periods")
return PeriodConverter.convert_from_freq(values, axis.freq)

@staticmethod
def convert_from_freq(values, freq):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

convert only uses the freq attribute of axis, so one should allow the user to pass freq without axis object.

if is_nested_list_like(values):
values = [PeriodConverter._convert_1d(v, units, axis) for v in values]
values = [PeriodConverter._convert_1d(v, freq) for v in values]
else:
values = PeriodConverter._convert_1d(values, units, axis)
values = PeriodConverter._convert_1d(values, freq)
return values

@staticmethod
def _convert_1d(values, units, axis):
if not hasattr(axis, "freq"):
raise TypeError("Axis must have `freq` set to convert to Periods")
def _convert_1d(values, freq):
valid_types = (str, datetime, Period, pydt.date, pydt.time, np.datetime64)
with warnings.catch_warnings():
warnings.filterwarnings(
Expand All @@ -248,17 +252,17 @@ def _convert_1d(values, units, axis):
or is_integer(values)
or is_float(values)
):
return get_datevalue(values, axis.freq)
return get_datevalue(values, freq)
elif isinstance(values, PeriodIndex):
return values.asfreq(axis.freq).asi8
return values.asfreq(freq).asi8
elif isinstance(values, Index):
return values.map(lambda x: get_datevalue(x, axis.freq))
return values.map(lambda x: get_datevalue(x, freq))
elif lib.infer_dtype(values, skipna=False) == "period":
# https://github.com/pandas-dev/pandas/issues/24304
# convert ndarray[period] -> PeriodIndex
return PeriodIndex(values, freq=axis.freq).asi8
return PeriodIndex(values, freq=freq).asi8
elif isinstance(values, (list, tuple, np.ndarray, Index)):
return [get_datevalue(x, axis.freq) for x in values]
return [get_datevalue(x, freq) for x in values]
return values


Expand Down
46 changes: 30 additions & 16 deletions pandas/plotting/_matplotlib/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@

from pandas.io.formats.printing import pprint_thing
from pandas.plotting._matplotlib import tools
from pandas.plotting._matplotlib.converter import register_pandas_matplotlib_converters
from pandas.plotting._matplotlib.converter import (
PeriodConverter,
register_pandas_matplotlib_converters,
)
from pandas.plotting._matplotlib.groupby import reconstruct_data_with_by
from pandas.plotting._matplotlib.misc import unpack_single_str_list
from pandas.plotting._matplotlib.style import get_standard_colors
Expand Down Expand Up @@ -288,6 +291,21 @@ def __init__(

self.data = self._ensure_frame(self.data)

from pandas.plotting import plot_params

self.x_compat = plot_params["x_compat"]
if "x_compat" in self.kwds:
self.x_compat = bool(self.kwds.pop("x_compat"))

@final
def _is_ts_plot(self) -> bool:
# this is slightly deceptive
return not self.x_compat and self.use_index and self._use_dynamic_x()

@final
def _use_dynamic_x(self) -> bool:
return use_dynamic_x(self._get_ax(0), self.data)

Comment on lines +294 to +308
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Those routines are not exclusive to LinePlot, as they seem related to any type of time series. _is_ts_plot is used in this PR to check if a boxplot is a time series.

@final
@staticmethod
def _validate_sharex(sharex: bool | None, ax, by) -> bool:
Expand Down Expand Up @@ -1520,23 +1538,9 @@ def _kind(self) -> Literal["line", "area", "hist", "kde", "box"]:
return "line"

def __init__(self, data, **kwargs) -> None:
from pandas.plotting import plot_params

MPLPlot.__init__(self, data, **kwargs)
if self.stacked:
self.data = self.data.fillna(value=0)
self.x_compat = plot_params["x_compat"]
if "x_compat" in self.kwds:
self.x_compat = bool(self.kwds.pop("x_compat"))

@final
def _is_ts_plot(self) -> bool:
# this is slightly deceptive
return not self.x_compat and self.use_index and self._use_dynamic_x()

@final
def _use_dynamic_x(self) -> bool:
return use_dynamic_x(self._get_ax(0), self.data)

def _make_plot(self, fig: Figure) -> None:
if self._is_ts_plot():
Expand Down Expand Up @@ -1855,7 +1859,6 @@ def __init__(
self.bar_width = width
self._align = align
self._position = position
self.tick_pos = np.arange(len(data))

if is_list_like(bottom):
bottom = np.array(bottom)
Expand All @@ -1868,6 +1871,16 @@ def __init__(

MPLPlot.__init__(self, data, **kwargs)

if self._is_ts_plot():
self.tick_pos = np.array(
PeriodConverter.convert_from_freq(
self._get_xticks(),
data.index.freq,
)
)
else:
self.tick_pos = np.arange(len(data))

@cache_readonly
def ax_pos(self) -> np.ndarray:
return self.tick_pos - self.tickoffset
Expand Down Expand Up @@ -1897,6 +1910,7 @@ def lim_offset(self):

# error: Signature of "_plot" incompatible with supertype "MPLPlot"
@classmethod
@register_pandas_matplotlib_converters
def _plot( # type: ignore[override]
cls,
ax: Axes,
Expand Down
14 changes: 14 additions & 0 deletions pandas/tests/plotting/test_series.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,17 @@ def test_secondary_y_subplot_axis_labels(self):
s1.plot(ax=ax2)
assert len(ax.xaxis.get_minor_ticks()) == 0
assert len(ax.get_xticklabels()) > 0

def test_bar_line_plot(self):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fails on main

"""
Test that bar and line plots with the same x values are superposed
"""
# GH61161
index = period_range("2023", periods=3, freq="Y")
s = Series([1, 2, 3], index=index)
ax = plt.subplot()
s.plot(kind="bar", ax=ax)
bar_xticks = ax.get_xticks().tolist()
s.plot(kind="line", ax=ax, color="r")
line_xticks = ax.get_xticks()[: len(s)].tolist()
assert line_xticks == bar_xticks