From 3aa81e2320a86ceeb3a5638f2c8da6037c43e4cb Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Sun, 15 Jun 2025 18:21:35 +0100 Subject: [PATCH 01/10] Add support for overriding x tick with non arithmetic progression values --- plotly/matplotlylib/mpltools.py | 15 +++++++++++-- plotly/matplotlylib/tests/test_renderer.py | 25 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 0a3206998b..6e6d843a8c 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 81b7c22271..7eb6ea11c0 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -233,3 +233,28 @@ def test_semitransparent_axes_background_preserved(): plotly_fig = tls.mpl_to_plotly(fig) assert plotly_fig.layout.plot_bgcolor == "rgba(26, 51, 76, 0.4)" + + + +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 485bf1582c54e9d42dd25cc57148579c63be520b Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 10 Aug 2026 13:56:21 +0100 Subject: [PATCH 02/10] Run ruff format --- plotly/matplotlylib/tests/test_renderer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 7eb6ea11c0..caf1bfd16c 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -235,7 +235,6 @@ def test_semitransparent_axes_background_preserved(): assert plotly_fig.layout.plot_bgcolor == "rgba(26, 51, 76, 0.4)" - def test_non_arithmetic_progression_xtickvals(): xticks = [0.01, 0.53, 0.75] plt.figure() From a30c5943d28f22edc916231dd79f3b8a9f9324a9 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 19:09:51 +0100 Subject: [PATCH 03/10] 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 6e6d843a8c..44f54ff4e2 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 d48a381b4eb2cac072888fd0d37f969ef32c8b4a Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 19:34:31 +0100 Subject: [PATCH 04/10] 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 caf1bfd16c..9de32299ef 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -237,11 +237,11 @@ def test_semitransparent_axes_background_preserved(): 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) @@ -249,11 +249,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 7164b8fc86858275a7c7cdf3aecb6d745ecbda6b Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 20:54:39 +0100 Subject: [PATCH 05/10] 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 44f54ff4e2..ef45fa2155 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 9de32299ef..0855449873 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -257,3 +257,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 d05bc6031b9d44b71bbca82c8620c57833b10158 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:17:02 +0100 Subject: [PATCH 06/10] 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 0855449873..da5a942f25 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -262,9 +262,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]) @@ -277,3 +275,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 066fba9aab951868b5986ca4f1605a437d9cc263 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:19:31 +0100 Subject: [PATCH 07/10] 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 ef45fa2155..16541f8c3e 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 ff371af45c0e290283487734ef98c15e709876e8 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:40:48 +0100 Subject: [PATCH 08/10] 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 16541f8c3e..eee489acc7 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 da5a942f25..8ae889ad35 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -259,6 +259,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 ed47534d99649e90234fae197cb5eca2f41df815 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:45:26 +0100 Subject: [PATCH 09/10] 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 8ae889ad35..a39590a488 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -246,6 +246,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 8e7df2f70a856bdd31dd47882cd9e05592072825 Mon Sep 17 00:00:00 2001 From: Roberto Moura Date: Mon, 31 Aug 2026 21:45:54 +0100 Subject: [PATCH 10/10] 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!