@@ -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+
74112def _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
249287class 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