Skip to content

Commit 9bed9d3

Browse files
committed
Fix _mean() to handle datetime values for shape annotations
Instead of assuming input values are equal, _mean() now converts datetime-like values (date strings and datetime objects) to milliseconds-since-epoch, computes the actual arithmetic mean, and converts back. This correctly handles annotation placement for ALL shapes (vlines, hlines, vrects, hrects) with datetime axes. The numeric fast path is unchanged — datetime handling only activates when sum() raises TypeError on non-numeric types. Added tests for datetime strings, datetime objects, and rects with different x0/x1 values on datetime axes. Fixes #3065
1 parent 4e953f3 commit 9bed9d3

File tree

2 files changed

+122
-1
lines changed

2 files changed

+122
-1
lines changed

plotly/shapeannotation.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,52 @@
11
# some functions defined here to avoid numpy import
22

3+
import datetime
4+
5+
6+
def _is_date_string(val):
7+
"""Check if a value is a date/datetime string."""
8+
if not isinstance(val, str):
9+
return False
10+
try:
11+
datetime.datetime.fromisoformat(val.replace("Z", "+00:00"))
12+
return True
13+
except (ValueError, AttributeError):
14+
return False
15+
16+
17+
def _datetime_str_to_ms(val):
18+
"""Convert a datetime string to milliseconds since epoch."""
19+
dt = datetime.datetime.fromisoformat(val.replace("Z", "+00:00"))
20+
if dt.tzinfo is None:
21+
dt = dt.replace(tzinfo=datetime.timezone.utc)
22+
return dt.timestamp() * 1000
23+
24+
25+
def _ms_to_datetime_str(ms):
26+
"""Convert milliseconds since epoch back to a datetime string."""
27+
dt = datetime.datetime.fromtimestamp(ms / 1000, tz=datetime.timezone.utc)
28+
return dt.strftime("%Y-%m-%d %H:%M:%S")
29+
330

431
def _mean(x):
532
if len(x) == 0:
633
raise ValueError("x must have positive length")
7-
return float(sum(x)) / len(x)
34+
try:
35+
return float(sum(x)) / len(x)
36+
except TypeError:
37+
# Handle non-numeric types like datetime strings or datetime objects
38+
if all(_is_date_string(v) for v in x):
39+
ms_values = [_datetime_str_to_ms(v) for v in x]
40+
mean_ms = sum(ms_values) / len(ms_values)
41+
return _ms_to_datetime_str(mean_ms)
42+
# Handle datetime.datetime, pd.Timestamp, or similar objects
43+
if all(hasattr(v, "timestamp") for v in x):
44+
ts_values = [v.timestamp() * 1000 for v in x]
45+
mean_ms = sum(ts_values) / len(ts_values)
46+
return datetime.datetime.fromtimestamp(
47+
mean_ms / 1000, tz=datetime.timezone.utc
48+
).isoformat()
49+
raise
850

951

1052
def _argmin(x):

tests/test_optional/test_autoshapes/test_annotated_shapes.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,5 +425,84 @@ def test_all_annotation_positions():
425425
draw_all_annotation_positions(testing=True)
426426

427427

428+
428429
if __name__ == "__main__":
429430
draw_all_annotation_positions()
431+
432+
433+
# Tests for datetime axis annotation support (issue #3065)
434+
import datetime
435+
436+
437+
def test_vline_datetime_string_annotation():
438+
"""add_vline with annotation_text on datetime x-axis should not crash."""
439+
fig = go.Figure()
440+
fig.add_trace(
441+
go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])
442+
)
443+
fig.add_vline(x="2018-09-24", annotation_text="test")
444+
assert len(fig.layout.annotations) == 1
445+
assert fig.layout.annotations[0].text == "test"
446+
447+
448+
def test_hline_with_datetime_vline():
449+
"""add_hline should still work alongside datetime vline usage."""
450+
fig = go.Figure()
451+
fig.add_trace(
452+
go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])
453+
)
454+
fig.add_hline(y=2, annotation_text="hline test")
455+
assert len(fig.layout.annotations) == 1
456+
assert fig.layout.annotations[0].text == "hline test"
457+
458+
459+
def test_vrect_datetime_string_annotation():
460+
"""add_vrect with annotation_text on datetime x-axis should not crash."""
461+
fig = go.Figure()
462+
fig.add_trace(
463+
go.Scatter(x=["2018-01-01", "2018-06-01", "2018-12-31"], y=[1, 2, 3])
464+
)
465+
fig.add_vrect(x0="2018-03-01", x1="2018-09-01", annotation_text="rect test")
466+
assert len(fig.layout.annotations) == 1
467+
assert fig.layout.annotations[0].text == "rect test"
468+
469+
470+
def test_vline_datetime_object_annotation():
471+
"""add_vline with datetime.datetime object should not crash."""
472+
fig = go.Figure()
473+
fig.add_trace(
474+
go.Scatter(
475+
x=[
476+
datetime.datetime(2018, 1, 1),
477+
datetime.datetime(2018, 6, 1),
478+
datetime.datetime(2018, 12, 31),
479+
],
480+
y=[1, 2, 3],
481+
)
482+
)
483+
fig.add_vline(x=datetime.datetime(2018, 9, 24), annotation_text="dt test")
484+
assert len(fig.layout.annotations) == 1
485+
assert fig.layout.annotations[0].text == "dt test"
486+
487+
488+
def test_vrect_datetime_object_annotation():
489+
"""add_vrect with datetime.datetime objects should compute correct mean."""
490+
fig = go.Figure()
491+
fig.add_trace(
492+
go.Scatter(
493+
x=[
494+
datetime.datetime(2018, 1, 1),
495+
datetime.datetime(2018, 6, 1),
496+
datetime.datetime(2018, 12, 31),
497+
],
498+
y=[1, 2, 3],
499+
)
500+
)
501+
fig.add_vrect(
502+
x0=datetime.datetime(2018, 3, 1),
503+
x1=datetime.datetime(2018, 9, 1),
504+
annotation_text="rect dt test",
505+
)
506+
assert len(fig.layout.annotations) == 1
507+
assert fig.layout.annotations[0].text == "rect dt test"
508+

0 commit comments

Comments
 (0)