Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- 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!
- 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!
- 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!
- 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!
- 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!

Expand Down
46 changes: 19 additions & 27 deletions plotly/matplotlylib/mpltools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from _plotly_utils.colors import hex_to_rgb

from plotly.matplotlylib.mplexporter.utils import export_color


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


def merge_color_and_opacity(color, opacity):
"""
Merge hex color with an alpha (opacity) to get an rgba tuple.

:param (str|unicode) color: A hex color string.
:param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'.
:return: (int, int, int, float) The rgba color and alpha tuple.

"""
if color is None: # None can be used as a placeholder, just bail.
return None

rgb_tup = hex_to_rgb(color)
if opacity is None:
return "rgb{}".format(rgb_tup)

rgba_tup = rgb_tup + (opacity,)
return "rgba{}".format(rgba_tup)


def convert_va(mpl_va):
"""Convert mpl vertical alignment word to equivalent HTML word.

Expand Down Expand Up @@ -530,19 +512,29 @@ def prep_ticks(ax, index, ax_type, props):
return axis_dict


def _export_color(color):
def _export_color(color, opacity=None):
"""Export a matplotlib color for use as a plotly color.

matplotlib uses "none" for fully transparent colors, which plotly does not
accept, so transparent colors are exported as transparent black.
Colors already exported by the mplexporter (hex or rgba strings) are
passed through unchanged.
accept, so transparent colors are exported as transparent black. Colors
already exported by the mplexporter (hex or rgba strings) keep the alpha
they carry unless an explicit opacity overrides it.
"""
if color is None:
return None
if isinstance(color, str):
return color if color != "none" else "rgba(0,0,0,0)"
return [_export_color(c) for c in color]
if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color):
return [_export_color(c, opacity) for c in color]
if not isinstance(color, str):
color = export_color(color)
if color == "none":
return "rgba(0,0,0,0)"
if color.startswith("#") and opacity is not None:
return "rgba{0}".format(hex_to_rgb(color) + (opacity,))
if color.startswith("rgb") and opacity not in (None, 1):
rgb = color[color.index("(") + 1 : color.rindex(")")].split(",")
r, g, b = (int(round(float(component))) for component in rgb[:3])
return "rgba({0}, {1}, {2}, {3})".format(r, g, b, opacity)
return color


def prep_xy_axis(ax, props, x_bounds, y_bounds):
Expand Down
27 changes: 16 additions & 11 deletions plotly/matplotlylib/renderer.py
Comment thread
robertoffmoura marked this conversation as resolved.
Comment thread
robertoffmoura marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ def draw_bar(self, coll):
yaxis="y{0}".format(self.axis_ct),
opacity=trace[0]["alpha"], # TODO: get all alphas if array?
marker=go.bar.Marker(
color=trace[0]["facecolor"], # TODO: get all
color=_export_color(trace[0]["facecolor"]), # TODO: get all
line=dict(width=trace[0]["edgewidth"]),
),
) # TODO ditto
Expand Down Expand Up @@ -446,7 +446,7 @@ def draw_marked_line(self, **props):
self.msg += "... with just markers\n"
mode = "markers"
if props["linestyle"]:
color = mpltools.merge_color_and_opacity(
color = _export_color(
props["linestyle"]["color"], props["linestyle"]["alpha"]
)

Expand All @@ -469,22 +469,22 @@ def draw_marked_line(self, **props):
if props["coordinates"] == "data":
marker = go.scatter.Marker(
opacity=props["markerstyle"]["alpha"],
color=props["markerstyle"]["facecolor"],
color=_export_color(props["markerstyle"]["facecolor"]),
symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]),
size=props["markerstyle"]["markersize"],
line=dict(
color=props["markerstyle"]["edgecolor"],
color=_export_color(props["markerstyle"]["edgecolor"]),
width=props["markerstyle"]["edgewidth"],
),
)
else:
shape = dict(
opacity=props["markerstyle"]["alpha"],
fillcolor=props["markerstyle"]["facecolor"],
fillcolor=_export_color(props["markerstyle"]["facecolor"]),
symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]),
size=props["markerstyle"]["markersize"],
line=dict(
color=props["markerstyle"]["edgecolor"],
color=_export_color(props["markerstyle"]["edgecolor"]),
width=props["markerstyle"]["edgewidth"],
),
)
Expand Down Expand Up @@ -782,7 +782,8 @@ def draw_text(self, **props):
yanchor=yanchor,
showarrow=False, # change this later?
font=go.layout.annotation.Font(
color=props["style"]["color"], size=props["style"]["fontsize"]
color=_export_color(props["style"]["color"]),
size=props["style"]["fontsize"],
),
)
self.plotly_fig["layout"]["annotations"] += (annotation,)
Expand Down Expand Up @@ -822,7 +823,8 @@ def draw_title(self, **props):
annotation = go.layout.Annotation(
text=props["text"],
font=go.layout.annotation.Font(
color=props["style"]["color"], size=props["style"]["fontsize"]
color=_export_color(props["style"]["color"]),
size=props["style"]["fontsize"],
),
xref="paper",
yref="paper",
Expand All @@ -837,7 +839,8 @@ def draw_title(self, **props):
self.msg += " Only one subplot found, adding as a plotly title\n"
self.plotly_fig["layout"]["title"] = props["text"]
title_font = dict(
size=props["style"]["fontsize"], color=props["style"]["color"]
size=props["style"]["fontsize"],
color=_export_color(props["style"]["color"]),
)
self.plotly_fig["layout"]["title_font"] = title_font

Expand Down Expand Up @@ -868,7 +871,8 @@ def draw_xlabel(self, **props):
axis_key = "xaxis{0}".format(self.axis_ct)
self.plotly_fig["layout"][axis_key]["title"] = str(props["text"])
title_font = dict(
size=props["style"]["fontsize"], color=props["style"]["color"]
size=props["style"]["fontsize"],
color=_export_color(props["style"]["color"]),
)
self.plotly_fig["layout"][axis_key]["title_font"] = title_font

Expand Down Expand Up @@ -899,7 +903,8 @@ def draw_ylabel(self, **props):
axis_key = "yaxis{0}".format(self.axis_ct)
self.plotly_fig["layout"][axis_key]["title"] = props["text"]
title_font = dict(
size=props["style"]["fontsize"], color=props["style"]["color"]
size=props["style"]["fontsize"],
color=_export_color(props["style"]["color"]),
)
self.plotly_fig["layout"][axis_key]["title_font"] = title_font

Expand Down
107 changes: 107 additions & 0 deletions plotly/matplotlylib/tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,113 @@ def test_pcolor_rectangles_render():
assert all(len(t.x) >= 4 for t in plotly_fig.data)


def test_boxplot_converts_with_none_marker_facecolor():
"""Boxplot outlier markers use facecolor 'none', which plotly rejects."""
fig, ax = plt.subplots()
ax.boxplot(np.random.randn(100, 4))

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) > 0


def test_line_with_none_color_converts():
"""Lines with color='none' use the string 'none' for the line color,
which plotly rejects; it must be exported as a transparent line."""
fig, ax = plt.subplots()
ax.plot([0, 1], [0, 1], color="none")

plotly_fig = tls.mpl_to_plotly(fig)

assert len(plotly_fig.data) == 1
assert plotly_fig.data[0].line.color == "rgba(0,0,0,0)"


def test_line_with_rgba_color_converts():
"""Line colors that carry their own alpha (rgba tuple or 8-digit hex)
export as rgba strings."""
fig, ax = plt.subplots()
ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.5))
ax.plot([0, 1], [1, 0], color="#0000FF80")

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)"
assert plotly_fig.data[1].line.color == "rgba(0, 0, 255, 0.5019607843137255)"


def test_line_rgba_color_with_separate_alpha_converts():
"""An explicit alpha overrides the alpha carried by the line color."""
fig, ax = plt.subplots()
ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.2), alpha=0.5)

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)"


def test_transparent_text_colors_export():
"""Text, title, and axis labels with color 'none' export transparent
fonts."""
fig, ax = plt.subplots()
ax.text(0.5, 0.5, "text", color="none")
ax.set_title("title", color="none")
ax.set_xlabel("xlabel", color="none")
ax.set_ylabel("ylabel", color="none")

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.layout.annotations[0].font.color == "rgba(0,0,0,0)"
assert plotly_fig.layout.title.font.color == "rgba(0,0,0,0)"
assert plotly_fig.layout.xaxis.title.font.color == "rgba(0,0,0,0)"
assert plotly_fig.layout.yaxis.title.font.color == "rgba(0,0,0,0)"


def test_export_color_maps_colors():
"""_export_color maps matplotlib color strings to plotly colors, keeping
or overriding the alpha as requested."""
from plotly.matplotlylib.mpltools import _export_color

expected_mappings = {
(None, None): None,
("none", None): "rgba(0,0,0,0)",
("#FF0000", None): "#FF0000",
("#FF0000", 1): "rgba(255, 0, 0, 1)",
("#FF0000", 0.5): "rgba(255, 0, 0, 0.5)",
("rgba(255, 0, 0, 0.2)", None): "rgba(255, 0, 0, 0.2)",
("rgba(255, 0, 0, 0.2)", 1): "rgba(255, 0, 0, 0.2)",
("rgba(255, 0, 0, 0.2)", 0.5): "rgba(255, 0, 0, 0.5)",
("rgb(255, 0, 0)", None): "rgb(255, 0, 0)",
("rgb(255, 0, 0)", 0.5): "rgba(255, 0, 0, 0.5)",
((1.0, 0.0, 0.0, 0.5), None): "rgba(255, 0, 0, 0.5)",
((0.0, 1.0, 0.0, 1.0), None): "#00FF00",
}
for (color, opacity), expected in expected_mappings.items():
result = _export_color(color, opacity)
assert result == expected, (
f"Input {color!r} with opacity {opacity!r} produced {result!r}, "
f"expected {expected!r}"
)

assert _export_color(["#FF0000", "none"]) == ["#FF0000", "rgba(0,0,0,0)"]
assert _export_color(["rgba(255, 0, 0, 0.2)"], 0.5) == ["rgba(255, 0, 0, 0.5)"]


def test_scatter_with_multiple_colors_converts():
"""Scatter markers with per-point colors export a list of marker colors."""
fig, ax = plt.subplots()
ax.scatter([0, 1, 2], [0, 1, 2], c=["red", "green", "blue"])

plotly_fig = tls.mpl_to_plotly(fig)

assert plotly_fig.data[0].mode == "markers"
assert plotly_fig.data[0].marker.color == (
"rgba(255,0,0,1.0)",
"rgba(0,128,0,1.0)",
"rgba(0,0,255,1.0)",
)


def test_eventplot_segments_render():
fig, ax = plt.subplots()
ax.eventplot([np.random.randn(20) for _ in range(5)])
Expand Down
Loading