Skip to content

Commit 45ca501

Browse files
Merge remote-tracking branch 'upstream/main' into fix-timeline-daylight-saving
# Conflicts: # CHANGELOG.md
2 parents 3e293e6 + 893e977 commit 45ca501

5 files changed

Lines changed: 568 additions & 72 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ This project adheres to [Semantic Versioning](http://semver.org/).
55
## Unreleased
66

77
### Fixed
8-
- 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)]
8+
- 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!
99
- 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!
1010
- 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!
11+
- 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!
12+
- Fix `mpl_to_plotly` showing unwanted legends with placeholder or internal label entries (`_nolegend_`, `_childN`) by defaulting `layout.showlegend=False` unless the matplotlib figure explicitly defines a legend [[#5699](https://github.com/plotly/plotly.py/pull/5699)], with thanks to @robertoffmoura for the contribution!
13+
- Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution!
14+
- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution!
1115
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
16+
- Fix `px.timeline` bars ending an hour early or late when timezone-aware dates span a daylight saving time change [[#5796](https://github.com/plotly/plotly.py/pull/5796)], with thanks to @mottopanikeiku for the contribution!
1217

1318
## [7.1.0] - 2026-09-15
1419

‎plotly/matplotlylib/mplexporter/utils.py‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,18 +200,36 @@ def get_text_style(text):
200200
def get_axis_properties(axis):
201201
"""Return the property dictionary for a matplotlib.Axis instance"""
202202
props = {}
203-
label1On = axis.get_tick_params().get("label1On", True)
203+
tick_params = axis.get_tick_params()
204204

205205
if isinstance(axis, matplotlib.axis.XAxis):
206-
if label1On:
207-
props["position"] = "bottom"
208-
else:
206+
if (
207+
axis.get_ticks_position() == "top"
208+
or axis.get_label_position() == "top"
209+
or (
210+
tick_params.get("labeltop", False)
211+
and not tick_params.get("labelbottom", False)
212+
)
213+
or (tick_params.get("top", False) and not tick_params.get("bottom", False))
214+
or not tick_params.get("label1On", True)
215+
):
209216
props["position"] = "top"
210-
elif isinstance(axis, matplotlib.axis.YAxis):
211-
if label1On:
212-
props["position"] = "left"
213217
else:
218+
props["position"] = "bottom"
219+
elif isinstance(axis, matplotlib.axis.YAxis):
220+
if (
221+
axis.get_ticks_position() == "right"
222+
or axis.get_label_position() == "right"
223+
or (
224+
tick_params.get("labelright", False)
225+
and not tick_params.get("labelleft", False)
226+
)
227+
or (tick_params.get("right", False) and not tick_params.get("left", False))
228+
or not tick_params.get("label1On", True)
229+
):
214230
props["position"] = "right"
231+
else:
232+
props["position"] = "left"
215233
else:
216234
raise ValueError("{0} should be an Axis instance".format(axis))
217235

@@ -239,12 +257,15 @@ def get_axis_properties(axis):
239257
# Get axis scale
240258
props["scale"] = axis.get_scale()
241259

242-
# Get major tick label size (assumes that's all we really care about!)
260+
# Get major tick label size and color (assumes that's all we really
261+
# care about!)
243262
labels = axis.get_ticklabels()
244263
if labels:
245264
props["fontsize"] = labels[0].get_fontsize()
265+
props["fontcolor"] = export_color(labels[0].get_color())
246266
else:
247267
props["fontsize"] = None
268+
props["fontcolor"] = None
248269

249270
# Get associated grid
250271
props["grid"] = get_grid_style(axis)

‎plotly/matplotlylib/mpltools.py‎

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from _plotly_utils.colors import hex_to_rgb
1414

15+
from plotly.matplotlylib.mplexporter.utils import export_color
16+
1517

1618
def check_bar_match(old_bar, new_bar):
1719
"""Check if two bars belong in the same collection (bar chart).
@@ -106,26 +108,6 @@ def convert_symbol(mpl_symbol):
106108
return "circle" # default
107109

108110

109-
def merge_color_and_opacity(color, opacity):
110-
"""
111-
Merge hex color with an alpha (opacity) to get an rgba tuple.
112-
113-
:param (str|unicode) color: A hex color string.
114-
:param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'.
115-
:return: (int, int, int, float) The rgba color and alpha tuple.
116-
117-
"""
118-
if color is None: # None can be used as a placeholder, just bail.
119-
return None
120-
121-
rgb_tup = hex_to_rgb(color)
122-
if opacity is None:
123-
return "rgb{}".format(rgb_tup)
124-
125-
rgba_tup = rgb_tup + (opacity,)
126-
return "rgba{}".format(rgba_tup)
127-
128-
129111
def convert_va(mpl_va):
130112
"""Convert mpl vertical alignment word to equivalent HTML word.
131113
@@ -250,15 +232,12 @@ def get_axes_bounds(fig):
250232
return (x_min, x_max), (y_min, y_max)
251233

252234

253-
def get_axis_mirror(main_spine, mirror_spine):
254-
if main_spine and mirror_spine:
235+
def get_axis_mirror(main_spine, mirror_spine, main_tick_markers, mirror_tick_markers):
236+
if main_spine and mirror_spine and main_tick_markers and mirror_tick_markers:
255237
return "ticks"
256-
elif main_spine and not mirror_spine:
257-
return False
258-
elif not main_spine and mirror_spine:
259-
return False # can't handle this case yet!
260-
else:
261-
return False # nuttin'!
238+
if main_spine and mirror_spine:
239+
return True
240+
return False
262241

263242

264243
def get_bar_gap(bar_starts, bar_ends, tol=1e-10):
@@ -278,6 +257,23 @@ def get_bar_gap(bar_starts, bar_ends, tol=1e-10):
278257
return min(max(gap0 / bar_delta, 0.0), 1.0)
279258

280259

260+
DRAWSTYLE_SHAPE_MAP = {
261+
"steps": "vh",
262+
"steps-pre": "vh",
263+
"steps-post": "hv",
264+
"steps-mid": "hvh",
265+
}
266+
267+
268+
def convert_drawstyle(drawstyle):
269+
"""Convert a matplotlib line drawstyle to a plotly line shape.
270+
271+
Matplotlib draws steps as vertical/horizontal segments; plotly's
272+
``line.shape`` expresses the same via "vh", "hv" and "hvh".
273+
"""
274+
return DRAWSTYLE_SHAPE_MAP.get(drawstyle)
275+
276+
281277
def convert_rgba_array(color_list):
282278
clean_color_list = list()
283279
for c in color_list:
@@ -516,14 +512,42 @@ def prep_ticks(ax, index, ax_type, props):
516512
return axis_dict
517513

518514

515+
def _export_color(color, opacity=None):
516+
"""Export a matplotlib color for use as a plotly color.
517+
518+
matplotlib uses "none" for fully transparent colors, which plotly does not
519+
accept, so transparent colors are exported as transparent black. Colors
520+
already exported by the mplexporter (hex or rgba strings) keep the alpha
521+
they carry unless an explicit opacity overrides it.
522+
"""
523+
if color is None:
524+
return None
525+
if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color):
526+
return [_export_color(c, opacity) for c in color]
527+
if not isinstance(color, str):
528+
color = export_color(color)
529+
if color == "none":
530+
return "rgba(0,0,0,0)"
531+
if color.startswith("#") and opacity is not None:
532+
return "rgba{0}".format(hex_to_rgb(color) + (opacity,))
533+
if color.startswith("rgb") and opacity not in (None, 1):
534+
rgb = color[color.index("(") + 1 : color.rindex(")")].split(",")
535+
r, g, b = (int(round(float(component))) for component in rgb[:3])
536+
return "rgba({0}, {1}, {2}, {3})".format(r, g, b, opacity)
537+
return color
538+
539+
519540
def prep_xy_axis(ax, props, x_bounds, y_bounds):
520541
xaxis = dict(
521542
type=props["axes"][0]["scale"],
522543
range=list(props["xlim"]),
523544
showgrid=props["axes"][0]["grid"]["gridOn"],
524545
domain=convert_x_domain(props["bounds"], x_bounds),
525546
side=props["axes"][0]["position"],
526-
tickfont=dict(size=props["axes"][0]["fontsize"]),
547+
tickfont=dict(
548+
size=props["axes"][0]["fontsize"],
549+
color=_export_color(props["axes"][0]["fontcolor"]),
550+
),
527551
)
528552
xaxis.update(prep_ticks(ax, 0, "x", props))
529553
yaxis = dict(
@@ -532,7 +556,10 @@ def prep_xy_axis(ax, props, x_bounds, y_bounds):
532556
showgrid=props["axes"][1]["grid"]["gridOn"],
533557
domain=convert_y_domain(props["bounds"], y_bounds),
534558
side=props["axes"][1]["position"],
535-
tickfont=dict(size=props["axes"][1]["fontsize"]),
559+
tickfont=dict(
560+
size=props["axes"][1]["fontsize"],
561+
color=_export_color(props["axes"][1]["fontcolor"]),
562+
),
536563
)
537564
yaxis.update(prep_ticks(ax, 1, "y", props))
538565
return xaxis, yaxis

0 commit comments

Comments
 (0)