From 37aedc180141869fecec9a4dbd571f976a13586a Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Sun, 15 Jun 2025 18:21:35 +0100 Subject: [PATCH 1/9] Add support for overriding x tick with non arithmetic progression values --- plotly/matplotlylib/mpltools.py | 15 ++++++++++++-- plotly/matplotlylib/tests/test_renderer.py | 24 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 4875c2babd..532088cda4 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -436,9 +436,9 @@ def prep_ticks(ax, index, ax_type, props): tick0 = tickvalues[0] dticks = [ round(tickvalues[i] - tickvalues[i - 1], 12) - for i in range(1, len(tickvalues) - 1) + for i in range(1, len(tickvalues)) ] - if all([dticks[i] == dticks[i - 1] for i in range(1, len(dticks) - 1)]): + if all([dticks[i] == dticks[i - 1] for i in range(1, len(dticks))]): dtick = tickvalues[1] - tickvalues[0] else: warnings.warn( @@ -448,6 +448,8 @@ def prep_ticks(ax, index, ax_type, props): raise TypeError except (IndexError, TypeError): axis_dict["nticks"] = props["axes"][index]["nticks"] + if props["axes"][index]["tickvalues"] is not None: + axis_dict["tickvals"] = props["axes"][index]["tickvalues"] else: axis_dict["tick0"] = tick0 axis_dict["dtick"] = dtick @@ -496,6 +498,15 @@ def prep_ticks(ax, index, ax_type, props): if formatter == "LogFormatterMathtext": axis_dict["exponentformat"] = "e" + elif ( + formatter == "FuncFormatter" and props["axes"][index]["tickformat"] is not None + ): + to_remove = ["dticktickmode"] + for key in to_remove: + if key in axis_dict: + axis_dict.pop(key) + axis_dict["ticktext"] = props["axes"][index]["tickformat"] + axis_dict["tickvals"] = props["axes"][index]["tickvalues"] return axis_dict diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 726dd507d3..33fdb38406 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -244,3 +244,27 @@ def test_line_color_is_valid_plotly_color(): plotly_fig = tls.mpl_to_plotly(fig) assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 1)" + + +def test_non_arithmetic_progression_xtickvals(): + xticks = [0.01, 0.53, 0.75] + plt.figure() + plt.plot([0, 1], [0, 1]) + plt.xticks(xticks) + + plotly_fig = tls.mpl_to_plotly(plt.gcf()) + + assert plotly_fig.layout.xaxis.tickvals == tuple(xticks) + + +def test_non_arithmetic_progression_xticktext(): + xtickvals = [0.01, 0.53, 0.75] + xticktext = ["Baseline", "param = 1", "param = 2"] + plt.figure() + plt.plot([0, 1], [0, 1]) + plt.xticks(xtickvals, xticktext) + + plotly_fig = tls.mpl_to_plotly(plt.gcf()) + + assert plotly_fig.layout.xaxis.tickvals == tuple(xtickvals) + assert plotly_fig.layout.xaxis.ticktext == tuple(xticktext) From aa6e32ac63eee4ba5e0fd96ad5590fd00e462920 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 19:09:51 +0100 Subject: [PATCH 2/9] Remove dtick and tickmode when overriding ticks with custom values --- plotly/matplotlylib/mpltools.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 532088cda4..cc8ce49364 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -501,10 +501,8 @@ def prep_ticks(ax, index, ax_type, props): elif ( formatter == "FuncFormatter" and props["axes"][index]["tickformat"] is not None ): - to_remove = ["dticktickmode"] - for key in to_remove: - if key in axis_dict: - axis_dict.pop(key) + axis_dict.pop("dtick", None) + axis_dict.pop("tickmode", None) axis_dict["ticktext"] = props["axes"][index]["tickformat"] axis_dict["tickvals"] = props["axes"][index]["tickvalues"] return axis_dict From c96ca8703987797140e0085254497b0cf4e154f4 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 19:34:31 +0100 Subject: [PATCH 3/9] Use `fig, ax = plt.subplots()` in tests --- plotly/matplotlylib/tests/test_renderer.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 33fdb38406..4a4b55ceae 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -248,11 +248,11 @@ def test_line_color_is_valid_plotly_color(): def test_non_arithmetic_progression_xtickvals(): xticks = [0.01, 0.53, 0.75] - plt.figure() - plt.plot([0, 1], [0, 1]) - plt.xticks(xticks) + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xticks(xticks) - plotly_fig = tls.mpl_to_plotly(plt.gcf()) + plotly_fig = tls.mpl_to_plotly(fig) assert plotly_fig.layout.xaxis.tickvals == tuple(xticks) @@ -260,11 +260,11 @@ def test_non_arithmetic_progression_xtickvals(): def test_non_arithmetic_progression_xticktext(): xtickvals = [0.01, 0.53, 0.75] xticktext = ["Baseline", "param = 1", "param = 2"] - plt.figure() - plt.plot([0, 1], [0, 1]) - plt.xticks(xtickvals, xticktext) + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xticks(xtickvals, xticktext) - plotly_fig = tls.mpl_to_plotly(plt.gcf()) + plotly_fig = tls.mpl_to_plotly(fig) assert plotly_fig.layout.xaxis.tickvals == tuple(xtickvals) assert plotly_fig.layout.xaxis.ticktext == tuple(xticktext) From e541091ead4adab99a1cb2ad1ac32cedfe23311b Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 20:54:39 +0100 Subject: [PATCH 4/9] Convert custom tick values for date axes --- plotly/matplotlylib/mpltools.py | 13 +++++++------ plotly/matplotlylib/tests/test_renderer.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index cc8ce49364..8d23cabc77 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -487,14 +487,15 @@ def prep_ticks(ax, index, ax_type, props): formatter = axis.get_major_formatter().__class__.__name__ if ax_type == "x" and "DateFormatter" in formatter: axis_dict["type"] = "date" - try: - axis_dict["tick0"] = mpl_dates_to_datestrings(axis_dict["tick0"], formatter) - except KeyError: - pass - finally: + tickvalues = props["axes"][index]["tickvalues"] + if tickvalues is not None: + # custom ticks: export the exact locations and drop the + # arithmetic tick0/dtick spec, which plotly would use instead + axis_dict["tickvals"] = mpl_dates_to_datestrings(tickvalues, formatter) + axis_dict.pop("tick0", None) axis_dict.pop("dtick", None) axis_dict.pop("tickmode", None) - axis_dict["range"] = mpl_dates_to_datestrings(props["xlim"], formatter) + axis_dict["range"] = mpl_dates_to_datestrings(props["xlim"], formatter) if formatter == "LogFormatterMathtext": axis_dict["exponentformat"] = "e" diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 4a4b55ceae..345428247d 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -268,3 +268,23 @@ def test_non_arithmetic_progression_xticktext(): assert plotly_fig.layout.xaxis.tickvals == tuple(xtickvals) assert plotly_fig.layout.xaxis.ticktext == tuple(xticktext) + + +def test_custom_date_xtickvals_are_converted(): + """Custom tick values on a date axis must be converted to date strings, + not left as raw matplotlib date numbers or datetime objects.""" + dates = [ + datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(10) + ] + fig, ax = plt.subplots() + ax.plot(dates, np.random.rand(10)) + ax.set_xticks(dates[::3]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == ( + "2023-01-01 00:00:00", + "2023-01-04 00:00:00", + "2023-01-07 00:00:00", + "2023-01-10 00:00:00", + ) From bb38731b7296f77a7da390417854df1e77a7ef17 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:17:02 +0100 Subject: [PATCH 5/9] Add tests for custom tick values on date axes --- plotly/matplotlylib/tests/test_renderer.py | 42 ++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 345428247d..fc21ca23bf 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -273,9 +273,7 @@ def test_non_arithmetic_progression_xticktext(): def test_custom_date_xtickvals_are_converted(): """Custom tick values on a date axis must be converted to date strings, not left as raw matplotlib date numbers or datetime objects.""" - dates = [ - datetime.datetime(2023, 1, 1) + datetime.timedelta(days=i) for i in range(10) - ] + dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)] fig, ax = plt.subplots() ax.plot(dates, np.random.rand(10)) ax.set_xticks(dates[::3]) @@ -288,3 +286,41 @@ def test_custom_date_xtickvals_are_converted(): "2023-01-07 00:00:00", "2023-01-10 00:00:00", ) + + +def test_uneven_custom_date_xtickvals_are_converted(): + """Unevenly spaced custom date ticks must be converted to date strings.""" + dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)] + ticks = [datetime.datetime(2023, 1, i) for i in [1, 3, 6, 10]] + fig, ax = plt.subplots() + ax.plot(dates, np.random.rand(10)) + ax.set_xticks(ticks) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == ( + "2023-01-01 00:00:00", + "2023-01-03 00:00:00", + "2023-01-06 00:00:00", + "2023-01-10 00:00:00", + ) + + +def test_custom_date_xtickvals_given_as_numbers_are_converted(): + """Custom date ticks given as matplotlib date numbers must be converted + to date strings.""" + import matplotlib.dates as mdates + + dates = [datetime.datetime(2023, 1, i) for i in range(1, 11)] + fig, ax = plt.subplots() + ax.plot(dates, np.random.rand(10)) + ax.set_xticks([mdates.date2num(d) for d in dates[::3]]) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == ( + "2023-01-01 00:00:00", + "2023-01-04 00:00:00", + "2023-01-07 00:00:00", + "2023-01-10 00:00:00", + ) From d06b992ff9cd5e812cef9279b570a8ac1d019647 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:19:31 +0100 Subject: [PATCH 6/9] Update uneven tick spacing warning to reflect explicit tickvals export --- plotly/matplotlylib/mpltools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 8d23cabc77..00570c4c26 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -443,7 +443,7 @@ def prep_ticks(ax, index, ax_type, props): else: warnings.warn( "'linear' {0}-axis tick spacing not even, " - "ignoring mpl tick formatting.".format(ax_type) + "exporting explicit tick values instead of dtick.".format(ax_type) ) raise TypeError except (IndexError, TypeError): From 894e4d52713da5d62336e716fab234ae8d3e1fce Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:40:48 +0100 Subject: [PATCH 7/9] Export tick text for FixedFormatter tick labels --- plotly/matplotlylib/mpltools.py | 3 ++- plotly/matplotlylib/tests/test_renderer.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 00570c4c26..ad01b37520 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -500,7 +500,8 @@ def prep_ticks(ax, index, ax_type, props): if formatter == "LogFormatterMathtext": axis_dict["exponentformat"] = "e" elif ( - formatter == "FuncFormatter" and props["axes"][index]["tickformat"] is not None + formatter in ("FuncFormatter", "FixedFormatter") + and props["axes"][index]["tickformat"] is not None ): axis_dict.pop("dtick", None) axis_dict.pop("tickmode", None) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index fc21ca23bf..f6d3f22619 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -270,6 +270,22 @@ def test_non_arithmetic_progression_xticktext(): assert plotly_fig.layout.xaxis.ticktext == tuple(xticktext) +def test_fixed_formatter_ticktext(): + import matplotlib.ticker as ticker + + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.xaxis.set_major_locator(ticker.FixedLocator([0.01, 0.53, 0.75])) + ax.xaxis.set_major_formatter( + ticker.FixedFormatter(["Baseline", "param = 1", "param = 2"]) + ) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == (0.01, 0.53, 0.75) + assert plotly_fig.layout.xaxis.ticktext == ("Baseline", "param = 1", "param = 2") + + def test_custom_date_xtickvals_are_converted(): """Custom tick values on a date axis must be converted to date strings, not left as raw matplotlib date numbers or datetime objects.""" From f48430c6d3535aa9957bbd83798e319f8c6190b4 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:45:26 +0100 Subject: [PATCH 8/9] Add yticks test --- plotly/matplotlylib/tests/test_renderer.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index f6d3f22619..18ce2d02b3 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -257,6 +257,17 @@ def test_non_arithmetic_progression_xtickvals(): assert plotly_fig.layout.xaxis.tickvals == tuple(xticks) +def test_non_arithmetic_progression_yticks(): + yticks = [0.01, 0.53, 0.75] + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_yticks(yticks) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.yaxis.tickvals == tuple(yticks) + + def test_non_arithmetic_progression_xticktext(): xtickvals = [0.01, 0.53, 0.75] xticktext = ["Baseline", "param = 1", "param = 2"] From c5787d1041cc337414632d6b9bcd3beccab76de2 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:45:54 +0100 Subject: [PATCH 9/9] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b56e5ac90..f7ace75f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - Support `marginal_x`/`marginal_y="heatmap"` in `density_heatmap`, drawing a single-row/column heatmap strip in the margin colored by the same `z`/`histfunc` aggregate as the main plot and sharing its color scale [[#5706](https://github.com/plotly/plotly.py/issues/5706)], with thanks to @lucasjamar for the contribution! +- Add support for custom tick values in `mpl_to_plotly` when the matplotlib tick positions don't follow an arithmetic progression, including custom tick labels and tick values on date axes [[#5262](https://github.com/plotly/plotly.py/pull/5262)], with thanks to @robertoffmoura for the contribution! ### Fixed - Fix `mpl_to_plotly` not setting `paper_bgcolor` and `plot_bgcolor` from the matplotlib figure and axes backgrounds, so converted figures match the source figure's background colors [[#5285](https://github.com/plotly/plotly.py/pull/5285)], with thanks to @robertoffmoura for the contribution!