Skip to content

Commit 7efa6dd

Browse files
Fix timeline bar lengths across daylight saving changes
I calculate timeline bar lengths in the same local wall-clock coordinates used by date axes, so timezone-aware endpoints remain aligned across daylight saving changes. I leave the original start column unchanged and cover spring, autumn, UTC, and naive timestamps across dataframe backends. Fixes #4611 Signed-off-by: mottopanikeiku <176798723+mottopanikeiku@users.noreply.github.com>
1 parent fcc3c8e commit 7efa6dd

3 files changed

Lines changed: 37 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1010
### Fixed
1111
- 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!
1212
- 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!
13+
- 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)]
1314
- 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!
1415
- 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!
1516
- 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!

‎plotly/express/_core.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2194,11 +2194,18 @@ def process_dataframe_timeline(args):
21942194
"Both x_start and x_end must refer to data convertible to datetimes."
21952195
) from exc
21962196

2197+
# Match the local times displayed on date axes when calculating bar lengths.
2198+
schema = df.schema
2199+
x_start, x_end = [
2200+
nw.col(col).dt.replace_time_zone(None)
2201+
if schema[col] == nw.Datetime and schema[col].time_zone is not None
2202+
else nw.col(col)
2203+
for col in (args["x_start"], args["x_end"])
2204+
]
2205+
21972206
# note that we are not adding any columns to the data frame here, so no risk of overwrite
21982207
args["data_frame"] = df.with_columns(
2199-
(nw.col(args["x_end"]) - nw.col(args["x_start"]))
2200-
.dt.total_milliseconds()
2201-
.alias(args["x_end"])
2208+
(x_end - x_start).dt.total_milliseconds().alias(args["x_end"])
22022209
)
22032210
args["x"] = args["x_end"]
22042211
args["base"] = args["x_start"]

‎tests/test_optional/test_px/test_px_functions.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,32 @@ def test_timeline_cols_already_temporal(constructor, datetime_columns):
680680
assert fig.layout.xaxis.title.text is None
681681

682682

683+
@pytest.mark.parametrize("time_zone", [None, "UTC", "US/Pacific"])
684+
def test_timeline_daylight_saving(constructor, time_zone):
685+
# Regression for https://github.com/plotly/plotly.py/issues/4611
686+
starts = ["2024-03-09", "2024-03-10", "2024-03-11", "2023-11-05"]
687+
finishes = ["2024-03-10", "2024-03-11", "2024-03-12", "2023-11-06"]
688+
df = nw.from_native(
689+
constructor({"Start": starts, "Finish": finishes, "Task": ["Job A"] * 4})
690+
).with_columns(
691+
nw.col("Start", "Finish")
692+
.str.to_datetime(format="%Y-%m-%d")
693+
.dt.replace_time_zone(time_zone)
694+
)
695+
696+
fig = px.timeline(df.to_native(), x_start="Start", x_end="Finish", y="Task")
697+
698+
# Date axes use local wall-clock coordinates: consecutive midnights are
699+
# 24 hours apart even when the elapsed time is 23 or 25 hours.
700+
assert_array_equal(fig.data[0].x, [24 * 60 * 60 * 1000] * 4)
701+
base = np.asarray(fig.data[0].base, dtype="datetime64[ms]")
702+
assert_array_equal(base, np.array(starts, dtype="datetime64[ms]"))
703+
assert_array_equal(
704+
base + fig.data[0].x.astype("timedelta64[ms]"),
705+
np.array(finishes, dtype="datetime64[ms]"),
706+
)
707+
708+
683709
def test_empty_histogram():
684710
"""Empty px.histogram() should not raise, matching scatter/bar/pie behavior.
685711

0 commit comments

Comments
 (0)