From a5eab34e8957d7d8ae79343554922afd6dc201b0 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 2 Sep 2026 15:24:58 +0000 Subject: [PATCH 01/16] Convert quiver arrows to annotation arrows --- plotly/matplotlylib/renderer.py | 60 +++++++++++++++++++++- plotly/matplotlylib/tests/test_renderer.py | 45 ++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 0a3c829945..e4fee9a10e 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -11,8 +11,10 @@ import warnings import matplotlib.patches as mpatches +import matplotlib.quiver as mquiver from matplotlib import lines as mlines from matplotlib import transforms +import numpy as np import plotly.graph_objs as go from plotly.matplotlylib.mplexporter import Renderer from plotly.matplotlylib import mpltools @@ -729,7 +731,13 @@ def draw_path_collection(self, **props): """ self.msg += " Attempting to draw a path collection\n" - if props["offset_coordinates"] == "data": + if ( + isinstance(props["mplobj"], mquiver.Quiver) + and props["mplobj"].pivot == "tail" + ): + self.msg += " Drawing a quiver as arrow annotations\n" + self._draw_quiver(props) + elif props["offset_coordinates"] == "data": markerstyle = mpltools.get_markerstyle_from_collection(props) scatter_props = { "coordinates": "data", @@ -850,6 +858,56 @@ def flush(): flush() + def _draw_quiver(self, props): + """Draw a matplotlib Quiver collection as layout annotations with + arrows. The exporter reports the arrow path vertices in pixels + relative to the pivot point, and the pivot positions as data + coordinates. The annotation arrowhead lands on the annotation + position, so each annotation is anchored at the arrow tip in data + coordinates and offset back to the tail in pixels.""" + q = props["mplobj"] + trans = q.get_offset_transform() + ax = self.current_mpl_ax + box_min = np.asarray(trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) + box_max = np.asarray(trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) + facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) + linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) + dpi = self.mpl_fig.dpi + axis_ref = str(self.axis_ct) + annotations = [] + for tail, path in zip(props["offsets"], props["paths"]): + tail_px = np.asarray(trans.transform(tail)) + # matplotlib clips arrowheads at the axes box, so clamp the + # arrowhead position into the axes range the same way + tip_px = np.clip(tail_px + np.asarray(path[0][3]), box_min, box_max) + tip = trans.inverted().transform(tip_px) + if isinstance(facecolors, list): + color = facecolors[0] + else: + color = facecolors + if isinstance(linewidths, list): + linewidth = linewidths[0] + else: + linewidth = linewidths + annotations.append( + go.layout.Annotation( + x=tip[0], + y=tip[1], + xref="x{0}".format(axis_ref), + yref="y{0}".format(axis_ref), + text="", + showarrow=True, + ax=tail_px[0] - tip_px[0], + ay=tip_px[1] - tail_px[1], + arrowhead=2, + arrowsize=max(1.0, linewidth * dpi / 72 * 2), + arrowwidth=max(1.0, linewidth * dpi / 72), + arrowcolor=color, + ) + ) + self.plotly_fig["layout"]["annotations"] += tuple(annotations) + self.msg += " Heck yeah, I drew that quiver\n" + def _draw_filled_path_collection(self, props): """Draw a path collection (e.g. violin plot bodies) as filled polygons.""" facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 90f73bff36..8219bdcf73 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1392,3 +1392,48 @@ def test_mixed_style_lines_group_consecutive_matches(): else: # older matplotlib emits one collection per level assert dashes == ["5.55px,2.4px"] * 2 + ["solid"] * 2 + + +def test_quiver_converts(): + """Quiver arrows convert to layout annotations with arrows anchored at + the arrow tails, pointing to the arrow tips.""" + x = np.arange(-2, 2.5, 1.0) + X, Y = np.meshgrid(x, x) + U = np.ones_like(X) + V = np.zeros_like(Y) + fig, ax = plt.subplots() + q = ax.quiver(X, Y, U, V) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == X.size + assert anns[0].showarrow is True + assert anns[0].xref == "x" + assert anns[0].yref == "y" + # the annotation anchor (the arrowhead) sits at the arrow tip and the + # pixel offset points back to the tail + tip_px = q.get_transform().transform(q.get_paths()[0].vertices[3]) + tail_px = q.get_offset_transform().transform(q.get_offsets()[0]) + tip_data = q.get_offset_transform().inverted().transform(tail_px + tip_px) + assert abs(anns[0].x - tip_data[0]) < 1e-6 + assert abs(anns[0].y - tip_data[1]) < 1e-6 + assert abs(anns[0].ax + tip_px[0]) < 1e-6 + assert abs(anns[0].ay - tip_px[1]) < 1e-6 + assert tip_px[0] > 0 + assert abs(tip_px[1]) < 1e-6 + # no marker traces + assert all(t.mode != "markers" for t in plotly_fig.data) + + +def test_quiver_alpha_rides_in_arrow_color(): + """Quiver arrows with alpha export the alpha in the arrow color.""" + fig, ax = plt.subplots() + ax.quiver([0], [0], [1], [0], alpha=0.5) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == 1 + assert anns[0].opacity is None + assert anns[0].arrowcolor == "rgba(0,0,0,0.5)" From f2f461b79a62b8f576a2cfdeb01a10068135cd11 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 09:52:35 +0000 Subject: [PATCH 02/16] Keep per-arrow styles on quiver arrows --- plotly/matplotlylib/renderer.py | 24 ++++++++++++++-------- plotly/matplotlylib/tests/test_renderer.py | 12 +++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index e4fee9a10e..5f5f6133df 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -875,20 +875,26 @@ def _draw_quiver(self, props): dpi = self.mpl_fig.dpi axis_ref = str(self.axis_ct) annotations = [] - for tail, path in zip(props["offsets"], props["paths"]): + + def per_arrow(styles, i, default): + if isinstance(styles, str): + return styles + if styles is None: + return default + try: + n = len(styles) + except TypeError: + return styles + return styles[i % n] if n else default + + for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): tail_px = np.asarray(trans.transform(tail)) # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way tip_px = np.clip(tail_px + np.asarray(path[0][3]), box_min, box_max) tip = trans.inverted().transform(tip_px) - if isinstance(facecolors, list): - color = facecolors[0] - else: - color = facecolors - if isinstance(linewidths, list): - linewidth = linewidths[0] - else: - linewidth = linewidths + color = per_arrow(facecolors, i, "rgba(0,0,0,0)") + linewidth = per_arrow(linewidths, i, 0) annotations.append( go.layout.Annotation( x=tip[0], diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 8219bdcf73..67960f6226 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1437,3 +1437,15 @@ def test_quiver_alpha_rides_in_arrow_color(): assert len(anns) == 1 assert anns[0].opacity is None assert anns[0].arrowcolor == "rgba(0,0,0,0.5)" + + +def test_quiver_per_arrow_styles(): + """Quiver arrows keep their per-arrow colors and linewidths.""" + fig, ax = plt.subplots() + ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], color=["red", "blue"], linewidths=[1, 2]) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)", "rgba(0,0,255,1.0)"] + assert anns[0].arrowwidth < anns[1].arrowwidth From 6825be486fa71cded7356ab25e86dcdf9efdfb60 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 09:56:38 +0000 Subject: [PATCH 03/16] Share per-path style helper between path collection renderers --- plotly/matplotlylib/renderer.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 5f5f6133df..ee3ac7bc12 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -875,26 +875,14 @@ def _draw_quiver(self, props): dpi = self.mpl_fig.dpi axis_ref = str(self.axis_ct) annotations = [] - - def per_arrow(styles, i, default): - if isinstance(styles, str): - return styles - if styles is None: - return default - try: - n = len(styles) - except TypeError: - return styles - return styles[i % n] if n else default - for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): tail_px = np.asarray(trans.transform(tail)) # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way tip_px = np.clip(tail_px + np.asarray(path[0][3]), box_min, box_max) tip = trans.inverted().transform(tip_px) - color = per_arrow(facecolors, i, "rgba(0,0,0,0)") - linewidth = per_arrow(linewidths, i, 0) + color = _per_path(facecolors, i, "rgba(0,0,0,0)") + linewidth = _per_path(linewidths, i, 0) annotations.append( go.layout.Annotation( x=tip[0], From a9a3120cd3849bdbc28d4ee746fae85f289aa1e2 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 10:10:41 +0000 Subject: [PATCH 04/16] Fix quiver arrow head size scaling --- plotly/matplotlylib/renderer.py | 2 +- plotly/matplotlylib/tests/test_renderer.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index ee3ac7bc12..fd8a4be7e0 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -894,7 +894,7 @@ def _draw_quiver(self, props): ax=tail_px[0] - tip_px[0], ay=tip_px[1] - tail_px[1], arrowhead=2, - arrowsize=max(1.0, linewidth * dpi / 72 * 2), + arrowsize=max(0.3, q.headwidth / 3.0), arrowwidth=max(1.0, linewidth * dpi / 72), arrowcolor=color, ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 67960f6226..8015075c68 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1449,3 +1449,15 @@ def test_quiver_per_arrow_styles(): anns = plotly_fig.layout.annotations assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)", "rgba(0,0,255,1.0)"] assert anns[0].arrowwidth < anns[1].arrowwidth + + +def test_quiver_arrow_head_size_uses_headwidth(): + """Quiver arrow heads use the quiver's headwidth ratio.""" + fig, ax = plt.subplots() + q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], linewidths=[1, 4]) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert anns[0].arrowsize == anns[1].arrowsize + assert abs(anns[0].arrowsize - q.headwidth / 3.0) < 1e-9 From 9501e84b8c45e44bd8316222509e12072dd51d03 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 10:49:17 +0000 Subject: [PATCH 05/16] Size quiver arrows from the arrow path geometry --- plotly/matplotlylib/renderer.py | 23 ++++++++++++++++------ plotly/matplotlylib/tests/test_renderer.py | 16 +++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index fd8a4be7e0..375bd8d3fe 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -871,18 +871,29 @@ def _draw_quiver(self, props): box_min = np.asarray(trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) box_max = np.asarray(trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) - linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"]) - dpi = self.mpl_fig.dpi axis_ref = str(self.axis_ct) annotations = [] for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): + verts = np.asarray(path[0], dtype=float) tail_px = np.asarray(trans.transform(tail)) + tip_offset = verts[3] # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way - tip_px = np.clip(tail_px + np.asarray(path[0][3]), box_min, box_max) + tip_px = np.clip(tail_px + tip_offset, box_min, box_max) tip = trans.inverted().transform(tip_px) color = _per_path(facecolors, i, "rgba(0,0,0,0)") - linewidth = _per_path(linewidths, i, 0) + length = np.hypot(*tip_offset) + if length > 0: + along = tip_offset / length + perp = np.array([-along[1], along[0]]) + shaft_width = abs(verts[1] @ perp) + abs(verts[5] @ perp) + head_width = abs(verts[2] @ perp) + abs(verts[4] @ perp) + else: + shaft_width = 1.0 + head_width = 3.0 + arrowwidth = max(0.1, shaft_width) + # plotly draws the head 5x arrowwidth wide per unit arrowsize + arrowsize = max(0.3, head_width / (5.0 * arrowwidth)) annotations.append( go.layout.Annotation( x=tip[0], @@ -894,8 +905,8 @@ def _draw_quiver(self, props): ax=tail_px[0] - tip_px[0], ay=tip_px[1] - tail_px[1], arrowhead=2, - arrowsize=max(0.3, q.headwidth / 3.0), - arrowwidth=max(1.0, linewidth * dpi / 72), + arrowsize=arrowsize, + arrowwidth=arrowwidth, arrowcolor=color, ) ) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 8015075c68..f7777230f2 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1439,8 +1439,8 @@ def test_quiver_alpha_rides_in_arrow_color(): assert anns[0].arrowcolor == "rgba(0,0,0,0.5)" -def test_quiver_per_arrow_styles(): - """Quiver arrows keep their per-arrow colors and linewidths.""" +def test_quiver_per_arrow_colors(): + """Quiver arrows keep their per-arrow colors.""" fig, ax = plt.subplots() ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], color=["red", "blue"], linewidths=[1, 2]) @@ -1448,16 +1448,20 @@ def test_quiver_per_arrow_styles(): anns = plotly_fig.layout.annotations assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)", "rgba(0,0,255,1.0)"] - assert anns[0].arrowwidth < anns[1].arrowwidth + assert anns[0].arrowwidth == anns[1].arrowwidth + assert anns[0].arrowsize == anns[1].arrowsize def test_quiver_arrow_head_size_uses_headwidth(): """Quiver arrow heads use the quiver's headwidth ratio.""" fig, ax = plt.subplots() - q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], linewidths=[1, 4]) + ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], linewidths=[1, 4]) plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations assert anns[0].arrowsize == anns[1].arrowsize - assert abs(anns[0].arrowsize - q.headwidth / 3.0) < 1e-9 + + fig2, ax2 = plt.subplots() + ax2.quiver([0, 1], [0, 0], [1, 1], [0, 0], headwidth=6) + anns2 = tls.mpl_to_plotly(fig2).layout.annotations + assert abs(anns2[0].arrowsize / anns[0].arrowsize - 2.0) < 1e-9 From 81f49cc18643f73fc3e5c20d73ec54d1dbb9a727 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 11:03:15 +0000 Subject: [PATCH 06/16] Skip masked and zero-length quiver arrows --- plotly/matplotlylib/renderer.py | 3 ++ plotly/matplotlylib/tests/test_renderer.py | 35 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 375bd8d3fe..54103fe96d 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -875,6 +875,9 @@ def _draw_quiver(self, props): annotations = [] for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) + if len(verts) < 6: + # masked, NaN, and zero-length arrows have no path + continue tail_px = np.asarray(trans.transform(tail)) tip_offset = verts[3] # matplotlib clips arrowheads at the axes box, so clamp the diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index f7777230f2..60a4086158 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1465,3 +1465,38 @@ def test_quiver_arrow_head_size_uses_headwidth(): ax2.quiver([0, 1], [0, 0], [1, 1], [0, 0], headwidth=6) anns2 = tls.mpl_to_plotly(fig2).layout.annotations assert abs(anns2[0].arrowsize / anns[0].arrowsize - 2.0) < 1e-9 + + +def test_quiver_masked_vectors_convert(): + """Quiver arrows with masked vectors convert the unmasked arrows.""" + fig, ax = plt.subplots() + ax.quiver( + [0, 1], + [0, 0], + np.ma.array([1.0, 1.0], mask=[False, True]), + np.ma.array([0.0, 0.0], mask=[False, True]), + ) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.annotations) == 1 + + +def test_quiver_nan_vectors_convert(): + """Quiver arrows with NaN vectors convert the finite arrows.""" + fig, ax = plt.subplots() + ax.quiver([0, 1], [0, 0], [1.0, np.nan], [0.0, 0.0]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.annotations) == 1 + + +def test_quiver_zero_vectors_convert(): + """Quiver arrows with zero-length vectors convert without arrows.""" + fig, ax = plt.subplots() + ax.quiver([0], [0], [0], [0]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.annotations) == 0 From 8895e3541df355237a44b6756bc07b945e95bdd1 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 12:34:46 +0000 Subject: [PATCH 07/16] Add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32590d344f..08ca447dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased + ### Added - Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution! +- Add support for converting matplotlib quiver plots (`plt.quiver`) to annotation arrows in `mpl_to_plotly`, with thanks to @robertoffmoura for the contribution! + ### 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! From 876977dcc817afe950c1b8977b404f73d95c602a Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 13:43:13 +0000 Subject: [PATCH 08/16] Skip quiver arrows with non-finite tail positions --- plotly/matplotlylib/renderer.py | 3 +-- plotly/matplotlylib/tests/test_renderer.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 54103fe96d..e61719d0c4 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -875,8 +875,7 @@ def _draw_quiver(self, props): annotations = [] for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) - if len(verts) < 6: - # masked, NaN, and zero-length arrows have no path + if len(verts) < 6 or not np.all(np.isfinite(tail)): continue tail_px = np.asarray(trans.transform(tail)) tip_offset = verts[3] diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 60a4086158..d4e75fe3fc 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1500,3 +1500,13 @@ def test_quiver_zero_vectors_convert(): plotly_fig = tls.mpl_to_plotly(fig) assert len(plotly_fig.layout.annotations) == 0 + + +def test_quiver_nan_tails_convert(): + """Quiver arrows with NaN tail positions convert the finite arrows.""" + fig, ax = plt.subplots() + ax.quiver([0, np.nan], [0, 0], [1.0, 1.0], [0.0, 0.0]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert len(plotly_fig.layout.annotations) == 1 From 3f58f9b47f4d41915908845959eda96dbca1d94a Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 14:04:41 +0000 Subject: [PATCH 09/16] Support quiver arrows with pivot 'mid' --- plotly/matplotlylib/renderer.py | 18 +++++++++++------- plotly/matplotlylib/tests/test_renderer.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index e61719d0c4..3ae2e0cc48 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -731,9 +731,9 @@ def draw_path_collection(self, **props): """ self.msg += " Attempting to draw a path collection\n" - if ( - isinstance(props["mplobj"], mquiver.Quiver) - and props["mplobj"].pivot == "tail" + if isinstance(props["mplobj"], mquiver.Quiver) and props["mplobj"].pivot in ( + "tail", + "middle", ): self.msg += " Drawing a quiver as arrow annotations\n" self._draw_quiver(props) @@ -873,16 +873,20 @@ def _draw_quiver(self, props): facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) axis_ref = str(self.axis_ct) annotations = [] - for i, (tail, path) in enumerate(zip(props["offsets"], props["paths"])): + for i, (pivot, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) - if len(verts) < 6 or not np.all(np.isfinite(tail)): + if len(verts) < 6 or not np.all(np.isfinite(pivot)): continue - tail_px = np.asarray(trans.transform(tail)) + pivot_px = np.asarray(trans.transform(pivot)) tip_offset = verts[3] # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way - tip_px = np.clip(tail_px + tip_offset, box_min, box_max) + tip_px = np.clip(pivot_px + tip_offset, box_min, box_max) tip = trans.inverted().transform(tip_px) + if q.pivot == "middle": + tail_px = pivot_px - tip_offset + else: + tail_px = pivot_px color = _per_path(facecolors, i, "rgba(0,0,0,0)") length = np.hypot(*tip_offset) if length > 0: diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index d4e75fe3fc..2ebdafe3b1 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1510,3 +1510,22 @@ def test_quiver_nan_tails_convert(): plotly_fig = tls.mpl_to_plotly(fig) assert len(plotly_fig.layout.annotations) == 1 + + +def test_quiver_mid_pivot_converts(): + """Quiver arrows with pivot 'mid' convert to annotations centered on the + grid points.""" + fig, ax = plt.subplots() + q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], pivot="mid") + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == 2 + tip_px = q.get_transform().transform(q.get_paths()[0].vertices[3]) + pivot_px = q.get_offset_transform().transform(q.get_offsets()[0]) + tip_data = q.get_offset_transform().inverted().transform(pivot_px + tip_px) + assert abs(anns[0].x - tip_data[0]) < 1e-6 + assert abs(anns[0].y - tip_data[1]) < 1e-6 + assert abs(anns[0].ax + 2 * tip_px[0]) < 1e-6 + assert abs(anns[0].ay - 2 * tip_px[1]) < 1e-6 From 9de2996ea4407192687cbf44af0aad2cd87f5679 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Wed, 7 Oct 2026 14:41:09 +0000 Subject: [PATCH 10/16] Support quiver arrows with pivot 'tip' --- plotly/matplotlylib/renderer.py | 19 ++++++++++--------- plotly/matplotlylib/tests/test_renderer.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 3ae2e0cc48..210477dc01 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -734,6 +734,7 @@ def draw_path_collection(self, **props): if isinstance(props["mplobj"], mquiver.Quiver) and props["mplobj"].pivot in ( "tail", "middle", + "tip", ): self.msg += " Drawing a quiver as arrow annotations\n" self._draw_quiver(props) @@ -875,22 +876,22 @@ def _draw_quiver(self, props): annotations = [] for i, (pivot, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) - if len(verts) < 6 or not np.all(np.isfinite(pivot)): + if len(verts) < 7 or not np.all(np.isfinite(pivot)): continue pivot_px = np.asarray(trans.transform(pivot)) - tip_offset = verts[3] + # the tail is the midpoint of the tail cap, the tip is vertex 3 + tail_vertex = (verts[0] + verts[6]) / 2 + tip_vertex = verts[3] + arrow_vector = tip_vertex - tail_vertex # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way - tip_px = np.clip(pivot_px + tip_offset, box_min, box_max) + tip_px = np.clip(pivot_px + tip_vertex, box_min, box_max) tip = trans.inverted().transform(tip_px) - if q.pivot == "middle": - tail_px = pivot_px - tip_offset - else: - tail_px = pivot_px + tail_px = pivot_px + tail_vertex color = _per_path(facecolors, i, "rgba(0,0,0,0)") - length = np.hypot(*tip_offset) + length = np.hypot(*arrow_vector) if length > 0: - along = tip_offset / length + along = arrow_vector / length perp = np.array([-along[1], along[0]]) shaft_width = abs(verts[1] @ perp) + abs(verts[5] @ perp) head_width = abs(verts[2] @ perp) + abs(verts[4] @ perp) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 2ebdafe3b1..00291faa6e 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1529,3 +1529,24 @@ def test_quiver_mid_pivot_converts(): assert abs(anns[0].y - tip_data[1]) < 1e-6 assert abs(anns[0].ax + 2 * tip_px[0]) < 1e-6 assert abs(anns[0].ay - 2 * tip_px[1]) < 1e-6 + + +def test_quiver_tip_pivot_converts(): + """Quiver arrows with pivot 'tip' convert to annotations anchored at the + grid points.""" + fig, ax = plt.subplots() + q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], pivot="tip") + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == 2 + verts = q.get_paths()[0].vertices + tip_px = q.get_transform().transform(verts[3]) + tail_px = q.get_transform().transform((verts[0] + verts[6]) / 2) + vector_px = tip_px - tail_px + pivot = q.get_offsets()[0] + assert abs(anns[0].x - pivot[0]) < 1e-6 + assert abs(anns[0].y - pivot[1]) < 1e-6 + assert abs(anns[0].ax + vector_px[0]) < 1e-6 + assert abs(anns[0].ay - vector_px[1]) < 1e-6 From b3ce303790e5ba4621e472ac1bc2a09e48bc84fb Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 17:36:03 +0000 Subject: [PATCH 11/16] Place quiver arrows correctly on log axes --- plotly/matplotlylib/renderer.py | 20 ++++++++++++++------ plotly/matplotlylib/tests/test_renderer.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 210477dc01..ac98393cdd 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -863,9 +863,11 @@ def _draw_quiver(self, props): """Draw a matplotlib Quiver collection as layout annotations with arrows. The exporter reports the arrow path vertices in pixels relative to the pivot point, and the pivot positions as data - coordinates. The annotation arrowhead lands on the annotation - position, so each annotation is anchored at the arrow tip in data - coordinates and offset back to the tail in pixels.""" + coordinates, or as display pixels when the offset transform is + nonlinear (e.g. on log axes). The annotation arrowhead lands on the + annotation position, so each annotation is anchored at the arrow tip + in data coordinates (log10 of the data on plotly log axes) and + offset back to the tail in pixels.""" q = props["mplobj"] trans = q.get_offset_transform() ax = self.current_mpl_ax @@ -873,12 +875,18 @@ def _draw_quiver(self, props): box_max = np.asarray(trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) axis_ref = str(self.axis_ct) + log_x = self.plotly_fig["layout"]["xaxis" + axis_ref]["type"] == "log" + log_y = self.plotly_fig["layout"]["yaxis" + axis_ref]["type"] == "log" + pivots_in_pixels = props["offset_coordinates"] == "display" annotations = [] for i, (pivot, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) if len(verts) < 7 or not np.all(np.isfinite(pivot)): continue - pivot_px = np.asarray(trans.transform(pivot)) + if pivots_in_pixels: + pivot_px = np.asarray(pivot, dtype=float) + else: + pivot_px = np.asarray(trans.transform(pivot)) # the tail is the midpoint of the tail cap, the tip is vertex 3 tail_vertex = (verts[0] + verts[6]) / 2 tip_vertex = verts[3] @@ -903,8 +911,8 @@ def _draw_quiver(self, props): arrowsize = max(0.3, head_width / (5.0 * arrowwidth)) annotations.append( go.layout.Annotation( - x=tip[0], - y=tip[1], + x=np.log10(tip[0]) if log_x else tip[0], + y=np.log10(tip[1]) if log_y else tip[1], xref="x{0}".format(axis_ref), yref="y{0}".format(axis_ref), text="", diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 00291faa6e..04256c3696 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1550,3 +1550,23 @@ def test_quiver_tip_pivot_converts(): assert abs(anns[0].y - pivot[1]) < 1e-6 assert abs(anns[0].ax + vector_px[0]) < 1e-6 assert abs(anns[0].ay - vector_px[1]) < 1e-6 + + +def test_quiver_log_axis_arrows_start_at_data_points(): + """Quiver arrows on a log x axis start at their data points, with the + annotation x given in log10 units as plotly log axes expect.""" + fig, ax = plt.subplots() + ax.quiver([1, 10, 100], [1, 2, 3], [1, 1, 1], [1, 0, 1]) + ax.set_xscale("log") + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert plotly_fig.layout.xaxis.type == "log" + assert len(anns) == 3 + for ann, log_x, y in zip(anns, [0, 1, 2], [1, 2, 3]): + # the annotation sits at the arrow tip, a short way from the tail + assert log_x <= ann.x < log_x + 0.5 + assert y <= ann.y < y + 0.5 + assert abs(ann.ax) < 100 + assert abs(ann.ay) < 100 From ccc55c4e07df42625e9baf2bc03f44c96c23b3e2 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 17:42:44 +0000 Subject: [PATCH 12/16] Place quiver arrows given in axes coordinates --- plotly/matplotlylib/renderer.py | 34 ++++++++++++---------- plotly/matplotlylib/tests/test_renderer.py | 21 +++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index ac98393cdd..17cb8b69d7 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -862,31 +862,33 @@ def flush(): def _draw_quiver(self, props): """Draw a matplotlib Quiver collection as layout annotations with arrows. The exporter reports the arrow path vertices in pixels - relative to the pivot point, and the pivot positions as data - coordinates, or as display pixels when the offset transform is - nonlinear (e.g. on log axes). The annotation arrowhead lands on the - annotation position, so each annotation is anchored at the arrow tip - in data coordinates (log10 of the data on plotly log axes) and - offset back to the tail in pixels.""" - q = props["mplobj"] - trans = q.get_offset_transform() + relative to the pivot point, and the pivot positions in data, axes, + figure or display coordinates, as given by "offset_coordinates" + (display pixels when the offset transform is nonlinear, e.g. on log + axes). The annotation arrowhead lands on the annotation position, so + each annotation is anchored at the arrow tip in data coordinates + (log10 of the data on plotly log axes) and offset back to the tail in + pixels.""" ax = self.current_mpl_ax - box_min = np.asarray(trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) - box_max = np.asarray(trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) + pivot_trans = { + "data": ax.transData, + "axes": ax.transAxes, + "figure": ax.figure.transFigure, + "display": transforms.IdentityTransform(), + }[props["offset_coordinates"]] + data_trans = ax.transData + box_min = np.asarray(data_trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) + box_max = np.asarray(data_trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) axis_ref = str(self.axis_ct) log_x = self.plotly_fig["layout"]["xaxis" + axis_ref]["type"] == "log" log_y = self.plotly_fig["layout"]["yaxis" + axis_ref]["type"] == "log" - pivots_in_pixels = props["offset_coordinates"] == "display" annotations = [] for i, (pivot, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) if len(verts) < 7 or not np.all(np.isfinite(pivot)): continue - if pivots_in_pixels: - pivot_px = np.asarray(pivot, dtype=float) - else: - pivot_px = np.asarray(trans.transform(pivot)) + pivot_px = np.asarray(pivot_trans.transform(pivot), dtype=float) # the tail is the midpoint of the tail cap, the tip is vertex 3 tail_vertex = (verts[0] + verts[6]) / 2 tip_vertex = verts[3] @@ -894,7 +896,7 @@ def _draw_quiver(self, props): # matplotlib clips arrowheads at the axes box, so clamp the # arrowhead position into the axes range the same way tip_px = np.clip(pivot_px + tip_vertex, box_min, box_max) - tip = trans.inverted().transform(tip_px) + tip = data_trans.inverted().transform(tip_px) tail_px = pivot_px + tail_vertex color = _per_path(facecolors, i, "rgba(0,0,0,0)") length = np.hypot(*arrow_vector) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 04256c3696..2446e22f1b 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1570,3 +1570,24 @@ def test_quiver_log_axis_arrows_start_at_data_points(): assert y <= ann.y < y + 0.5 assert abs(ann.ax) < 100 assert abs(ann.ay) < 100 + + +def test_quiver_axes_coordinates_arrows_start_at_their_positions(): + """Quiver arrows placed in axes coordinates start at the matching data + positions.""" + fig, ax = plt.subplots() + ax.quiver([0.2, 0.5], [0.2, 0.5], [1, 1], [1, 0], transform=ax.transAxes) + ax.set_xlim(10, 20) + ax.set_ylim(10, 20) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == 2 + # axes positions (0.2, 0.2) and (0.5, 0.5) are data (12, 12) and (15, 15) + for ann, start in zip(anns, [12, 15]): + # the annotation sits at the arrow tip, a short way from the tail + assert start <= ann.x < start + 2 + assert start <= ann.y < start + 2 + assert abs(ann.ax) < 100 + assert abs(ann.ay) < 100 From fda39a021c595bc522289f538835d4d98ba9a080 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 17:47:40 +0000 Subject: [PATCH 13/16] Color outline-only quiver arrows with the edge color --- plotly/matplotlylib/renderer.py | 12 ++++++++- plotly/matplotlylib/tests/test_renderer.py | 29 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index 17cb8b69d7..c61196160c 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -880,6 +880,9 @@ def _draw_quiver(self, props): box_min = np.asarray(data_trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) box_max = np.asarray(data_trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) + face_rgba = np.asarray(props["styles"]["facecolor"], dtype=float).reshape(-1, 4) + edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) + linewidths = np.atleast_1d(props["styles"]["linewidth"]) axis_ref = str(self.axis_ct) log_x = self.plotly_fig["layout"]["xaxis" + axis_ref]["type"] == "log" log_y = self.plotly_fig["layout"]["yaxis" + axis_ref]["type"] == "log" @@ -898,7 +901,14 @@ def _draw_quiver(self, props): tip_px = np.clip(pivot_px + tip_vertex, box_min, box_max) tip = data_trans.inverted().transform(tip_px) tail_px = pivot_px + tail_vertex - color = _per_path(facecolors, i, "rgba(0,0,0,0)") + # plotly arrows have a single color: the face color, or the edge + # color for arrows drawn only as outlines + if _per_path(face_rgba, i, (0, 0, 0, 0))[3] > 0: + color = _per_path(facecolors, i, "rgba(0,0,0,0)") + elif _per_path(linewidths, i, 0) > 0: + color = _per_path(edgecolors, i, "rgba(0,0,0,0)") + else: + color = "rgba(0,0,0,0)" length = np.hypot(*arrow_vector) if length > 0: along = arrow_vector / length diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 2446e22f1b..b7db0aa57e 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1591,3 +1591,32 @@ def test_quiver_axes_coordinates_arrows_start_at_their_positions(): assert start <= ann.y < start + 2 assert abs(ann.ax) < 100 assert abs(ann.ay) < 100 + + +def test_quiver_outline_arrows_use_edge_color(): + """Quiver arrows drawn as outlines (facecolor "none") take their arrow + color from the edge color.""" + fig, ax = plt.subplots() + ax.quiver( + [0, 1], [0, 1], [1, 1], [1, 0], facecolor="none", edgecolor="r", linewidth=1 + ) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert len(anns) == 2 + assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)"] * 2 + + +def test_quiver_arrows_without_face_or_edge_width_are_transparent(): + """Quiver arrows with facecolor "none" and zero edge width, which are + invisible in matplotlib, get a transparent arrow color.""" + fig, ax = plt.subplots() + ax.quiver( + [0, 1], [0, 1], [1, 1], [1, 0], facecolor="none", edgecolor="r", linewidth=0 + ) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert [a.arrowcolor for a in anns] == ["rgba(0,0,0,0)"] * 2 From 429c9c1dab400feafe09fc4f8df596517ede1ff8 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 17:56:32 +0000 Subject: [PATCH 14/16] Convert quiver annotation x to date strings on date axes --- plotly/matplotlylib/renderer.py | 2 +- plotly/matplotlylib/tests/test_renderer.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index c61196160c..b33e7ea491 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -923,7 +923,7 @@ def _draw_quiver(self, props): arrowsize = max(0.3, head_width / (5.0 * arrowwidth)) annotations.append( go.layout.Annotation( - x=np.log10(tip[0]) if log_x else tip[0], + x=np.log10(tip[0]) if log_x else self._convert_x_dates([tip[0]])[0], y=np.log10(tip[1]) if log_y else tip[1], xref="x{0}".format(axis_ref), yref="y{0}".format(axis_ref), diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index b7db0aa57e..457517f8a3 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1620,3 +1620,20 @@ def test_quiver_arrows_without_face_or_edge_width_are_transparent(): anns = plotly_fig.layout.annotations assert [a.arrowcolor for a in anns] == ["rgba(0,0,0,0)"] * 2 + + +def test_quiver_date_axis_arrows_use_date_strings(): + """Quiver arrows on a date x axis give their annotation x as date strings + on the day of each arrow.""" + dates = [datetime.datetime(2020, 1, day) for day in (1, 2, 3)] + fig, ax = plt.subplots() + ax.quiver(dates, [0, 1, 2], [1, 1, 1], [1, 0, 1]) + + plotly_fig = tls.mpl_to_plotly(fig) + + anns = plotly_fig.layout.annotations + assert plotly_fig.layout.xaxis.type == "date" + assert len(anns) == 3 + for ann, day in zip(anns, ["2020-01-01", "2020-01-02", "2020-01-03"]): + assert isinstance(ann.x, str) + assert ann.x.startswith(day) From da9081fb00b31632a8125a9cd8dd7397f31f6379 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 21:27:21 +0000 Subject: [PATCH 15/16] Convert quiver plots to quiver traces --- CHANGELOG.md | 2 +- plotly/matplotlylib/renderer.py | 101 +++++---- plotly/matplotlylib/tests/test_renderer.py | 251 +++++++++++---------- 3 files changed, 192 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ca447dae..a5de2130b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - Add support for converting matplotlib reference lines (`axhline`, `axvline`, and horizontal/vertical `axline`) as well as axes-coordinate lines into Plotly layout shapes in `mpl_to_plotly`, including support for date axes [[#5719](https://github.com/plotly/plotly.py/pull/5719)], with thanks to @robertoffmoura for the contribution! -- Add support for converting matplotlib quiver plots (`plt.quiver`) to annotation arrows in `mpl_to_plotly`, with thanks to @robertoffmoura for the contribution! +- Add support for converting matplotlib quiver plots (`plt.quiver`) to quiver traces in `mpl_to_plotly`, with thanks to @robertoffmoura for the contribution! ### Fixed diff --git a/plotly/matplotlylib/renderer.py b/plotly/matplotlylib/renderer.py index b33e7ea491..17f8c890ef 100644 --- a/plotly/matplotlylib/renderer.py +++ b/plotly/matplotlylib/renderer.py @@ -736,7 +736,7 @@ def draw_path_collection(self, **props): "middle", "tip", ): - self.msg += " Drawing a quiver as arrow annotations\n" + self.msg += " Drawing a quiver as quiver traces\n" self._draw_quiver(props) elif props["offset_coordinates"] == "data": markerstyle = mpltools.get_markerstyle_from_collection(props) @@ -860,15 +860,14 @@ def flush(): flush() def _draw_quiver(self, props): - """Draw a matplotlib Quiver collection as layout annotations with - arrows. The exporter reports the arrow path vertices in pixels - relative to the pivot point, and the pivot positions in data, axes, - figure or display coordinates, as given by "offset_coordinates" + """Draw a matplotlib Quiver collection as plotly quiver traces, one + per arrow color. The exporter reports the arrow path vertices in + pixels relative to the pivot point, and the pivot positions in data, + axes, figure or display coordinates, as given by "offset_coordinates" (display pixels when the offset transform is nonlinear, e.g. on log - axes). The annotation arrowhead lands on the annotation position, so - each annotation is anchored at the arrow tip in data coordinates - (log10 of the data on plotly log axes) and offset back to the tail in - pixels.""" + axes). Each arrow is anchored at its tail in data coordinates, with + u/v reaching its tip in axis units: data units on linear axes, + decades on log axes and milliseconds on date axes.""" ax = self.current_mpl_ax pivot_trans = { "data": ax.transData, @@ -876,9 +875,7 @@ def _draw_quiver(self, props): "figure": ax.figure.transFigure, "display": transforms.IdentityTransform(), }[props["offset_coordinates"]] - data_trans = ax.transData - box_min = np.asarray(data_trans.transform((ax.get_xlim()[0], ax.get_ylim()[0]))) - box_max = np.asarray(data_trans.transform((ax.get_xlim()[1], ax.get_ylim()[1]))) + to_data = ax.transData.inverted() facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"]) face_rgba = np.asarray(props["styles"]["facecolor"], dtype=float).reshape(-1, 4) edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"]) @@ -886,7 +883,9 @@ def _draw_quiver(self, props): axis_ref = str(self.axis_ct) log_x = self.plotly_fig["layout"]["xaxis" + axis_ref]["type"] == "log" log_y = self.plotly_fig["layout"]["yaxis" + axis_ref]["type"] == "log" - annotations = [] + arrows = {} # arrow color -> (tails, tips) in data coordinates + shaft_width = 0.0 + head_width = 0.0 for i, (pivot, path) in enumerate(zip(props["offsets"], props["paths"])): verts = np.asarray(path[0], dtype=float) if len(verts) < 7 or not np.all(np.isfinite(pivot)): @@ -895,12 +894,6 @@ def _draw_quiver(self, props): # the tail is the midpoint of the tail cap, the tip is vertex 3 tail_vertex = (verts[0] + verts[6]) / 2 tip_vertex = verts[3] - arrow_vector = tip_vertex - tail_vertex - # matplotlib clips arrowheads at the axes box, so clamp the - # arrowhead position into the axes range the same way - tip_px = np.clip(pivot_px + tip_vertex, box_min, box_max) - tip = data_trans.inverted().transform(tip_px) - tail_px = pivot_px + tail_vertex # plotly arrows have a single color: the face color, or the edge # color for arrows drawn only as outlines if _per_path(face_rgba, i, (0, 0, 0, 0))[3] > 0: @@ -909,35 +902,61 @@ def _draw_quiver(self, props): color = _per_path(edgecolors, i, "rgba(0,0,0,0)") else: color = "rgba(0,0,0,0)" + tails, tips = arrows.setdefault(color, ([], [])) + tails.append(to_data.transform(pivot_px + tail_vertex)) + tips.append(to_data.transform(pivot_px + tip_vertex)) + arrow_vector = tip_vertex - tail_vertex length = np.hypot(*arrow_vector) if length > 0: along = arrow_vector / length perp = np.array([-along[1], along[0]]) - shaft_width = abs(verts[1] @ perp) + abs(verts[5] @ perp) - head_width = abs(verts[2] @ perp) + abs(verts[4] @ perp) + shaft_width = max( + shaft_width, abs(verts[1] @ perp) + abs(verts[5] @ perp) + ) + head_width = max( + head_width, abs(verts[2] @ perp) + abs(verts[4] @ perp) + ) + line_width = max(0.1, shaft_width) + # plotly draws the head as two barbs 5.8 * arrowsize * line width long + # at 15 degrees to the shaft, stroked with the line width + arrowsize = max( + 0.3, + (head_width - line_width) / (2 * 5.8 * line_width * np.sin(np.pi / 12)), + ) + for color, (tails, tips) in arrows.items(): + tails = np.array(tails) + tips = np.array(tips) + if log_x: + u = np.log10(tips[:, 0]) - np.log10(tails[:, 0]) + elif self.x_is_mpl_date: + # matplotlib dates count days, plotly date axes milliseconds + u = (tips[:, 0] - tails[:, 0]) * 86400000 else: - shaft_width = 1.0 - head_width = 3.0 - arrowwidth = max(0.1, shaft_width) - # plotly draws the head 5x arrowwidth wide per unit arrowsize - arrowsize = max(0.3, head_width / (5.0 * arrowwidth)) - annotations.append( - go.layout.Annotation( - x=np.log10(tip[0]) if log_x else self._convert_x_dates([tip[0]])[0], - y=np.log10(tip[1]) if log_y else tip[1], - xref="x{0}".format(axis_ref), - yref="y{0}".format(axis_ref), - text="", - showarrow=True, - ax=tail_px[0] - tip_px[0], - ay=tip_px[1] - tail_px[1], - arrowhead=2, - arrowsize=arrowsize, - arrowwidth=arrowwidth, - arrowcolor=color, + u = tips[:, 0] - tails[:, 0] + if log_y: + v = np.log10(tips[:, 1]) - np.log10(tails[:, 1]) + else: + v = tips[:, 1] - tails[:, 1] + self.plotly_fig.add_trace( + go.Quiver( + x=self._convert_x_dates(list(tails[:, 0])), + y=tails[:, 1], + u=u, + v=v, + anchor="tail", + arrowref="data", + lengthmode="raw", + lengthfactor=1, + showlegend=False, + marker=go.quiver.Marker( + color=color, + arrowsize=arrowsize, + line=go.quiver.marker.Line(width=line_width), + ), + xaxis="x{0}".format(axis_ref), + yaxis="y{0}".format(axis_ref), ) ) - self.plotly_fig["layout"]["annotations"] += tuple(annotations) self.msg += " Heck yeah, I drew that quiver\n" def _draw_filled_path_collection(self, props): diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 457517f8a3..a708bd993a 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1394,9 +1394,21 @@ def test_mixed_style_lines_group_consecutive_matches(): assert dashes == ["5.55px,2.4px"] * 2 + ["solid"] * 2 +def _quiver_traces(plotly_fig): + return [t for t in plotly_fig.data if t.type == "quiver"] + + +def _arrow_px(q, i=0): + """Return the tail and tip of the i-th quiver arrow in pixels relative + to its pivot.""" + verts = q.get_paths()[i].vertices + trans = q.get_transform() + return trans.transform((verts[0] + verts[6]) / 2), trans.transform(verts[3]) + + def test_quiver_converts(): - """Quiver arrows convert to layout annotations with arrows anchored at - the arrow tails, pointing to the arrow tips.""" + """Quiver arrows convert to a quiver trace with each arrow anchored at + its tail and u/v reaching its tip in data units.""" x = np.arange(-2, 2.5, 1.0) X, Y = np.meshgrid(x, x) U = np.ones_like(X) @@ -1406,65 +1418,67 @@ def test_quiver_converts(): plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert len(anns) == X.size - assert anns[0].showarrow is True - assert anns[0].xref == "x" - assert anns[0].yref == "y" - # the annotation anchor (the arrowhead) sits at the arrow tip and the - # pixel offset points back to the tail - tip_px = q.get_transform().transform(q.get_paths()[0].vertices[3]) - tail_px = q.get_offset_transform().transform(q.get_offsets()[0]) - tip_data = q.get_offset_transform().inverted().transform(tail_px + tip_px) - assert abs(anns[0].x - tip_data[0]) < 1e-6 - assert abs(anns[0].y - tip_data[1]) < 1e-6 - assert abs(anns[0].ax + tip_px[0]) < 1e-6 - assert abs(anns[0].ay - tip_px[1]) < 1e-6 - assert tip_px[0] > 0 - assert abs(tip_px[1]) < 1e-6 - # no marker traces - assert all(t.mode != "markers" for t in plotly_fig.data) - - -def test_quiver_alpha_rides_in_arrow_color(): - """Quiver arrows with alpha export the alpha in the arrow color.""" - fig, ax = plt.subplots() - ax.quiver([0], [0], [1], [0], alpha=0.5) - - plotly_fig = tls.mpl_to_plotly(fig) - - anns = plotly_fig.layout.annotations - assert len(anns) == 1 - assert anns[0].opacity is None - assert anns[0].arrowcolor == "rgba(0,0,0,0.5)" + traces = _quiver_traces(plotly_fig) + assert len(traces) == 1 + trace = traces[0] + assert len(trace.x) == X.size + assert trace.xaxis == "x" + assert trace.yaxis == "y" + assert trace.anchor == "tail" + assert trace.arrowref == "data" + assert trace.lengthmode == "raw" + assert trace.lengthfactor == 1 + assert plotly_fig.layout.annotations == () + tail_px, tip_px = _arrow_px(q) + pivot_px = ax.transData.transform(q.get_offsets()[0]) + tail = ax.transData.inverted().transform(pivot_px + tail_px) + tip = ax.transData.inverted().transform(pivot_px + tip_px) + assert abs(trace.x[0] - tail[0]) < 1e-6 + assert abs(trace.y[0] - tail[1]) < 1e-6 + assert abs(trace.u[0] - (tip[0] - tail[0])) < 1e-6 + assert abs(trace.v[0] - (tip[1] - tail[1])) < 1e-6 + assert trace.u[0] > 0 def test_quiver_per_arrow_colors(): - """Quiver arrows keep their per-arrow colors.""" + """Quiver arrows of each color convert to their own quiver trace with + the same arrow sizes.""" fig, ax = plt.subplots() ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], color=["red", "blue"], linewidths=[1, 2]) plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)", "rgba(0,0,255,1.0)"] - assert anns[0].arrowwidth == anns[1].arrowwidth - assert anns[0].arrowsize == anns[1].arrowsize + traces = _quiver_traces(plotly_fig) + assert [t.marker.color for t in traces] == [ + "rgba(255,0,0,1.0)", + "rgba(0,0,255,1.0)", + ] + assert [t.x for t in traces] == [pytest.approx((0,)), pytest.approx((1,))] + assert traces[0].marker.line.width == traces[1].marker.line.width + assert traces[0].marker.arrowsize == traces[1].marker.arrowsize -def test_quiver_arrow_head_size_uses_headwidth(): - """Quiver arrow heads use the quiver's headwidth ratio.""" +@pytest.mark.parametrize("headwidth", [3, 6]) +def test_quiver_arrow_sizes_match_shaft_and_head_widths(headwidth): + """Quiver arrows take matplotlib's shaft width as the line width, and + an arrowsize that gives plotly's head (two barbs 5.8 * arrowsize * line + width long at 15 degrees to the shaft, plus the line width) matplotlib's + head width.""" fig, ax = plt.subplots() - ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], linewidths=[1, 4]) + q = ax.quiver([0], [0], [1], [0], headwidth=headwidth) plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert anns[0].arrowsize == anns[1].arrowsize - fig2, ax2 = plt.subplots() - ax2.quiver([0, 1], [0, 0], [1, 1], [0, 0], headwidth=6) - anns2 = tls.mpl_to_plotly(fig2).layout.annotations - assert abs(anns2[0].arrowsize / anns[0].arrowsize - 2.0) < 1e-9 + marker = _quiver_traces(plotly_fig)[0].marker + verts = q.get_transform().transform(q.get_paths()[0].vertices) + shaft_width = verts[1][1] - verts[5][1] + head_width = verts[2][1] - verts[4][1] + assert abs(marker.line.width - shaft_width) < 1e-6 + plotly_head_width = ( + 2 * 5.8 * marker.arrowsize * marker.line.width * np.sin(np.pi / 12) + + marker.line.width + ) + assert abs(plotly_head_width - head_width) < 1e-6 def test_quiver_masked_vectors_convert(): @@ -1479,7 +1493,7 @@ def test_quiver_masked_vectors_convert(): plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.layout.annotations) == 1 + assert [len(t.x) for t in _quiver_traces(plotly_fig)] == [1] def test_quiver_nan_vectors_convert(): @@ -1489,17 +1503,17 @@ def test_quiver_nan_vectors_convert(): plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.layout.annotations) == 1 + assert [len(t.x) for t in _quiver_traces(plotly_fig)] == [1] def test_quiver_zero_vectors_convert(): - """Quiver arrows with zero-length vectors convert without arrows.""" + """A quiver of zero-length vectors converts to zero quiver traces.""" fig, ax = plt.subplots() ax.quiver([0], [0], [0], [0]) plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.layout.annotations) == 0 + assert _quiver_traces(plotly_fig) == [] def test_quiver_nan_tails_convert(): @@ -1509,67 +1523,77 @@ def test_quiver_nan_tails_convert(): plotly_fig = tls.mpl_to_plotly(fig) - assert len(plotly_fig.layout.annotations) == 1 + assert [len(t.x) for t in _quiver_traces(plotly_fig)] == [1] def test_quiver_mid_pivot_converts(): - """Quiver arrows with pivot 'mid' convert to annotations centered on the - grid points.""" + """Quiver arrows with pivot 'mid' are centered on the grid points.""" fig, ax = plt.subplots() q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], pivot="mid") plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert len(anns) == 2 - tip_px = q.get_transform().transform(q.get_paths()[0].vertices[3]) - pivot_px = q.get_offset_transform().transform(q.get_offsets()[0]) - tip_data = q.get_offset_transform().inverted().transform(pivot_px + tip_px) - assert abs(anns[0].x - tip_data[0]) < 1e-6 - assert abs(anns[0].y - tip_data[1]) < 1e-6 - assert abs(anns[0].ax + 2 * tip_px[0]) < 1e-6 - assert abs(anns[0].ay - 2 * tip_px[1]) < 1e-6 + trace = _quiver_traces(plotly_fig)[0] + assert len(trace.x) == 2 + for i in range(2): + center_x = trace.x[i] + trace.u[i] / 2 + center_y = trace.y[i] + trace.v[i] / 2 + assert abs(center_x - q.get_offsets()[i][0]) < 1e-6 + assert abs(center_y - q.get_offsets()[i][1]) < 1e-6 + assert trace.u[0] > 0 def test_quiver_tip_pivot_converts(): - """Quiver arrows with pivot 'tip' convert to annotations anchored at the - grid points.""" + """Quiver arrows with pivot 'tip' end at the grid points.""" fig, ax = plt.subplots() q = ax.quiver([0, 1], [0, 0], [1, 1], [0, 0], pivot="tip") plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert len(anns) == 2 - verts = q.get_paths()[0].vertices - tip_px = q.get_transform().transform(verts[3]) - tail_px = q.get_transform().transform((verts[0] + verts[6]) / 2) - vector_px = tip_px - tail_px - pivot = q.get_offsets()[0] - assert abs(anns[0].x - pivot[0]) < 1e-6 - assert abs(anns[0].y - pivot[1]) < 1e-6 - assert abs(anns[0].ax + vector_px[0]) < 1e-6 - assert abs(anns[0].ay - vector_px[1]) < 1e-6 + trace = _quiver_traces(plotly_fig)[0] + assert len(trace.x) == 2 + for i in range(2): + assert abs(trace.x[i] + trace.u[i] - q.get_offsets()[i][0]) < 1e-6 + assert abs(trace.y[i] + trace.v[i] - q.get_offsets()[i][1]) < 1e-6 + assert trace.u[0] > 0 + + +def test_quiver_arrows_past_the_axes_edge_keep_their_length(): + """Quiver arrows running past the axes edge keep their full length.""" + fig, ax = plt.subplots() + ax.quiver([1], [1], [1], [0], angles="xy", scale_units="xy", scale=1) + ax.set_xlim(0, 1.05) + ax.set_ylim(0, 2) + + plotly_fig = tls.mpl_to_plotly(fig) + + trace = _quiver_traces(plotly_fig)[0] + assert abs(trace.x[0] - 1) < 1e-6 + assert abs(trace.u[0] - 1) < 1e-6 def test_quiver_log_axis_arrows_start_at_data_points(): - """Quiver arrows on a log x axis start at their data points, with the - annotation x given in log10 units as plotly log axes expect.""" + """Quiver arrows on a log x axis start at their data points, with u in + decades as plotly log axes expect.""" fig, ax = plt.subplots() - ax.quiver([1, 10, 100], [1, 2, 3], [1, 1, 1], [1, 0, 1]) + q = ax.quiver([1, 10, 100], [1, 2, 3], [1, 1, 1], [1, 0, 1]) ax.set_xscale("log") plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations assert plotly_fig.layout.xaxis.type == "log" - assert len(anns) == 3 - for ann, log_x, y in zip(anns, [0, 1, 2], [1, 2, 3]): - # the annotation sits at the arrow tip, a short way from the tail - assert log_x <= ann.x < log_x + 0.5 - assert y <= ann.y < y + 0.5 - assert abs(ann.ax) < 100 - assert abs(ann.ay) < 100 + trace = _quiver_traces(plotly_fig)[0] + pivot_px = ax.transData.transform(q.get_offsets()[0]) + tail_px, tip_px = _arrow_px(q) + tail = ax.transData.inverted().transform(pivot_px + tail_px) + tip = ax.transData.inverted().transform(pivot_px + tip_px) + assert abs(trace.u[0] - (np.log10(tip[0]) - np.log10(tail[0]))) < 1e-6 + for x, y, u, start_x, start_y in zip( + trace.x, trace.y, trace.u, [1, 10, 100], [1, 2, 3] + ): + assert abs(x / start_x - 1) < 1e-6 + assert abs(y - start_y) < 1e-6 + assert 0 < u < 0.5 def test_quiver_axes_coordinates_arrows_start_at_their_positions(): @@ -1582,15 +1606,12 @@ def test_quiver_axes_coordinates_arrows_start_at_their_positions(): plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert len(anns) == 2 + trace = _quiver_traces(plotly_fig)[0] # axes positions (0.2, 0.2) and (0.5, 0.5) are data (12, 12) and (15, 15) - for ann, start in zip(anns, [12, 15]): - # the annotation sits at the arrow tip, a short way from the tail - assert start <= ann.x < start + 2 - assert start <= ann.y < start + 2 - assert abs(ann.ax) < 100 - assert abs(ann.ay) < 100 + for x, y, u, start in zip(trace.x, trace.y, trace.u, [12, 15]): + assert abs(x - start) < 1e-6 + assert abs(y - start) < 1e-6 + assert 0 < u < 2 def test_quiver_outline_arrows_use_edge_color(): @@ -1603,37 +1624,27 @@ def test_quiver_outline_arrows_use_edge_color(): plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations - assert len(anns) == 2 - assert [a.arrowcolor for a in anns] == ["rgba(255,0,0,1.0)"] * 2 - - -def test_quiver_arrows_without_face_or_edge_width_are_transparent(): - """Quiver arrows with facecolor "none" and zero edge width, which are - invisible in matplotlib, get a transparent arrow color.""" - fig, ax = plt.subplots() - ax.quiver( - [0, 1], [0, 1], [1, 1], [1, 0], facecolor="none", edgecolor="r", linewidth=0 - ) - - plotly_fig = tls.mpl_to_plotly(fig) - - anns = plotly_fig.layout.annotations - assert [a.arrowcolor for a in anns] == ["rgba(0,0,0,0)"] * 2 + traces = _quiver_traces(plotly_fig) + assert [t.marker.color for t in traces] == ["rgba(255,0,0,1.0)"] + assert len(traces[0].x) == 2 def test_quiver_date_axis_arrows_use_date_strings(): - """Quiver arrows on a date x axis give their annotation x as date strings - on the day of each arrow.""" + """Quiver arrows on a date x axis start at date strings on the day of + each arrow, with u in milliseconds as plotly date axes expect.""" dates = [datetime.datetime(2020, 1, day) for day in (1, 2, 3)] fig, ax = plt.subplots() - ax.quiver(dates, [0, 1, 2], [1, 1, 1], [1, 0, 1]) + q = ax.quiver(dates, [0, 1, 2], [1, 1, 1], [1, 0, 1]) plotly_fig = tls.mpl_to_plotly(fig) - anns = plotly_fig.layout.annotations assert plotly_fig.layout.xaxis.type == "date" - assert len(anns) == 3 - for ann, day in zip(anns, ["2020-01-01", "2020-01-02", "2020-01-03"]): - assert isinstance(ann.x, str) - assert ann.x.startswith(day) + trace = _quiver_traces(plotly_fig)[0] + pivot_px = ax.transData.transform(q.get_offsets()[0]) + tail_px, tip_px = _arrow_px(q) + tail = ax.transData.inverted().transform(pivot_px + tail_px) + tip = ax.transData.inverted().transform(pivot_px + tip_px) + assert abs(trace.u[0] - (tip[0] - tail[0]) * 86400000) < 1e-3 + for x, day in zip(trace.x, ["2020-01-01", "2020-01-02", "2020-01-03"]): + assert isinstance(x, str) + assert x.startswith(day) From b872265f8e3ec2ecd20f28f0825a6af1521cbd5d Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Fri, 23 Oct 2026 22:12:24 +0000 Subject: [PATCH 16/16] Test colormapped quiver arrow colors --- plotly/matplotlylib/tests/test_renderer.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index a708bd993a..e7c9bb9175 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -1648,3 +1648,30 @@ def test_quiver_date_axis_arrows_use_date_strings(): for x, day in zip(trace.x, ["2020-01-01", "2020-01-02", "2020-01-03"]): assert isinstance(x, str) assert x.startswith(day) + + +def test_quiver_colormapped_arrows_keep_their_colors(): + """Colormapped quiver arrows convert to one quiver trace per color, with + each arrow starting at its grid point in its matplotlib color.""" + x = np.linspace(-2, 2, 8) + X, Y = np.meshgrid(x, x) + fig, ax = plt.subplots() + q = ax.quiver(X, Y, -Y, X, Y + X / 8) + + plotly_fig = tls.mpl_to_plotly(fig) + + expected = { + (round(px, 6), round(py, 6)): "rgba({},{},{},{})".format( + int(r * 255), int(g * 255), int(b * 255), a + ) + for (px, py), (r, g, b, a) in zip(q.get_offsets(), q.get_facecolors()) + } + traces = _quiver_traces(plotly_fig) + assert len(traces) > 1 + assert sum(len(t.x) for t in traces) == X.size + for trace in traces: + for tx, ty, u, v in zip(trace.x, trace.y, trace.u, trace.v): + assert trace.marker.color == expected[(round(tx, 6), round(ty, 6))] + # the field rotates counterclockwise: u follows -y and v follows x + assert np.sign(u) == np.sign(-ty) + assert np.sign(v) == np.sign(tx)