Skip to content

Commit 920f996

Browse files
BUG: Handle overlapping line and scatter on the same plot (#61244)
* Refactor time series plotting logic for improved clarity Extract and streamline time series preparation steps into `prepare_ts_data`, replacing redundant logic across methods. Simplifies axis frequency handling and improves code readability while maintaining functionality. * Add test to validate xtick alignment for scatter and line plots This test ensures that the x-axis ticks are consistent between scatter and line plots when sharing the same axis. It addresses a potential issue related to GH#61005, verifying proper rendering of datetime x-axis labels. * Fix bug in Series.plot misalignment for line and scatter plots This resolves an issue where line and scatter plots were not aligned when using Series.plot. The fix ensures proper alignment and improves plot consistency. Refer to issue #61005 for further details. * Update scatter plot test to support datetime.time data Datetime.time is now supported in scatter plots due to added converter implementation in ScatterPlot. Removed the test expecting a TypeError and updated it to validate the new functionality. * Refactor handling of x_data in matplotlib plotting. Simplify and streamline the code by directly assigning x_data from the data variable and replacing the intermediate Series object with a clearer `s` variable. This improves readability and maintains the existing functionality. * Move test_scatter_line_xticks from Series to DataFrame tests Relocated the `test_scatter_line_xticks` test from `test_series.py` to `test_frame.py` for better alignment with DataFrame-specific functionality. This refactor ensures the test resides in the appropriate context based on its usage and focus. * Refactor `prepare_ts_data` to improve type annotations. Added precise type annotations to the function signature for better clarity and type checking. Replaced `data` with `series` and `kwds` with `kwargs` to enhance readability and consistency. * Refactor test_scatter_line_xticks to simplify DataFrame creation The DataFrame creation in the test has been streamlined for clarity and conciseness by replacing the loop with a list comprehension. This improves code readability and maintains the same functionality. * Refactor Series import to optimize scope and maintain consistency Moved the `Series` import inside relevant function scopes to minimize unnecessary top-level imports and align with existing import patterns. This helps improve code readability and ensures imports are only loaded where needed. * `Reorder import statement in _make_plot method` Moved the import of `Series` within the `_make_plot` method to comply with styling or runtime considerations. This ensures consistency and avoids potential import-related issues.
1 parent 214e474 commit 920f996

File tree

4 files changed

+69
-39
lines changed

4 files changed

+69
-39
lines changed

doc/source/whatsnew/v3.0.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,7 @@ Plotting
763763
- 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`)
764764
- Bug in :meth:`DataFrame.plot.line` raising ``ValueError`` when set both color and a ``dict`` style (:issue:`59461`)
765765
- Bug in :meth:`DataFrame.plot` that causes a shift to the right when the frequency multiplier is greater than one. (:issue:`57587`)
766+
- Bug in :meth:`Series.plot` preventing a line and scatter plot from being aligned (:issue:`61005`)
766767
- Bug in :meth:`Series.plot` with ``kind="pie"`` with :class:`ArrowDtype` (:issue:`59192`)
767768

768769
Groupby/resample/rolling

pandas/plotting/_matplotlib/core.py

+29-26
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,9 @@
6464
from pandas.plotting._matplotlib.misc import unpack_single_str_list
6565
from pandas.plotting._matplotlib.style import get_standard_colors
6666
from pandas.plotting._matplotlib.timeseries import (
67-
decorate_axes,
6867
format_dateaxis,
6968
maybe_convert_index,
70-
maybe_resample,
69+
prepare_ts_data,
7170
use_dynamic_x,
7271
)
7372
from pandas.plotting._matplotlib.tools import (
@@ -288,6 +287,21 @@ def __init__(
288287

289288
self.data = self._ensure_frame(self.data)
290289

290+
from pandas.plotting import plot_params
291+
292+
self.x_compat = plot_params["x_compat"]
293+
if "x_compat" in self.kwds:
294+
self.x_compat = bool(self.kwds.pop("x_compat"))
295+
296+
@final
297+
def _is_ts_plot(self) -> bool:
298+
# this is slightly deceptive
299+
return not self.x_compat and self.use_index and self._use_dynamic_x()
300+
301+
@final
302+
def _use_dynamic_x(self) -> bool:
303+
return use_dynamic_x(self._get_ax(0), self.data.index)
304+
291305
@final
292306
@staticmethod
293307
def _validate_sharex(sharex: bool | None, ax, by) -> bool:
@@ -1324,10 +1338,20 @@ def __init__(
13241338
c = self.data.columns[c]
13251339
self.c = c
13261340

1341+
@register_pandas_matplotlib_converters
13271342
def _make_plot(self, fig: Figure) -> None:
13281343
x, y, c, data = self.x, self.y, self.c, self.data
13291344
ax = self.axes[0]
13301345

1346+
from pandas import Series
1347+
1348+
x_data = data[x]
1349+
s = Series(index=x_data)
1350+
if use_dynamic_x(ax, s.index):
1351+
s = maybe_convert_index(ax, s)
1352+
freq, s = prepare_ts_data(s, ax, self.kwds)
1353+
x_data = s.index
1354+
13311355
c_is_column = is_hashable(c) and c in self.data.columns
13321356

13331357
color_by_categorical = c_is_column and isinstance(
@@ -1344,7 +1368,7 @@ def _make_plot(self, fig: Figure) -> None:
13441368
else:
13451369
label = None
13461370

1347-
# if a list of non color strings is passed in as c, color points
1371+
# if a list of non-color strings is passed in as c, color points
13481372
# by uniqueness of the strings, such same strings get same color
13491373
create_colors = not self._are_valid_colors(c_values)
13501374
if create_colors:
@@ -1360,7 +1384,7 @@ def _make_plot(self, fig: Figure) -> None:
13601384
)
13611385

13621386
scatter = ax.scatter(
1363-
data[x].values,
1387+
x_data.values,
13641388
data[y].values,
13651389
c=c_values,
13661390
label=label,
@@ -1520,23 +1544,9 @@ def _kind(self) -> Literal["line", "area", "hist", "kde", "box"]:
15201544
return "line"
15211545

15221546
def __init__(self, data, **kwargs) -> None:
1523-
from pandas.plotting import plot_params
1524-
15251547
MPLPlot.__init__(self, data, **kwargs)
15261548
if self.stacked:
15271549
self.data = self.data.fillna(value=0)
1528-
self.x_compat = plot_params["x_compat"]
1529-
if "x_compat" in self.kwds:
1530-
self.x_compat = bool(self.kwds.pop("x_compat"))
1531-
1532-
@final
1533-
def _is_ts_plot(self) -> bool:
1534-
# this is slightly deceptive
1535-
return not self.x_compat and self.use_index and self._use_dynamic_x()
1536-
1537-
@final
1538-
def _use_dynamic_x(self) -> bool:
1539-
return use_dynamic_x(self._get_ax(0), self.data)
15401550

15411551
def _make_plot(self, fig: Figure) -> None:
15421552
if self._is_ts_plot():
@@ -1626,15 +1636,8 @@ def _ts_plot(self, ax: Axes, x, data: Series, style=None, **kwds):
16261636
# accept x to be consistent with normal plot func,
16271637
# x is not passed to tsplot as it uses data.index as x coordinate
16281638
# column_num must be in kwds for stacking purpose
1629-
freq, data = maybe_resample(data, ax, kwds)
1639+
freq, data = prepare_ts_data(data, ax, kwds)
16301640

1631-
# Set ax with freq info
1632-
decorate_axes(ax, freq)
1633-
# digging deeper
1634-
if hasattr(ax, "left_ax"):
1635-
decorate_axes(ax.left_ax, freq)
1636-
if hasattr(ax, "right_ax"):
1637-
decorate_axes(ax.right_ax, freq)
16381641
# TODO #54485
16391642
ax._plot_data.append((data, self._kind, kwds)) # type: ignore[attr-defined]
16401643

pandas/plotting/_matplotlib/timeseries.py

+22-8
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
from pandas._typing import NDFrameT
4949

5050
from pandas import (
51-
DataFrame,
5251
DatetimeIndex,
5352
Index,
5453
PeriodIndex,
@@ -231,8 +230,8 @@ def _get_freq(ax: Axes, series: Series):
231230
return freq, ax_freq
232231

233232

234-
def use_dynamic_x(ax: Axes, data: DataFrame | Series) -> bool:
235-
freq = _get_index_freq(data.index)
233+
def use_dynamic_x(ax: Axes, index: Index) -> bool:
234+
freq = _get_index_freq(index)
236235
ax_freq = _get_ax_freq(ax)
237236

238237
if freq is None: # convert irregular if axes has freq info
@@ -250,16 +249,15 @@ def use_dynamic_x(ax: Axes, data: DataFrame | Series) -> bool:
250249
return False
251250

252251
# FIXME: hack this for 0.10.1, creating more technical debt...sigh
253-
if isinstance(data.index, ABCDatetimeIndex):
252+
if isinstance(index, ABCDatetimeIndex):
254253
# error: "BaseOffset" has no attribute "_period_dtype_code"
255254
freq_str = OFFSET_TO_PERIOD_FREQSTR.get(freq_str, freq_str)
256255
base = to_offset(freq_str, is_period=True)._period_dtype_code # type: ignore[attr-defined]
257-
x = data.index
258256
if base <= FreqGroup.FR_DAY.value:
259-
return x[:1].is_normalized
260-
period = Period(x[0], freq_str)
257+
return index[:1].is_normalized
258+
period = Period(index[0], freq_str)
261259
assert isinstance(period, Period)
262-
return period.to_timestamp().tz_localize(x.tz) == x[0]
260+
return period.to_timestamp().tz_localize(index.tz) == index[0]
263261
return True
264262

265263

@@ -366,3 +364,19 @@ def format_dateaxis(
366364
raise TypeError("index type not supported")
367365

368366
plt.draw_if_interactive()
367+
368+
369+
def prepare_ts_data(
370+
series: Series, ax: Axes, kwargs: dict[str, Any]
371+
) -> tuple[BaseOffset | str, Series]:
372+
freq, data = maybe_resample(series, ax, kwargs)
373+
374+
# Set ax with freq info
375+
decorate_axes(ax, freq)
376+
# digging deeper
377+
if hasattr(ax, "left_ax"):
378+
decorate_axes(ax.left_ax, freq)
379+
if hasattr(ax, "right_ax"):
380+
decorate_axes(ax.right_ax, freq)
381+
382+
return freq, data

pandas/tests/plotting/frame/test_frame.py

+17-5
Original file line numberDiff line numberDiff line change
@@ -840,14 +840,26 @@ def test_plot_scatter_shape(self):
840840
axes = df.plot(x="x", y="y", kind="scatter", subplots=True)
841841
_check_axes_shape(axes, axes_num=1, layout=(1, 1))
842842

843-
def test_raise_error_on_datetime_time_data(self):
844-
# GH 8113, datetime.time type is not supported by matplotlib in scatter
843+
def test_scatter_on_datetime_time_data(self):
844+
# datetime.time type is now supported in scatter, since a converter
845+
# is implemented in ScatterPlot
845846
df = DataFrame(np.random.default_rng(2).standard_normal(10), columns=["a"])
846847
df["dtime"] = date_range(start="2014-01-01", freq="h", periods=10).time
847-
msg = "must be a string or a (real )?number, not 'datetime.time'"
848+
df.plot(kind="scatter", x="dtime", y="a")
848849

849-
with pytest.raises(TypeError, match=msg):
850-
df.plot(kind="scatter", x="dtime", y="a")
850+
def test_scatter_line_xticks(self):
851+
# GH#61005
852+
df = DataFrame(
853+
[(datetime(year=2025, month=1, day=1, hour=n), n) for n in range(3)],
854+
columns=["datetime", "y"],
855+
)
856+
fig, ax = plt.subplots(2, sharex=True)
857+
df.plot.scatter(x="datetime", y="y", ax=ax[0])
858+
scatter_xticks = ax[0].get_xticks()
859+
df.plot(x="datetime", y="y", ax=ax[1])
860+
line_xticks = ax[1].get_xticks()
861+
assert scatter_xticks[0] == line_xticks[0]
862+
assert scatter_xticks[-1] == line_xticks[-1]
851863

852864
@pytest.mark.parametrize("x, y", [("dates", "vals"), (0, 1)])
853865
def test_scatterplot_datetime_data(self, x, y):

0 commit comments

Comments
 (0)