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! diff --git a/plotly/matplotlylib/mpltools.py b/plotly/matplotlylib/mpltools.py index 0a3206998b..eee489acc7 100644 --- a/plotly/matplotlylib/mpltools.py +++ b/plotly/matplotlylib/mpltools.py @@ -436,18 +436,20 @@ 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( "'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): 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 @@ -485,17 +487,26 @@ 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" + elif ( + formatter in ("FuncFormatter", "FixedFormatter") + and props["axes"][index]["tickformat"] is not None + ): + 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 diff --git a/plotly/matplotlylib/tests/test_renderer.py b/plotly/matplotlylib/tests/test_renderer.py index 81b7c22271..a39590a488 100644 --- a/plotly/matplotlylib/tests/test_renderer.py +++ b/plotly/matplotlylib/tests/test_renderer.py @@ -233,3 +233,110 @@ 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] + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xticks(xticks) + + plotly_fig = tls.mpl_to_plotly(fig) + + 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"] + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.set_xticks(xtickvals, xticktext) + + plotly_fig = tls.mpl_to_plotly(fig) + + assert plotly_fig.layout.xaxis.tickvals == tuple(xtickvals) + 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.""" + 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]) + + 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", + ) + + +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", + )