diff --git a/CHANGELOG.md b/CHANGELOG.md index d0986ae579..5799590431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix `mpl_to_plotly` tick marker mirroring, axis positioning, and trace visibility for twinned and overlaid subplots (such as `twinx` and `twiny`) [[#5310](https://github.com/plotly/plotly.py/pull/5310)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` losing matplotlib axis line colors by exporting the color of each axis's displayed spine to the plotly x and y axis `linecolor` [[#5311](https://github.com/plotly/plotly.py/pull/5311)], with thanks to @robertoffmoura for the contribution! +- Fix `px.timeline` bars ending an hour early or late when timezone-aware dates span a daylight saving time change [[#4611](https://github.com/plotly/plotly.py/issues/4611)] - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#5691](https://github.com/plotly/plotly.py/pull/5691)], with thanks to @hb1915 for the contribution! - Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution! - Fix `mpl_to_plotly` drawing step plots with diagonal lines instead of vertical/horizontal step segments by mapping matplotlib step drawstyles (`steps-pre`, `steps-post`, `steps-mid`) to plotly line shapes [[#5697](https://github.com/plotly/plotly.py/pull/5697)], with thanks to @robertoffmoura for the contribution! diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 9994720b09..104991f3a0 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2194,11 +2194,18 @@ def process_dataframe_timeline(args): "Both x_start and x_end must refer to data convertible to datetimes." ) from exc + # Match the local times displayed on date axes when calculating bar lengths. + schema = df.schema + x_start, x_end = [ + nw.col(col).dt.replace_time_zone(None) + if schema[col] == nw.Datetime and schema[col].time_zone is not None + else nw.col(col) + for col in (args["x_start"], args["x_end"]) + ] + # note that we are not adding any columns to the data frame here, so no risk of overwrite args["data_frame"] = df.with_columns( - (nw.col(args["x_end"]) - nw.col(args["x_start"])) - .dt.total_milliseconds() - .alias(args["x_end"]) + (x_end - x_start).dt.total_milliseconds().alias(args["x_end"]) ) args["x"] = args["x_end"] args["base"] = args["x_start"] diff --git a/tests/test_optional/test_px/test_px_functions.py b/tests/test_optional/test_px/test_px_functions.py index 84a451733b..2ce41c4550 100644 --- a/tests/test_optional/test_px/test_px_functions.py +++ b/tests/test_optional/test_px/test_px_functions.py @@ -680,6 +680,32 @@ def test_timeline_cols_already_temporal(constructor, datetime_columns): assert fig.layout.xaxis.title.text is None +@pytest.mark.parametrize("time_zone", [None, "UTC", "US/Pacific"]) +def test_timeline_daylight_saving(constructor, time_zone): + # Regression for https://github.com/plotly/plotly.py/issues/4611 + starts = ["2024-03-09", "2024-03-10", "2024-03-11", "2023-11-05"] + finishes = ["2024-03-10", "2024-03-11", "2024-03-12", "2023-11-06"] + df = nw.from_native( + constructor({"Start": starts, "Finish": finishes, "Task": ["Job A"] * 4}) + ).with_columns( + nw.col("Start", "Finish") + .str.to_datetime(format="%Y-%m-%d") + .dt.replace_time_zone(time_zone) + ) + + fig = px.timeline(df.to_native(), x_start="Start", x_end="Finish", y="Task") + + # Date axes use local wall-clock coordinates: consecutive midnights are + # 24 hours apart even when the elapsed time is 23 or 25 hours. + assert_array_equal(fig.data[0].x, [24 * 60 * 60 * 1000] * 4) + base = np.asarray(fig.data[0].base, dtype="datetime64[ms]") + assert_array_equal(base, np.array(starts, dtype="datetime64[ms]")) + assert_array_equal( + base + fig.data[0].x.astype("timedelta64[ms]"), + np.array(finishes, dtype="datetime64[ms]"), + ) + + def test_empty_histogram(): """Empty px.histogram() should not raise, matching scatter/bar/pie behavior.