Skip to content

Commit 25c9e51

Browse files
authored
Merge pull request #68 from max-models/devel
Version 0.1.9
2 parents eef1772 + bc8787d commit 25c9e51

5 files changed

Lines changed: 1222 additions & 80 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "maxplotlibx"
7-
version = "0.1.8"
7+
version = "0.1.9"
88
description = "A reproducible plotting module with various backends and export options."
99
readme = "README.md"
1010
requires-python = ">=3.8"

‎src/maxplotlib/canvas/canvas.py‎

Lines changed: 203 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,44 @@ def _running_in_jupyter() -> bool:
7171
return shell is not None and "IPKernelApp" in getattr(shell, "config", {})
7272

7373

74+
_MATPLOTLIB_LOC_TO_PLOTLY_ANCHOR = {
75+
"upper right": dict(x=1.0, y=1.0, xanchor="right", yanchor="top"),
76+
"upper left": dict(x=0.0, y=1.0, xanchor="left", yanchor="top"),
77+
"lower right": dict(x=1.0, y=0.0, xanchor="right", yanchor="bottom"),
78+
"lower left": dict(x=0.0, y=0.0, xanchor="left", yanchor="bottom"),
79+
"upper center": dict(x=0.5, y=1.0, xanchor="center", yanchor="top"),
80+
"lower center": dict(x=0.5, y=0.0, xanchor="center", yanchor="bottom"),
81+
"center left": dict(x=0.0, y=0.5, xanchor="left", yanchor="middle"),
82+
"center right": dict(x=1.0, y=0.5, xanchor="right", yanchor="middle"),
83+
"center": dict(x=0.5, y=0.5, xanchor="center", yanchor="middle"),
84+
"right": dict(x=1.0, y=0.5, xanchor="right", yanchor="middle"),
85+
}
86+
87+
88+
def _plotly_legend_kwargs(legend_kwargs):
89+
"""Translate Matplotlib ``ax.legend()`` keywords into Plotly's legend.
90+
91+
Only the settings that map cleanly are translated (position via ``loc``,
92+
``title``, ``fontsize``, and a two-or-more-column ``ncol`` as horizontal
93+
orientation); "best"/unrecognized locations and other Matplotlib-only
94+
legend options (``handlelength``, ``framealpha``, ...) are left at
95+
Plotly's defaults rather than guessed at.
96+
"""
97+
if not legend_kwargs:
98+
return {}
99+
layout = {}
100+
loc = legend_kwargs.get("loc")
101+
if isinstance(loc, str):
102+
layout.update(_MATPLOTLIB_LOC_TO_PLOTLY_ANCHOR.get(loc, {}))
103+
if legend_kwargs.get("title") is not None:
104+
layout["title"] = dict(text=legend_kwargs["title"])
105+
if legend_kwargs.get("fontsize") is not None:
106+
layout["font"] = dict(size=legend_kwargs["fontsize"])
107+
if legend_kwargs.get("ncol", legend_kwargs.get("ncols", 1)) not in (None, 1):
108+
layout["orientation"] = "h"
109+
return layout
110+
111+
74112
def _apply_matplotlib_customizations(fig, axes, customizations) -> None:
75113
"""Apply declarative method calls to a Matplotlib figure and its axes."""
76114
if callable(customizations):
@@ -247,6 +285,17 @@ def plot_matplotlib(tikzfigure: TikzFigure, ax, layers=None):
247285

248286

249287
class Canvas:
288+
"""A figure made of one or more subplots, rendered by any backend.
289+
290+
The drawing methods mirror Matplotlib's ``Axes`` API and forward their
291+
keyword arguments to the selected backend. Two keyword arguments are
292+
handled by maxplotlib itself and accepted by every drawing method:
293+
``hover=`` (hover text, used by the Plotly backend, ignored elsewhere) and
294+
``meta=`` (an opaque tag set as ``meta`` on Plotly traces and as ``gid``
295+
on Matplotlib artists, so the artists can be found by identity rather than
296+
by drawing order). See :class:`~maxplotlib.subfigure.line_plot.LinePlot`.
297+
"""
298+
250299
def __init__(
251300
self,
252301
nrows: int = 1,
@@ -312,6 +361,7 @@ def __init__(
312361
self._matplotlib_fig = None
313362
self._matplotlib_axes = None
314363
self._plotext_figure = None
364+
self._plotly_fig = None
315365
self._suptitle: str | None = None
316366
self._suptitle_kwargs: dict = {}
317367
self._supxlabel: str | None = None
@@ -326,6 +376,7 @@ def __init__(
326376
self._align_xlabels = False
327377
self._align_ylabels = False
328378
self._autofmt_xdate_kwargs: dict | None = None
379+
self._barmode: str | None = None
329380

330381
# Dictionary to store lines for each subplot
331382
# Key: (row, col), Value: list of lines with their data and kwargs
@@ -560,7 +611,9 @@ def bar(
560611
height (array-like): Heights of the bars.
561612
layer (int): Layer index (default 0).
562613
row, col (int): Subplot position (default top-left).
563-
**kwargs: Forwarded to the backend (e.g., color, width, label).
614+
**kwargs: Forwarded to the backend (e.g., color, width, bottom,
615+
alpha, edgecolor, label). ``bottom`` stacks the bars on every
616+
backend.
564617
"""
565618
sp = self._get_or_create_subplot(row, col)
566619
sp.bar(x, height, layer=layer, **kwargs)
@@ -995,7 +1048,10 @@ def flame_chart(
9951048
start_times (array-like, optional): Start times for each frame. If None, computed from hierarchy.
9961049
layer (int): Layer index (default 0).
9971050
row, col (int): Subplot position (default top-left).
998-
**kwargs: Forwarded to the backend (e.g., colormap, edgecolor, label).
1051+
**kwargs: Forwarded to the backend (e.g., colormap, colors,
1052+
edgecolor, label). ``colormap`` accepts a Matplotlib colormap name
1053+
or a Plotly colorscale name on either backend; ``colors`` sets an
1054+
explicit color per frame.
9991055
"""
10001056
sp = self._get_or_create_subplot(row, col)
10011057
sp.flame_chart(
@@ -1086,6 +1142,24 @@ def configure(
10861142
self.tight_layout()
10871143
return self
10881144

1145+
def set_barmode(self, mode: str | None):
1146+
"""Set how overlapping bars are arranged in the Plotly backend.
1147+
1148+
``mode`` is one of Plotly's ``"group"``, ``"overlay"``, ``"stack"``
1149+
or ``"relative"``, or ``None`` to let maxplotlib decide. By default
1150+
the canvas uses ``"overlay"`` whenever any bar positions itself with
1151+
an explicit base (``bottom=``/``left=``, Gantt rows, flame frames),
1152+
because Plotly's default grouping would re-offset those bars away
1153+
from the positions the caller asked for. Ignored by the other
1154+
backends, which have no equivalent layout-level setting.
1155+
"""
1156+
self._barmode = mode
1157+
return self
1158+
1159+
@property
1160+
def barmode(self):
1161+
return self._barmode
1162+
10891163
def set_xlim(
10901164
self, left=None, right=None, row: int | None = None, col: int | None = None
10911165
):
@@ -1105,10 +1179,22 @@ def set_grid(
11051179
self._get_or_create_subplot(row, col).set_grid(visible)
11061180

11071181
def set_legend(
1108-
self, visible: bool = True, row: int | None = None, col: int | None = None
1182+
self,
1183+
visible: bool = True,
1184+
row: int | None = None,
1185+
col: int | None = None,
1186+
**kwargs,
11091187
):
1110-
"""Show or hide the legend for a subplot (default top-left)."""
1111-
self._get_or_create_subplot(row, col).set_legend(visible)
1188+
"""Show or hide the legend for a subplot (default top-left).
1189+
1190+
``**kwargs`` are forwarded to ``ax.legend()`` on Matplotlib; ``loc``,
1191+
``title``, ``fontsize`` and ``ncol``/``ncols`` are also translated for
1192+
the Plotly backend's legend.
1193+
"""
1194+
self._get_or_create_subplot(row, col).set_legend(visible, **kwargs)
1195+
1196+
def legend(self, row=None, col=None, **kwargs):
1197+
self._get_or_create_subplot(row, col).set_legend(**kwargs)
11121198

11131199
def legend(self, row=None, col=None, **kwargs):
11141200
self._get_or_create_subplot(row, col).set_legend(**kwargs)
@@ -1992,6 +2078,63 @@ def render(self, *args, **kwargs):
19922078
"""
19932079
return self._render(*args, **kwargs)
19942080

2081+
# ------------------------------------------------------------------
2082+
# Escape hatches: get the native object a backend builds, to edit with
2083+
# that backend's own (more mature) API for anything maxplotlib doesn't
2084+
# wrap. Each is a thin, discoverable alias for the corresponding
2085+
# ``plot_<backend>()`` method; none of them display the result.
2086+
# ------------------------------------------------------------------
2087+
2088+
def get_matplotlib_figaxs(
2089+
self,
2090+
layers: list | None = None,
2091+
usetex: bool | None = None,
2092+
verbose: bool = False,
2093+
matplotlib_postprocess=None,
2094+
matplotlib_customizations=None,
2095+
):
2096+
"""Render with Matplotlib and return ``(figure, axes)`` to edit directly.
2097+
2098+
Use this to reach any Matplotlib API maxplotlib doesn't wrap natively
2099+
(custom artists, fine-grained styling, ``savefig`` options, ...).
2100+
Further calls on the canvas after this one will not retroactively
2101+
affect the returned objects; re-call to pick up later changes.
2102+
"""
2103+
return self.plot_matplotlib(
2104+
layers=layers,
2105+
usetex=usetex,
2106+
verbose=verbose,
2107+
matplotlib_postprocess=matplotlib_postprocess,
2108+
matplotlib_customizations=matplotlib_customizations,
2109+
)
2110+
2111+
def get_plotly_fig(
2112+
self,
2113+
layers: list | None = None,
2114+
usetex: bool | None = None,
2115+
verbose: bool = False,
2116+
allow_unsupported: bool = False,
2117+
):
2118+
"""Render with Plotly and return the ``go.Figure`` to edit directly.
2119+
2120+
Use this to reach any Plotly API maxplotlib doesn't wrap natively
2121+
(``fig.update_traces()``, custom layout, ``fig.write_html()``, ...).
2122+
"""
2123+
return self.plot_plotly(
2124+
layers=layers,
2125+
usetex=usetex,
2126+
verbose=verbose,
2127+
allow_unsupported=allow_unsupported,
2128+
)
2129+
2130+
def get_plotext_fig(self, layers: list | None = None, verbose: bool = False):
2131+
"""Render with plotext and return the ``PlotextFigure`` to edit directly."""
2132+
return self.plot_plotext(layers=layers, verbose=verbose)
2133+
2134+
def get_tikz_figure(self, verbose: bool = False):
2135+
"""Render with TikZ and return the ``TikzFigure`` to edit directly."""
2136+
return self.plot_tikzfigure(verbose=verbose)
2137+
19952138
def show(
19962139
self,
19972140
backend: Backends = "matplotlib",
@@ -2776,6 +2919,7 @@ def plot_plotly(
27762919
fig.update_xaxes(
27772920
tickmode="array",
27782921
ticktext=line_plot._xticklabels,
2922+
tickangle=line_plot._xticklabel_kwargs.get("rotation"),
27792923
tickfont={
27802924
key: line_plot._xticklabel_kwargs[key]
27812925
for key in ("size", "color", "family")
@@ -2788,6 +2932,7 @@ def plot_plotly(
27882932
fig.update_yaxes(
27892933
tickmode="array",
27902934
ticktext=line_plot._yticklabels,
2935+
tickangle=line_plot._yticklabel_kwargs.get("rotation"),
27912936
tickfont={
27922937
key: line_plot._yticklabel_kwargs[key]
27932938
for key in ("size", "color", "family")
@@ -2797,6 +2942,35 @@ def plot_plotly(
27972942
col=col + 1,
27982943
)
27992944

2945+
# tick_params() is the neutral place to ask for rotated or
2946+
# restyled tick labels; translate the Matplotlib spelling into
2947+
# the Plotly axis properties.
2948+
tick_params = line_plot._tick_params
2949+
if tick_params:
2950+
axis = tick_params.get("axis", "both")
2951+
rotation = tick_params.get("labelrotation", tick_params.get("rotation"))
2952+
tickfont = {}
2953+
if tick_params.get("labelsize") is not None:
2954+
tickfont["size"] = tick_params["labelsize"]
2955+
if tick_params.get("labelcolor") is not None:
2956+
tickfont["color"] = tick_params["labelcolor"]
2957+
tick_kwargs = {}
2958+
if rotation is not None:
2959+
tick_kwargs["tickangle"] = rotation
2960+
if tickfont:
2961+
tick_kwargs["tickfont"] = tickfont
2962+
if tick_params.get("color") is not None:
2963+
tick_kwargs["tickcolor"] = tick_params["color"]
2964+
if tick_params.get("length") is not None:
2965+
tick_kwargs["ticklen"] = tick_params["length"]
2966+
if tick_params.get("width") is not None:
2967+
tick_kwargs["tickwidth"] = tick_params["width"]
2968+
if tick_kwargs:
2969+
if axis in ("x", "both"):
2970+
fig.update_xaxes(row=row + 1, col=col + 1, **tick_kwargs)
2971+
if axis in ("y", "both"):
2972+
fig.update_yaxes(row=row + 1, col=col + 1, **tick_kwargs)
2973+
28002974
if line_plot._axis_settings:
28012975
axis_args = line_plot._axis_settings.get("args", ())
28022976
if axis_args and axis_args[0] == "off":
@@ -2825,6 +2999,29 @@ def plot_plotly(
28252999
font=dict(size=self.fontsize),
28263000
margin=dict(l=10, r=10, t=40, b=10),
28273001
)
3002+
barmode = self._barmode
3003+
if barmode is None:
3004+
barmode = next(
3005+
(
3006+
subplot._plotly_barmode_hint
3007+
for subplot in self._subplot_dict.values()
3008+
if getattr(subplot, "_plotly_barmode_hint", None) is not None
3009+
),
3010+
None,
3011+
)
3012+
if barmode is not None:
3013+
fig.update_layout(barmode=barmode)
3014+
legend_kwargs = next(
3015+
(
3016+
subplot._legend_kwargs
3017+
for subplot in self._subplot_dict.values()
3018+
if subplot._legend and subplot._legend_kwargs
3019+
),
3020+
None,
3021+
)
3022+
legend_layout = _plotly_legend_kwargs(legend_kwargs)
3023+
if legend_layout:
3024+
fig.update_layout(legend=legend_layout)
28283025
if self._suptitle:
28293026
fig.update_layout(title=dict(text=self._suptitle, x=0.5))
28303027

@@ -2837,6 +3034,7 @@ def plot_plotly(
28373034
"install kaleido (e.g., `pip install -U kaleido`)."
28383035
) from exc
28393036

3037+
self._plotly_fig = fig
28403038
return fig
28413039

28423040
def _save_plotly(self, fig, filename: str) -> None:

0 commit comments

Comments
 (0)