diff --git a/doc/index.rst b/doc/index.rst index cab120283c..f6b16b2e5c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -9,7 +9,7 @@ .. raw:: html
-
+
HoloViews is an `open-source `_ Python library designed to make data analysis and visualization seamless and simple. With HoloViews, you can usually express what you want to do in very few lines of code, letting you focus on what you are trying to explore and convey, not on the process of plotting. @@ -27,9 +27,6 @@ If you have any `issues `_ or wish
-.. raw:: html - :file: latest_news.html - .. raw:: html
diff --git a/doc/latest_news.html b/doc/latest_news.html deleted file mode 100644 index 82f2125b36..0000000000 --- a/doc/latest_news.html +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
diff --git a/examples/user_guide/Plotting_with_Bokeh.ipynb b/examples/user_guide/Plotting_with_Bokeh.ipynb index 8b121159e6..56f8aec3d9 100644 --- a/examples/user_guide/Plotting_with_Bokeh.ipynb +++ b/examples/user_guide/Plotting_with_Bokeh.ipynb @@ -122,7 +122,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Not also that most of these options support vectorized style mapping as described in the [Style Mapping user guide](04-Style_Mapping.ipynb). Here's an example of HoloViews Elements using a Bokeh backend, with bokeh's style options:" + "Note also that most of these options support vectorized style mapping as described in the [Style Mapping user guide](04-Style_Mapping.ipynb). Here's an example of HoloViews Elements using a Bokeh backend, with bokeh's style options:" ] }, { @@ -149,6 +149,63 @@ "Notice that because the first two plots use the same underlying data, they become linked, such that zooming or panning one of the plots makes the corresponding change on the other." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setting Backend Opts\n", + "\n", + "HoloViews does not expose every single option from Bokeh.\n", + "\n", + "Instead, HoloViews allow users to attach [hooks](Customizing_Plots.ipynb#plot-hooks) to modify the plot object directly--but writing these hooks could be cumbersome, especially if it's only used for a single line of update.\n", + "\n", + "Fortunately, HoloViews allows `backend_opts` for the Bokeh backend to configure options by declaring a dictionary with accessor specification for updating the plot components.\n", + "\n", + "For example, here's how to make the toolbar auto-hide." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.Curve(data).opts(\n", + " backend_opts={\"plot.toolbar.autohide\": False}\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following is the equivalent, as a hook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def hook(hv_plot, element):\n", + " toolbar = hv_plot.handles['plot'].toolbar\n", + " toolbar.autohide = True\n", + "\n", + "hv.Curve(data).opts(hooks=[hook])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how much more concise it is with `backend_opts`!\n", + "\n", + "With knowledge of the attributes of Bokeh, it's possible to configure many other plot components besides `toolbar`. Some examples include `legend`, `colorbar`, `xaxis`, `yaxis`, and much, much more.\n", + "\n", + "If you're unsure, simply input your best guess and it'll try to provide a list of suggestions if there's an issue." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/user_guide/Plotting_with_Matplotlib.ipynb b/examples/user_guide/Plotting_with_Matplotlib.ipynb index 4c6570be8c..3e64fc2603 100644 --- a/examples/user_guide/Plotting_with_Matplotlib.ipynb +++ b/examples/user_guide/Plotting_with_Matplotlib.ipynb @@ -52,6 +52,91 @@ "print('Axes: ', fig.axes)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setting Backend Opts\n", + "\n", + "HoloViews does not expose every single option from matplotlib.\n", + "\n", + "Instead, HoloViews allow users to attach [hooks](Customizing_Plots.ipynb#plot-hooks) to modify the plot object directly--but writing these hooks could be cumbersome, especially if it's only used for a single line of update.\n", + "\n", + "Fortunately, HoloViews allows `backend_opts` for the Bokeh backend to configure options by declaring a dictionary with accessor specification for updating the plot components.\n", + "\n", + "For example, here's how to remove the frame on the legend." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plot = hv.Curve([1, 2, 3], label=\"a\") * hv.Curve([1, 4, 9], label=\"b\")\n", + "plot.opts(\n", + " show_legend=True,\n", + " backend_opts={\n", + " \"legend.frame_on\": False,\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following is the equivalent, as a hook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def hook(plot, element):\n", + " legend = plot.handles[\"legend\"]\n", + " legend.set_frame_on(False)\n", + "\n", + "plot = hv.Curve([1, 2, 3], label=\"a\") * hv.Curve([1, 4, 9], label=\"b\")\n", + "plot.opts(\n", + " show_legend=True,\n", + " hooks=[hook]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice how much more concise it is with `backend_opts`, and it's even possible to update items in a list.\n", + "\n", + "For example you can set the first legend label's `fontsize`!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plot = hv.Curve([1, 2, 3], label=\"a\") * hv.Curve([1, 4, 9], label=\"b\")\n", + "plot.opts(\n", + " show_legend=True,\n", + " backend_opts={\"legend.get_texts()[0:2].fontsize\": 18}\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With knowledge of the methods in matplotlib, it's possible to configure many other plot components besides `legend`. Some examples include `colorbar`, `xaxis`, `yaxis`, and much, much more.\n", + "\n", + "If you're unsure, simply input your best guess and it'll try to provide a list of suggestions if there's an issue." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/holoviews/core/data/xarray.py b/holoviews/core/data/xarray.py index a8d82dbfd3..1893f67b29 100644 --- a/holoviews/core/data/xarray.py +++ b/holoviews/core/data/xarray.py @@ -144,7 +144,7 @@ def retrieve_unit_and_label(dim): packed = True else: data = {d: v for d, v in zip(dimensions, data)} - elif isinstance(data, list) and data == []: + elif isinstance(data, (list, np.ndarray)) and len(data) == 0: dimensions = [d.name for d in kdims + vdims] data = {d: np.array([]) for d in dimensions[:ndims]} data.update({d: np.empty((0,) * ndims) for d in dimensions[ndims:]}) diff --git a/holoviews/core/sheetcoords.py b/holoviews/core/sheetcoords.py index 812f13f3b1..07c91571f8 100644 --- a/holoviews/core/sheetcoords.py +++ b/holoviews/core/sheetcoords.py @@ -517,7 +517,6 @@ def _boundsspec2slicespec(boundsspec,scs): l_idx = int(np.ceil(l_m-0.5)) t_idx = int(np.ceil(t_m-0.5)) - # CBENHANCEMENT: Python 2.6's math.trunc()? r_idx = int(np.floor(r_m+0.5)) b_idx = int(np.floor(b_m+0.5)) diff --git a/holoviews/element/geom.py b/holoviews/element/geom.py index c3d26753ad..f3ea3238f3 100644 --- a/holoviews/element/geom.py +++ b/holoviews/element/geom.py @@ -52,6 +52,35 @@ class VectorField(Selection2DExpr, Geometry): vdims = param.List(default=[Dimension('Angle', cyclic=True, range=(0,2*np.pi)), Dimension('Magnitude')], bounds=(1, None)) + @classmethod + def from_uv(cls, data, kdims=None, vdims=None, **params): + if kdims is None: + kdims = ['x', 'y'] + if vdims is None: + vdims = ['u', 'v'] + dataset = Dataset(data, kdims=kdims, vdims=vdims, **params) + us, vs = (dataset.dimension_values(i) for i in range(2, 4)) + + uv_magnitudes = np.hypot(us, vs) # unscaled + # this follows mathematical conventions, + # unlike WindBarbs which follows meteorological conventions + radians = np.arctan2(vs, us) + + # calculations on this data could mutate the original data + # here we do not do any calculations; we only store the data + repackaged_dataset = {} + for kdim in kdims: + repackaged_dataset[kdim] = dataset[kdim] + repackaged_dataset["Angle"] = radians + repackaged_dataset["Magnitude"] = uv_magnitudes + for vdim in vdims[2:]: + repackaged_dataset[vdim] = dataset[vdim] + vdims = [ + Dimension('Angle', cyclic=True, range=(0, 2 * np.pi)), + Dimension('Magnitude') + ] + vdims[2:] + return cls(repackaged_dataset, kdims=kdims, vdims=vdims, **params) + class Segments(SelectionGeomExpr, Geometry): """ diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 0b32e4d175..1101f8d599 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -1438,20 +1438,25 @@ class rasterize(AggregationOperation): ] __instance_params = set() + __instance_kwargs = {} @bothmethod def instance(self_or_cls, **params): - inst = super().instance(**params) - inst.__instance_params = set(params) + kwargs = set(params) - set(self_or_cls.param) + inst_params = {k: v for k, v in params.items() if k in self_or_cls.param} + inst = super().instance(**inst_params) + inst.__instance_params = set(inst_params) + inst.__instance_kwargs = {k: v for k, v in params.items() if k in kwargs} return inst def _process(self, element, key=None): # Potentially needs traverse to find element types first? all_allowed_kws = set() all_supplied_kws = set() - instance_params = { - k: getattr(self, k) for k in self.__instance_params - } + instance_params = dict( + self.__instance_kwargs, + **{k: getattr(self, k) for k in self.__instance_params} + ) for predicate, transform in self._transforms: merged_param_values = dict(instance_params, **self.p) diff --git a/holoviews/operation/resample.py b/holoviews/operation/resample.py index 56abef4c12..2f867c02e2 100644 --- a/holoviews/operation/resample.py +++ b/holoviews/operation/resample.py @@ -50,12 +50,13 @@ class ResampleOperation1D(LinkableOperation): height = param.Integer(default=400, doc=""" The height of the output image in pixels.""") - pixel_ratio = param.Number(default=1, bounds=(1,None), doc=""" + pixel_ratio = param.Number(default=1, bounds=(0,None), + inclusive_bounds=(False,False), doc=""" Pixel ratio applied to the height and width. Useful for higher resolution screens where the PlotSize stream reports 'nominal' dimensions in pixels that do not match the physical pixels. For instance, setting pixel_ratio=2 can give better results on Retina - displays.""") + displays. Also useful for using lower resolution for speed.""") class ResampleOperation2D(ResampleOperation1D): """ diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index b251f742f8..b773746002 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -30,7 +30,7 @@ from packaging.version import Version from ...core import DynamicMap, CompositeOverlay, Element, Dimension, Dataset -from ...core.options import abbreviated_exception, SkipRendering +from ...core.options import abbreviated_exception, Keywords, SkipRendering from ...core import util from ...element import ( Annotation, Contours, Graph, Path, Tiles, VectorField @@ -97,6 +97,13 @@ class ElementPlot(BokehPlot, GenericElementPlot): ratio between the axis scales use the data_aspect option instead.""") + backend_opts = param.Dict(default={}, doc=""" + A dictionary of custom options to apply to the plot or + subcomponents of the plot. The keys in the dictionary mirror + attribute access on the underlying models stored in the plot's + handles, e.g. {'colorbar.margin': 10} will index the colorbar + in the Plot.handles and then set the margin to 10.""") + data_aspect = param.Number(default=None, doc=""" Defines the aspect of the axis scaling, i.e. the ratio of y-unit to x-unit.""") @@ -772,6 +779,47 @@ def _update_title(self, key, plot, element): plot.title = Title(**self._title_properties(key, plot, element)) + def _update_backend_opts(self): + plot = self.handles["plot"] + model_accessor_aliases = { + "cbar": "colorbar", + "p": "plot", + "xaxes": "xaxis", + "yaxes": "yaxis", + } + + for opt, val in self.backend_opts.items(): + parsed_opt = self._parse_backend_opt( + opt, plot, model_accessor_aliases) + if parsed_opt is None: + continue + model, attr_accessor = parsed_opt + + # not using isinstance because some models inherit from list + if not isinstance(model, list): + # to reduce the need for many if/else; cast to list + # to do the same thing for both single and multiple models + models = [model] + else: + models = model + + valid_options = models[0].properties() + if attr_accessor not in valid_options: + kws = Keywords(values=valid_options) + matches = sorted(kws.fuzzy_match(attr_accessor)) + self.param.warning( + f"Could not find {attr_accessor!r} property on {type(models[0]).__name__!r} " + f"model. Ensure the custom option spec {opt!r} you provided references a " + f"valid attribute on the specified model. " + f"Similar options include {matches!r}" + ) + + continue + + for m in models: + setattr(m, attr_accessor, val) + + def _update_grid(self, plot): if not self.show_grid: plot.xgrid.grid_line_color = None @@ -848,8 +896,11 @@ def _update_ranges(self, element, ranges): frame_aspect = 1 elif self.aspect and self.aspect != 'equal': frame_aspect = self.aspect - else: + elif plot.frame_height and plot.frame_width: frame_aspect = plot.frame_height/plot.frame_width + else: + # Skip if aspect can't be determined + return if self.drawn: current_l, current_r = plot.x_range.start, plot.x_range.end @@ -1170,6 +1221,8 @@ def _init_glyph(self, plot, mapping, properties): plot_method = plot_method[int(self.invert_axes)] if 'legend_field' in properties and 'legend_label' in properties: del properties['legend_label'] + if "name" not in properties: + properties["name"] = properties.get("legend_label") or properties.get("legend_field") renderer = getattr(plot, plot_method)(**dict(properties, **mapping)) return renderer, renderer.glyph @@ -1681,6 +1734,7 @@ def update_frame(self, key, ranges=None, plot=None, element=None): def _execute_hooks(self, element): dtype_fix_hook(self, element) super()._execute_hooks(element) + self._update_backend_opts() def model_changed(self, model): diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index 78f7408f36..e6267edb6d 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -30,8 +30,8 @@ from ..util import attach_streams, displayable, collate from .links import LinkCallback from .util import ( - bokeh3, filter_toolboxes, make_axis, update_shared_sources, empty_plot, - decode_bytes, theme_attr_json, cds_column_replace, get_default, merge_tools + bokeh3, filter_toolboxes, make_axis, sync_legends, update_shared_sources, empty_plot, + decode_bytes, theme_attr_json, cds_column_replace, get_default, merge_tools, ) if bokeh3: @@ -469,6 +469,9 @@ class GridPlot(CompositePlot, GenericCompositePlot): as a tuple specifying width and height or an integer for a square plot.""") + sync_legends = param.Boolean(default=True, doc=""" + Whether to sync the legend when muted/unmuted based on the name""") + def __init__(self, layout, ranges=None, layout_num=1, keys=None, **params): if not isinstance(layout, GridSpace): raise Exception("GridPlot only accepts GridSpace.") @@ -586,6 +589,8 @@ def initialize_plot(self, ranges=None, plots=[]): merge_tools=self.merge_tools, sizing_mode=self.sizing_mode, toolbar_location=self.toolbar) + if self.sync_legends: + sync_legends(plot) plot = self._make_axes(plot) if bokeh3 and hasattr(plot, "toolbar"): plot.toolbar = merge_tools(plots) @@ -682,6 +687,9 @@ class LayoutPlot(CompositePlot, GenericLayoutPlot): merge_tools = param.Boolean(default=True, doc=""" Whether to merge all the tools into a single toolbar""") + sync_legends = param.Boolean(default=True, doc=""" + Whether to sync the legend when muted/unmuted based on the name""") + tabs = param.Boolean(default=False, doc=""" Whether to display overlaid plots in separate panes""") @@ -962,6 +970,8 @@ def initialize_plot(self, plots=None, ranges=None): merge_tools=self.merge_tools, sizing_mode=sizing_mode ) + if self.sync_legends: + sync_legends(layout_plot) if bokeh3: layout_plot.toolbar = merge_tools(plot_grid) diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py index c8d5153312..25285b68df 100644 --- a/holoviews/plotting/bokeh/raster.py +++ b/holoviews/plotting/bokeh/raster.py @@ -177,10 +177,17 @@ def get_data(self, element, ranges, style): img = np.dstack([element.dimension_values(d, flat=False) for d in element.vdims]) + + nan_mask = np.isnan(img) + img[nan_mask] = 0 + if img.ndim == 3: - if img.dtype.kind == 'f': + img_max = img.max() if img.size else np.nan + # Can be 0 to 255 if nodata has been used + if img.dtype.kind == 'f' and img_max <= 1: img = img*255 - if img.size and (img.min() < 0 or img.max() > 255): + # img_max * 255 <- have no effect + if img.size and (img.min() < 0 or img_max > 255): self.param.warning('Clipping input data to the valid ' 'range for RGB data ([0..1] for ' 'floats or [0..255] for integers).') @@ -197,6 +204,8 @@ def get_data(self, element, ranges, style): img = img.copy() img = img.view(dtype=np.uint32).reshape((N, M)) + img[nan_mask.any(-1)] = 0 + # Ensure axis inversions are handled correctly l, b, r, t = element.bounds.lbrt() if self.invert_axes: diff --git a/holoviews/plotting/bokeh/tiles.py b/holoviews/plotting/bokeh/tiles.py index 7b3ab8e25d..9a03757f75 100644 --- a/holoviews/plotting/bokeh/tiles.py +++ b/holoviews/plotting/bokeh/tiles.py @@ -24,7 +24,7 @@ def get_extents(self, element, ranges, range_type='combined'): def get_data(self, element, ranges, style): if not isinstance(element.data, (str, dict)): - SkipRendering("WMTS element data must be a URL string or " + SkipRendering("WMTS element data must be a URL string, dictionary, or " "xyzservices.TileProvider, bokeh cannot " "render %r" % element.data) if element.data is None: diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index a5998d1bf8..a683a43029 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -6,6 +6,7 @@ from collections import defaultdict from contextlib import contextmanager +from itertools import permutations from types import FunctionType import param @@ -28,6 +29,7 @@ from bokeh.themes import built_in_themes from packaging.version import Version +from ...core.layout import Layout from ...core.ndmapping import NdMapping from ...core.overlay import Overlay from ...core.util import ( @@ -35,6 +37,7 @@ cftime_to_timestamp, isnumeric, pd, unique_array ) from ...core.spaces import get_nested_dmaps, DynamicMap +from ...util.warnings import warn from ..util import dim_axis_label from ...util.warnings import deprecated @@ -408,6 +411,85 @@ def merge(tool, group): return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore) if merge_tools else tools) + +def sync_legends(bokeh_layout): + """This syncs the legends of all plots in a grid based on their name. + + Only works for Bokeh 3 and above. + + Parameters + ---------- + bokeh_layout : bokeh.models.{GridPlot, Row, Column} + Gridplot to sync legends of. + """ + if not bokeh3 or len(bokeh_layout.children) < 2: + return + + # Collect all glyph with names + items = defaultdict(list) + click_policies = set() + for fig in bokeh_layout.children: + if isinstance(fig, tuple): # GridPlot + fig = fig[0] + if not isinstance(fig, figure): + continue + for r in fig.renderers: + if r.name: + items[r.name].append(r) + if fig.legend: + click_policies.add(fig.legend.click_policy) + + click_policies.discard("none") # If legend is not visible, click_policy is "none" + if len(click_policies) > 1: + warn("Click policy of legends are not the same, no syncing will happen.") + return + elif not click_policies: + return + + # Link all glyphs with the same name + mapping = {"mute": "muted", "hide": "visible"} + policy = mapping.get(next(iter(click_policies))) + code = f"dst.{policy} = src.{policy}" + for item in items.values(): + for src, dst in permutations(item, 2): + src.js_on_change( + policy, + CustomJS(code=code, args=dict(src=src, dst=dst)), + ) + + +def select_legends(holoviews_layout, figure_index=None, legend_position="top_right"): + """ Only displays selected legends in plot layout. + + Parameters + ---------- + holoviews_layout : Holoviews Layout + Holoviews Layout with legends. + figure_index : list[int] | int | None + Index of the figures which legends to show. + If None is chosen, only the first figures legend is shown + legend_position : str + Position of the legend(s). + """ + if figure_index is None: + figure_index = [0] + elif isinstance(figure_index, int): + figure_index = [figure_index] + if not isinstance(holoviews_layout, Layout): + holoviews_layout = [holoviews_layout] + + for i, plot in enumerate(holoviews_layout): + if i in figure_index: + plot.opts(show_legend=True, legend_position=legend_position) + else: + plot.opts(show_legend=False) + + if isinstance(holoviews_layout, list): + return holoviews_layout[0] + + return holoviews_layout + + @contextmanager def silence_warnings(*warnings): """ diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 4da435c517..b6ee5e6657 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -15,7 +15,7 @@ from ...core import util from ...core import (OrderedDict, NdOverlay, DynamicMap, Dataset, CompositeOverlay, Element3D, Element) -from ...core.options import abbreviated_exception +from ...core.options import abbreviated_exception, Keywords from ...element import Graph, Path from ...streams import Stream from ...util.transform import dim @@ -196,6 +196,10 @@ def _finalize_axis(self, key, element=None, title=None, dimensions=None, ranges= self._execute_hooks(element) return super()._finalize_axis(key) + def _execute_hooks(self, element): + super()._execute_hooks(element) + self._update_backend_opts() + def _finalize_ticks(self, axis, dimensions, xticks, yticks, zticks): """ Finalizes the ticks on the axes based on the supplied ticks @@ -240,6 +244,47 @@ def _finalize_ticks(self, axis, dimensions, xticks, yticks, zticks): tick_fontsize = self._fontsize(f'{ax}ticks','labelsize',common=False) if tick_fontsize: ax_obj.set_tick_params(**tick_fontsize) + def _update_backend_opts(self): + plot = self.handles["fig"] + + model_accessor_aliases = { + "figure": "fig", + "axes": "axis", + "ax": "axis", + "colorbar": "cbar", + } + + for opt, val in self.backend_opts.items(): + parsed_opt = self._parse_backend_opt( + opt, plot, model_accessor_aliases) + if parsed_opt is None: + continue + + model, attr_accessor = parsed_opt + if not attr_accessor.startswith("set_"): + attr_accessor = f"set_{attr_accessor}" + + if not isinstance(model, list): + # to reduce the need for many if/else; cast to list + # to do the same thing for both single and multiple models + models = [model] + else: + models = model + + try: + for m in models: + getattr(m, attr_accessor)(val) + except AttributeError as exc: + valid_options = [attr for attr in dir(models[0]) if attr.startswith("set_")] + kws = Keywords(values=valid_options) + matches = sorted(kws.fuzzy_match(attr_accessor)) + self.param.warning( + f"Encountered error: {exc}, or could not find " + f"{attr_accessor!r} method on {type(models[0]).__name__!r} " + f"model. Ensure the custom option spec {opt!r} you provided references a " + f"valid method on the specified model. Similar options include {matches!r}" + ) + def _finalize_artist(self, element): """ Allows extending the _finalize_axis method with Element diff --git a/holoviews/plotting/mpl/plot.py b/holoviews/plotting/mpl/plot.py index 616b597302..09bc53212d 100644 --- a/holoviews/plotting/mpl/plot.py +++ b/holoviews/plotting/mpl/plot.py @@ -60,6 +60,13 @@ class MPLPlot(DimensionedPlot): sideplots = {} + backend_opts = param.Dict(default={}, doc=""" + A dictionary of custom options to apply to the plot or + subcomponents of the plot. The keys in the dictionary mirror + attribute access on the underlying models stored in the plot's + handles, e.g. {'colorbar.margin': 10} will index the colorbar + in the Plot.handles and then set the margin to 10.""") + fig_alpha = param.Number(default=1.0, bounds=(0, 1), doc=""" Alpha of the overall figure background.""") diff --git a/holoviews/plotting/plot.py b/holoviews/plotting/plot.py index 7659f50996..ce60aa817f 100644 --- a/holoviews/plotting/plot.py +++ b/holoviews/plotting/plot.py @@ -6,6 +6,7 @@ import uuid import warnings +from ast import literal_eval from collections import Counter, defaultdict, OrderedDict from functools import partial from itertools import groupby, product @@ -305,6 +306,13 @@ def _define_interface(self, plots, allow_mismatch): for plot in plots] param_sets = [set(params.keys()) for params in parameters] if not allow_mismatch and not all(pset == param_sets[0] for pset in param_sets): + # Find the mismatching sets + mismatching_sets = [pset for pset in param_sets if pset != param_sets[0]] + + # Print the mismatching sets + for mismatch_set in mismatching_sets: + print("Mismatching plot options:", mismatch_set) + raise Exception("All selectable plot classes must have identical plot options.") styles= [plot.style_opts for plot in plots] @@ -1164,7 +1172,6 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None, self.zorder = zorder self.cyclic_index = cyclic_index self.overlaid = overlaid - self.batched = batched self.overlay_dims = overlay_dims if not isinstance(element, (HoloMap, DynamicMap)): @@ -1179,7 +1186,7 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None, self.stream_sources = compute_overlayable_zorders(self.hmap) plot_element = self.hmap.last - if self.batched and not isinstance(self, GenericOverlayPlot): + if batched and not isinstance(self, GenericOverlayPlot): plot_element = plot_element.last dynamic = isinstance(element, DynamicMap) and not element.unbounded @@ -1203,6 +1210,7 @@ def __init__(self, element, keys=None, ranges=None, dimensions=None, self.param.warning(self._deprecations[p]) super().__init__(keys=keys, dimensions=dimensions, dynamic=dynamic, **applied_params) + self.batched = batched self.streams = get_nested_streams(self.hmap) if streams is None else streams # Attach streams if not overlaid and not a batched ElementPlot @@ -1492,6 +1500,128 @@ def _format_title_components(self, key, dimensions=True, separator='\n'): return (label, group, type_name, dim_title) + def _parse_backend_opt(self, opt, plot, model_accessor_aliases): + """ + Parses a custom option of the form 'model.accessor.option' + and returns the corresponding model and accessor. + """ + accessors = opt.split('.') + if len(accessors) < 2: + self.param.warning(f"Custom option {opt!r} expects at least " + "two accessors separated by '.'") + return + + model_accessor = accessors[0] + + # convert alias to handle key (figure -> fig) + model_accessor = model_accessor_aliases.get(model_accessor) or model_accessor + + if model_accessor in self.handles: + model = self.handles[model_accessor] + elif hasattr(plot, model_accessor): + model = getattr(plot, model_accessor) + else: + self.param.warning( + f"{model_accessor} model could not be resolved " + f"on {type(self).__name__!r} plot. " + f"Ensure the {opt!r} custom option spec " + f"references a valid model in the " + f"plot.handles {list(self.handles.keys())!r} or on the underlying " + f"figure object." + ) + return + + for acc in accessors[1:-1]: + # the logic handles resolving something like: + # legend.get_texts()[0].set_fontsize + if '[' in acc and acc.endswith(']'): + getitem_index = acc.index('[') + # gets the '0:2' or '0,2' or ':2' or '2:' + getitem_spec = acc[getitem_index+1:-1] + try: + if ':' in getitem_spec: + # slice notation + slice_parts = getitem_spec.split(':') + slice_start = None if slice_parts[0] == '' else int(slice_parts[0]) + slice_stop = None if slice_parts[1] == '' else int(slice_parts[1]) + slice_step = None if len(slice_parts) < 3 or slice_parts[2] == '' else int(slice_parts[2]) + getitem_acc = slice(slice_start, slice_stop, slice_step) + elif ',' in getitem_spec: + # multiple items + getitem_acc = [literal_eval(item.strip()) for item in getitem_spec.split(',')] + else: + # single index + getitem_acc = literal_eval(getitem_spec) + except Exception: + self.param.warning( + f"Could not evaluate getitem {getitem_spec!r} " + f"in custom option spec {opt!r}.") + model = None + break + # gets the 'legend.get_texts()' + acc = acc[:getitem_index] + else: + getitem_acc = None + + if "(" in acc and ")" in acc: + method_ini_index = acc.index("(") + method_end_index = acc.index(")") + method_spec = acc[method_ini_index + 1:method_end_index] + try: + if method_spec: + method_parts = method_spec.split(',') + method_args = [] + method_kwargs = {} + for part in method_parts: + if '=' in part: + # Handle keyword argument + key, value = part.split('=') + method_kwargs[key.strip()] = literal_eval(value.strip()) + else: + # Handle regular argument + method_args.append(literal_eval(part.strip())) + else: + method_args = () + method_kwargs = {} + except Exception: + self.param.warning( + f"Could not evaluate method arguments {method_spec!r} " + f"in custom option spec {opt!r}.") + model = None + break + acc = acc[:method_ini_index] + + # finally, we do something with all the things we gathered above + if not isinstance(model, list): + model = getattr(model, acc)(*method_args, **method_kwargs) + else: + model = [getattr(m, acc)(*method_args, **method_kwargs) for m in model] + + if getitem_acc is not None: + if not isinstance(getitem_acc, list): + model = model.__getitem__(getitem_acc) + else: + model = [model.__getitem__(i) for i in getitem_acc] + acc = acc[method_end_index:] + + if acc == "" or model is None: + continue + + if not hasattr(model, acc): + self.param.warning( + f"Could not resolve {acc!r} attribute on " + f"{type(model).__name__!r} model. Ensure the " + f"custom option spec you provided " + f"references a valid submodel." + ) + model = None + break + + model = getattr(model, acc) + + attr_accessor = accessors[-1] + return model, attr_accessor + def update_frame(self, key, ranges=None): """ Set the plot(s) to the given frame number. Operates by diff --git a/holoviews/tests/conftest.py b/holoviews/tests/conftest.py index 05a7b98ff1..ea50664a3f 100644 --- a/holoviews/tests/conftest.py +++ b/holoviews/tests/conftest.py @@ -17,3 +17,12 @@ def pytest_collection_modifyitems(config, items): config.hook.pytest_deselected(items=skipped) items[:] = selected + + +try: + # From Dask 2023.7,1 they now automatic convert strings + # https://docs.dask.org/en/stable/changelog.html#v2023-7-1 + import dask + dask.config.set({"dataframe.convert-string": False}) +except Exception: + pass diff --git a/holoviews/tests/core/data/test_xarrayinterface.py b/holoviews/tests/core/data/test_xarrayinterface.py index da93f45d8c..9115bd65b7 100644 --- a/holoviews/tests/core/data/test_xarrayinterface.py +++ b/holoviews/tests/core/data/test_xarrayinterface.py @@ -11,7 +11,7 @@ except ImportError: raise SkipTest("Could not import xarray, skipping XArrayInterface tests.") -from holoviews.core.data import Dataset, concat +from holoviews.core.data import Dataset, concat, XArrayInterface from holoviews.core.dimension import Dimension from holoviews.core.spaces import HoloMap from holoviews.element import Image, RGB, HSV, QuadMesh @@ -258,6 +258,18 @@ def test_mask_2d_array_transposed(self): expected[mask] = np.nan self.assertEqual(masked_array, expected) + def test_from_empty_numpy(self): + """ + Datashader sometimes pass an empty array to the interface + """ + kdims = ["dim_0", "dim_1"] + vdims = ["dim_2"] + ds = XArrayInterface.init(Image, np.array([]), kdims, vdims) + assert isinstance(ds[0], xr.Dataset) + assert ds[0][vdims[0]].size == 0 + assert ds[1]["kdims"] == kdims + assert ds[1]["vdims"] == vdims + # Disabled tests for NotImplemented methods def test_dataset_array_init_hm(self): "Tests support for arrays (homogeneous)" diff --git a/holoviews/tests/core/test_options.py b/holoviews/tests/core/test_options.py index 0ee78b9d22..06ecbf5558 100644 --- a/holoviews/tests/core/test_options.py +++ b/holoviews/tests/core/test_options.py @@ -406,7 +406,7 @@ def test_specification_specific_to_general_group_and_label(self): self.assertEqual(node1.kwargs, {'cmap': 'viridis', 'interpolation': 'nearest'}) self.assertEqual(node2.kwargs, {'alpha': 0.2}) - def test_custom_opts_to_default_inheritance(self): + def test_backend_opts_to_default_inheritance(self): """ Checks customs inheritance backs off to default tree correctly using .opts. diff --git a/holoviews/tests/element/test_elementconstructors.py b/holoviews/tests/element/test_elementconstructors.py index 9da202ad9a..fe752ecf09 100644 --- a/holoviews/tests/element/test_elementconstructors.py +++ b/holoviews/tests/element/test_elementconstructors.py @@ -1,5 +1,6 @@ import param import numpy as np +import pandas as pd from holoviews import ( Dimension, Dataset, Element, Annotation, Curve, Path, Histogram, @@ -153,6 +154,50 @@ def test_vectorfield_string_signature(self): self.assertEqual(vectorfield.kdims, [Dimension('a'), Dimension('b')]) self.assertEqual(vectorfield.vdims, [Dimension('c'), Dimension('d')]) + def test_vectorfield_from_uv(self): + x = np.linspace(-1, 1, 4) + X, Y = np.meshgrid(x, x) + U, V = 3 * X, 4 * Y + vectorfield = VectorField.from_uv((X, Y, U, V)) + + angle = np.arctan2(V, U) + mag = np.hypot(U, V) + kdims = [Dimension('x'), Dimension('y')] + vdims = [ + Dimension('Angle', cyclic=True, range=(0,2*np.pi)), + Dimension('Magnitude') + ] + self.assertEqual(vectorfield.kdims, kdims) + self.assertEqual(vectorfield.vdims, vdims) + self.assertEqual(vectorfield.dimension_values(0), X.T.flatten()) + self.assertEqual(vectorfield.dimension_values(1), Y.T.flatten()) + self.assertEqual(vectorfield.dimension_values(2), angle.T.flatten()) + self.assertEqual(vectorfield.dimension_values(3), mag.T.flatten()) + + def test_vectorfield_from_uv_dataframe(self): + x = np.linspace(-1, 1, 4) + X, Y = np.meshgrid(x, x) + U, V = 5 * X, 5 * Y + df = pd.DataFrame({ + "x": X.flatten(), + "y": Y.flatten(), + "u": U.flatten(), + "v": V.flatten(), + }) + vectorfield = VectorField.from_uv(df, ["x", "y"], ["u", "v"]) + + angle = np.arctan2(V, U) + mag = np.hypot(U, V) + kdims = [Dimension('x'), Dimension('y')] + vdims = [ + Dimension('Angle', cyclic=True, range=(0,2*np.pi)), + Dimension('Magnitude') + ] + self.assertEqual(vectorfield.kdims, kdims) + self.assertEqual(vectorfield.vdims, vdims) + self.assertEqual(vectorfield.dimension_values(2, flat=False), angle.flat) + self.assertEqual(vectorfield.dimension_values(3, flat=False), mag.flat) + def test_path_string_signature(self): path = Path([], ['a', 'b']) self.assertEqual(path.kdims, [Dimension('a'), Dimension('b')]) diff --git a/holoviews/tests/element/test_raster.py b/holoviews/tests/element/test_raster.py index cd3f5d5d41..8edf0f6f4a 100644 --- a/holoviews/tests/element/test_raster.py +++ b/holoviews/tests/element/test_raster.py @@ -55,6 +55,19 @@ def test_not_using_class_variables_vdims(self): assert i is not c assert i == c + def test_nodata(self): + N = 2 + rgb_d = np.linspace(0, 1, N * N * 3).reshape(N, N, 3) + rgb = RGB(rgb_d) + assert sum(np.isnan(rgb["R"])) == 0 + assert sum(np.isnan(rgb["G"])) == 0 + assert sum(np.isnan(rgb["B"])) == 0 + + rgb_n = rgb.redim.nodata(R=0) + assert sum(np.isnan(rgb_n["R"])) == 1 + assert sum(np.isnan(rgb_n["G"])) == 0 + assert sum(np.isnan(rgb_n["B"])) == 0 + class TestHSV(ComparisonTestCase): def setUp(self): diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py index c6dc86601f..64b556bcbf 100644 --- a/holoviews/tests/operation/test_datashader.py +++ b/holoviews/tests/operation/test_datashader.py @@ -9,11 +9,13 @@ from holoviews import ( Dimension, Curve, Points, Image, Dataset, RGB, Path, Graph, TriMesh, QuadMesh, NdOverlay, Contours, Spikes, Spread, Area, Rectangles, - Segments, Polygons, Nodes + Segments, Polygons, Nodes, DynamicMap, Overlay ) +from holoviews.util import render from holoviews.streams import Tap from holoviews.element.comparison import ComparisonTestCase from numpy import nan +from holoviews.operation import apply_when from packaging.version import Version try: @@ -1201,6 +1203,26 @@ def test_rasterize_image_expand_default(self): output = img.data["z"].to_numpy() assert np.isnan(output).any() + def test_rasterize_apply_when_instance_with_line_width(self): + df = pd.DataFrame( + np.random.multivariate_normal( + (0, 0), [[0.1, 0.1], [0.1, 1.0]], (100,)) + ) + df.columns = ["a", "b"] + + curve = Curve(df, kdims=["a"], vdims=["b"]) + # line_width is not a parameter + custom_rasterize = rasterize.instance(line_width=2) + assert {'line_width': 2} == custom_rasterize._rasterize__instance_kwargs + output = apply_when( + curve, operation=custom_rasterize, predicate=lambda x: len(x) > 10 + ) + render(output, "bokeh") + assert isinstance(output, DynamicMap) + overlay = output.items()[0][1] + assert isinstance(overlay, Overlay) + assert len(overlay) == 2 + @pytest.mark.parametrize("agg_input_fn,index_col", ( diff --git a/holoviews/tests/plotting/bokeh/test_elementplot.py b/holoviews/tests/plotting/bokeh/test_elementplot.py index 457dc5c421..f83259b697 100644 --- a/holoviews/tests/plotting/bokeh/test_elementplot.py +++ b/holoviews/tests/plotting/bokeh/test_elementplot.py @@ -8,7 +8,7 @@ from holoviews.core import Dimension, DynamicMap, NdOverlay, HoloMap from holoviews.core.util import dt_to_int -from holoviews.element import Curve, Image, Scatter, Labels +from holoviews.element import Curve, Image, Scatter, Labels, HeatMap from holoviews.streams import Stream, PointDraw from holoviews.plotting.util import process_cmap from holoviews.plotting.bokeh.util import bokeh3 @@ -21,7 +21,7 @@ from bokeh.document import Document from bokeh.models import tools -from bokeh.models import (PrintfTickFormatter, +from bokeh.models import (PrintfTickFormatter, FixedTicker, NumeralTickFormatter, LogTicker, LinearColorMapper, LogColorMapper, EqHistColorMapper) @@ -791,7 +791,57 @@ def test_element_data_aspect_frame_height_responsive(self): self.assertEqual(plot.state.sizing_mode, 'fixed') self.log_handler.assertContains('WARNING', "responsive mode could not be enabled") + ################################################################# + # Custom opts tests + ################################################################# + + def test_element_backend_opts(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, + backend_opts={ + "colorbar.title": "Testing", + "colorbar.ticker": FixedTicker(ticks=(3.5, 5)), + "colorbar.major_label_overrides": {3.5: "A", 5: "B"}, + }, + ) + plot = bokeh_renderer.get_plot(heat_map) + colorbar = plot.handles['colorbar'] + self.assertEqual(colorbar.title, "Testing") + self.assertEqual(colorbar.ticker.ticks, (3.5, 5)) + self.assertEqual(colorbar.major_label_overrides, {3.5: "A", 5: "B"}) + + def test_element_backend_opts_alias(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, + backend_opts={ + "cbar.title": "Testing", + "cbar.ticker": FixedTicker(ticks=(3.5, 5)), + "cbar.major_label_overrides": {3.5: "A", 5: "B"}, + }, + ) + plot = bokeh_renderer.get_plot(heat_map) + colorbar = plot.handles['colorbar'] + self.assertEqual(colorbar.title, "Testing") + self.assertEqual(colorbar.ticker.ticks, (3.5, 5)) + self.assertEqual(colorbar.major_label_overrides, {3.5: "A", 5: "B"}) + + def test_element_backend_opts_two_accessors(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, backend_opts={"colorbar": "Testing"}, + ) + bokeh_renderer.get_plot(heat_map) + self.log_handler.assertContains( + "WARNING", "Custom option 'colorbar' expects at least two" + ) + def test_element_backend_opts_model_not_resolved(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, backend_opts={"cb.title": "Testing"}, + ) + bokeh_renderer.get_plot(heat_map) + self.log_handler.assertContains( + "WARNING", "cb model could not be" + ) class TestColorbarPlot(LoggingComparisonTestCase, TestBokehPlot): diff --git a/holoviews/tests/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py index 18bc2941d7..aa0e04fa84 100644 --- a/holoviews/tests/plotting/bokeh/test_plot.py +++ b/holoviews/tests/plotting/bokeh/test_plot.py @@ -1,3 +1,5 @@ +import numpy as np +import pytest import pyviz_comms as comms from param import concrete_descendents @@ -5,12 +7,15 @@ from holoviews.core.element import Element from holoviews.core.options import Store from holoviews.element.comparison import ComparisonTestCase +from holoviews import Curve from bokeh.models import ( - ColumnDataSource, LinearColorMapper, LogColorMapper, HoverTool + ColumnDataSource, CustomJS, LinearColorMapper, LogColorMapper, HoverTool ) from holoviews.plotting.bokeh.callbacks import Callback from holoviews.plotting.bokeh.element import ElementPlot +from holoviews.plotting.bokeh.util import bokeh3 + bokeh_renderer = Store.renderers['bokeh'] from .. import option_intersections @@ -76,3 +81,46 @@ def _test_hover_info(self, element, tooltips, line_policy='nearest', formatters= print(renderers, hover) for renderer in renderers: self.assertTrue(any(renderer in h.renderers for h in hover)) + + +@pytest.mark.skipif(not bokeh3, reason="Bokeh>=3.0 required") +def test_sync_two_plots(): + curve = lambda i: Curve(np.arange(10) * i, label="ABC"[i]) + plot1 = curve(0) * curve(1) + plot2 = curve(0) * curve(1) * curve(2) + combined_plot = plot1 + plot2 + + grid_bkplot = bokeh_renderer.get_plot(combined_plot).handles["plot"] + for p, *_ in grid_bkplot.children: + for r in p.renderers: + if r.name == "C": + assert r.js_property_callbacks == {} + else: + k, v = next(iter(r.js_property_callbacks.items())) + assert k == "change:muted" + assert len(v) == 1 + assert isinstance(v[0], CustomJS) + assert v[0].code == "dst.muted = src.muted" + + +@pytest.mark.skipif(not bokeh3, reason="Bokeh>=3.0 required") +def test_sync_three_plots(): + curve = lambda i: Curve(np.arange(10) * i, label="ABC"[i]) + plot1 = curve(0) * curve(1) + plot2 = curve(0) * curve(1) * curve(2) + plot3 = curve(0) * curve(1) + combined_plot = plot1 + plot2 + plot3 + + grid_bkplot = bokeh_renderer.get_plot(combined_plot).handles["plot"] + for p, *_ in grid_bkplot.children: + for r in p.renderers: + if r.name == "C": + assert r.js_property_callbacks == {} + else: + k, v = next(iter(r.js_property_callbacks.items())) + assert k == "change:muted" + assert len(v) == 2 + assert isinstance(v[0], CustomJS) + assert v[0].code == "dst.muted = src.muted" + assert isinstance(v[1], CustomJS) + assert v[1].code == "dst.muted = src.muted" diff --git a/holoviews/tests/plotting/bokeh/test_rasterplot.py b/holoviews/tests/plotting/bokeh/test_rasterplot.py index 51e0de98ba..319cd7f0e1 100644 --- a/holoviews/tests/plotting/bokeh/test_rasterplot.py +++ b/holoviews/tests/plotting/bokeh/test_rasterplot.py @@ -42,6 +42,15 @@ def test_nodata_array_uint(self): self.assertEqual(source.data['image'][0], np.array([[2, np.NaN], [np.NaN, 1]])) + def test_nodata_rgb(self): + N = 2 + rgb_d = np.linspace(0, 1, N * N * 3).reshape(N, N, 3) + rgb = RGB(rgb_d).redim.nodata(R=0) + plot = bokeh_renderer.get_plot(rgb) + image_data = plot.handles["source"].data["image"][0] + # Image sets nan-values to 0 + assert (image_data == 0).sum() == 1 + def test_raster_invert_axes(self): arr = np.array([[0, 1, 2], [3, 4, 5]]) raster = Raster(arr).opts(invert_axes=True) diff --git a/holoviews/tests/plotting/matplotlib/test_elementplot.py b/holoviews/tests/plotting/matplotlib/test_elementplot.py index f9122d0118..548f95af17 100644 --- a/holoviews/tests/plotting/matplotlib/test_elementplot.py +++ b/holoviews/tests/plotting/matplotlib/test_elementplot.py @@ -2,7 +2,7 @@ from matplotlib import style from holoviews.core.spaces import DynamicMap -from holoviews.element import Image, Curve, Scatter, Scatter3D +from holoviews.element import Image, Curve, Scatter, Scatter3D, HeatMap from holoviews.streams import Stream from .test_plot import TestMPLPlot, mpl_renderer @@ -10,8 +10,10 @@ from matplotlib.ticker import FormatStrFormatter, FuncFormatter, PercentFormatter from matplotlib.projections import PolarAxes +from ...utils import LoggingComparisonTestCase -class TestElementPlot(TestMPLPlot): + +class TestElementPlot(LoggingComparisonTestCase, TestMPLPlot): def test_stream_cleanup(self): stream = Stream.define('Test', test=1)() @@ -186,6 +188,122 @@ def test_element_polar_xlimits(self): self.assertIsInstance(ax, PolarAxes) self.assertEqual(ax.get_xlim(), (0, 2 * np.pi)) + ################################################################# + # Custom opts tests + ################################################################# + + def test_element_backend_opts(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, + backend_opts={ + "colorbar.set_label": "Testing", + "colorbar.set_ticks": [3.5, 5], + "colorbar.ax.yticklabels": ["A", "B"], + }, + ) + plot = mpl_renderer.get_plot(heat_map) + colorbar = plot.handles['cbar'] + self.assertEqual(colorbar.ax.yaxis.get_label().get_text(), "Testing") + self.assertEqual(colorbar.get_ticks(), (3.5, 5)) + ticklabels = [ticklabel.get_text() for ticklabel in colorbar.ax.get_yticklabels()] + self.assertEqual(ticklabels, ["A", "B"]) + + def test_element_backend_opts_alias(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, + backend_opts={ + "cbar.set_label": "Testing", + "cbar.set_ticks": [3.5, 5], + "cbar.ax.yticklabels": ["A", "B"] + }, + ) + plot = mpl_renderer.get_plot(heat_map) + colorbar = plot.handles['cbar'] + self.assertEqual(colorbar.ax.yaxis.get_label().get_text(), "Testing") + self.assertEqual(colorbar.get_ticks(), (3.5, 5)) + ticklabels = [ticklabel.get_text() for ticklabel in colorbar.ax.get_yticklabels()] + self.assertEqual(ticklabels, ["A", "B"]) + + def test_element_backend_opts_method(self): + a = Curve([1, 2, 3], label="a") + b = Curve([1, 4, 9], label="b") + curve = (a * b).opts( + show_legend=True, + backend_opts={ + "legend.frame_on": False, + } + ) + plot = mpl_renderer.get_plot(curve) + legend = plot.handles['legend'] + self.assertFalse(legend.get_frame_on()) + + def test_element_backend_opts_sequential_method(self): + a = Curve([1, 2, 3], label="a") + b = Curve([1, 4, 9], label="b") + curve = (a * b).opts( + show_legend=True, + backend_opts={ + "legend.get_title().set_fontsize": 188, + } + ) + plot = mpl_renderer.get_plot(curve) + legend = plot.handles['legend'] + self.assertEqual(legend.get_title().get_fontsize(), 188) + + def test_element_backend_opts_getitem(self): + a = Curve([1, 2, 3], label="a") + b = Curve([1, 4, 9], label="b") + c = Curve([1, 4, 18], label="c") + d = Curve([1, 4, 36], label="d") + e = Curve([1, 4, 36], label="e") + curve = (a * b * c * d * e).opts( + show_legend=True, + backend_opts={ + "legend.get_texts()[0].fontsize": 188, + "legend.get_texts()[1:3].fontsize": 288, + "legend.get_texts()[3,4].fontsize": 388, + } + ) + plot = mpl_renderer.get_plot(curve) + legend = plot.handles['legend'] + self.assertEqual(legend.get_texts()[0].get_fontsize(), 188) + self.assertEqual(legend.get_texts()[1].get_fontsize(), 288) + self.assertEqual(legend.get_texts()[2].get_fontsize(), 288) + self.assertEqual(legend.get_texts()[3].get_fontsize(), 388) + self.assertEqual(legend.get_texts()[4].get_fontsize(), 388) + + def test_element_backend_opts_two_accessors(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, backend_opts={"colorbar": "Testing"}, + ) + mpl_renderer.get_plot(heat_map) + self.log_handler.assertContains( + "WARNING", "Custom option 'colorbar' expects at least two" + ) + + def test_element_backend_opts_model_not_resolved(self): + heat_map = HeatMap([(1, 2, 3), (2, 3, 4), (3, 4, 5)]).opts( + colorbar=True, backend_opts={"cb.title": "Testing"}, + ) + mpl_renderer.get_plot(heat_map) + self.log_handler.assertContains( + "WARNING", "cb model could not be" + ) + + def test_element_backend_opts_model_invalid_method(self): + a = Curve([1, 2, 3], label="a") + b = Curve([1, 4, 9], label="b") + curve = (a * b).opts( + show_legend=True, + backend_opts={ + "legend.get_texts()[0,1].f0ntzise": 811, + } + ) + mpl_renderer.get_plot(curve) + self.log_handler.assertContains( + "WARNING", "valid method on the specified model" + ) + class TestColorbarPlot(TestMPLPlot): diff --git a/setup.cfg b/setup.cfg index 07c2c7d24e..4192dd2b5f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,3 +6,4 @@ namespace_map = ibis-framework=ibis-sqlite dask=dask-core geoviews=geoviews-core + matplotlib=matplotlib-base