From 8b8bf8a13759dbac50ea7e404a647b1f51a6c203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 15 Jun 2023 18:23:51 +0200 Subject: [PATCH 01/19] Add sync legend option to LayoutPlot --- holoviews/plotting/bokeh/element.py | 2 ++ holoviews/plotting/bokeh/plot.py | 9 +++++++-- holoviews/plotting/bokeh/util.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index e2809bd794..945324c2a7 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1170,6 +1170,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", None) renderer = getattr(plot, plot_method)(**dict(properties, **mapping)) return renderer, renderer.glyph diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index 78f7408f36..0512033d66 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: @@ -682,6 +682,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 +965,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/util.py b/holoviews/plotting/bokeh/util.py index d70c195067..339e3df9bf 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -407,6 +407,37 @@ def merge(tool, group): return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore) if merge_tools else tools) + +def sync_legends(plot_grid): + """This syncs the legends of all plots in a grid based on their name. + + Parameters + ---------- + plot_grid : bokeh.models.plots.GridPlot + Gridplot to sync legends of. + """ + policy = "muted" + + items = defaultdict(lambda: []) + + # Collect all glyph with names + for fig, *_ in plot_grid.children: + for r in fig.renderers: + if r.name: + items[r.name].append(r) + + # Link all glyphs with the same name + for item in items.values(): + for src in item: + for dst in item: + src.js_on_change( + policy, + CustomJS( + code=f"dst.{policy} = src.{policy}", args=dict(src=src, dst=dst) + ), + ) + + @contextmanager def silence_warnings(*warnings): """ From 5836f7baf464715faaa8eb0df87410e1be63d32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 15 Jun 2023 18:24:23 +0200 Subject: [PATCH 02/19] Add utility function one_legend --- holoviews/plotting/bokeh/util.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 339e3df9bf..4379327454 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -437,6 +437,24 @@ def sync_legends(plot_grid): ), ) +def one_legend(plot_grid, legend_no=0, legend_position="top_right"): + """ Displays only one legend in a grid of plots. + + Parameters + ---------- + plot_grid : bokeh.models.plots.GridPlot + Gridplot where one legend is chosen. + legend_no : int + Figure in gridplot which shows the legend. + legend_position : str + Position of the legend. + """ + for i, plot in enumerate(plot_grid): + if legend_no == i: + plot.opts(show_legend=True, legend_position=legend_position) + else: + plot.opts(show_legend=False) + @contextmanager def silence_warnings(*warnings): From 73f5ddb82a1568908f8a364f35d3c2156e1df6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 15 Jun 2023 18:58:53 +0200 Subject: [PATCH 03/19] Only sync for figures --- holoviews/plotting/bokeh/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 4379327454..cb64b083ed 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -422,6 +422,8 @@ def sync_legends(plot_grid): # Collect all glyph with names for fig, *_ in plot_grid.children: + if not isinstance(fig, figure): + continue for r in fig.renderers: if r.name: items[r.name].append(r) From a157ff1c7598be77bc686adda028e83c3b945695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 15 Jun 2023 19:06:16 +0200 Subject: [PATCH 04/19] Add it to GridPlot --- holoviews/plotting/bokeh/plot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/holoviews/plotting/bokeh/plot.py b/holoviews/plotting/bokeh/plot.py index 0512033d66..e6267edb6d 100644 --- a/holoviews/plotting/bokeh/plot.py +++ b/holoviews/plotting/bokeh/plot.py @@ -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) From 188efbbb356c39f0e65a4592b0dfa2f05ee92123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 15 Jun 2023 19:10:26 +0200 Subject: [PATCH 05/19] Return plot --- holoviews/plotting/bokeh/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index cb64b083ed..b63a9fa4b6 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -457,6 +457,8 @@ def one_legend(plot_grid, legend_no=0, legend_position="top_right"): else: plot.opts(show_legend=False) + return plot + @contextmanager def silence_warnings(*warnings): From 32155703547651ae5b8b5a3fa5fadb1200cf1c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 10:12:38 +0200 Subject: [PATCH 06/19] Only sync for Bokeh 3 --- holoviews/plotting/bokeh/util.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index b63a9fa4b6..5502d4c129 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -411,11 +411,15 @@ def merge(tool, group): def sync_legends(plot_grid): """This syncs the legends of all plots in a grid based on their name. + Only works for Bokeh 3 and above. + Parameters ---------- plot_grid : bokeh.models.plots.GridPlot Gridplot to sync legends of. """ + if not bokeh3: + return policy = "muted" items = defaultdict(lambda: []) From 0b385c98fd8b93f0a0cf44dda1cd633e8faa2afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 10:13:46 +0200 Subject: [PATCH 07/19] Clean up --- holoviews/plotting/bokeh/util.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 5502d4c129..4723bc275a 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 @@ -420,11 +421,9 @@ def sync_legends(plot_grid): """ if not bokeh3: return - policy = "muted" - - items = defaultdict(lambda: []) # Collect all glyph with names + items = defaultdict(lambda: []) for fig, *_ in plot_grid.children: if not isinstance(fig, figure): continue @@ -433,15 +432,14 @@ def sync_legends(plot_grid): items[r.name].append(r) # Link all glyphs with the same name + policy = "muted" + code = f"dst.{policy} = src.{policy}" for item in items.values(): - for src in item: - for dst in item: - src.js_on_change( - policy, - CustomJS( - code=f"dst.{policy} = src.{policy}", args=dict(src=src, dst=dst) - ), - ) + for src, dst in permutations(item, 2): + src.js_on_change( + policy, + CustomJS(code=code, args=dict(src=src, dst=dst)), + ) def one_legend(plot_grid, legend_no=0, legend_position="top_right"): """ Displays only one legend in a grid of plots. @@ -461,7 +459,7 @@ def one_legend(plot_grid, legend_no=0, legend_position="top_right"): else: plot.opts(show_legend=False) - return plot + return plot_grid @contextmanager From 162e12c36c8b58d3c9791c4b07764ad9a818fa00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 12:25:06 +0200 Subject: [PATCH 08/19] Add option for click_policy and support Row/Column --- holoviews/plotting/bokeh/util.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 4723bc275a..7332b4b213 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -409,22 +409,26 @@ def merge(tool, group): return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore) if merge_tools else tools) -def sync_legends(plot_grid): +def sync_legends(plot_layout, click_policy="muted"): """This syncs the legends of all plots in a grid based on their name. Only works for Bokeh 3 and above. Parameters ---------- - plot_grid : bokeh.models.plots.GridPlot + plot_layout : bokeh.models.{GridPlot, Row, Column} Gridplot to sync legends of. + click_policy : str, optional + The click policy to use for syncing, can be either "muted" or "visible". Defaults to "muted". """ if not bokeh3: return # Collect all glyph with names items = defaultdict(lambda: []) - for fig, *_ in plot_grid.children: + for fig in plot_layout.children: + if isinstance(fig, tuple): # GridPlot + fig = fig[0] if not isinstance(fig, figure): continue for r in fig.renderers: @@ -432,12 +436,11 @@ def sync_legends(plot_grid): items[r.name].append(r) # Link all glyphs with the same name - policy = "muted" - code = f"dst.{policy} = src.{policy}" + code = f"dst.{click_policy} = src.{click_policy}" for item in items.values(): for src, dst in permutations(item, 2): src.js_on_change( - policy, + click_policy, CustomJS(code=code, args=dict(src=src, dst=dst)), ) From 214aa171119c750d92c2eba2eca0c9fd9834a1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 12:36:21 +0200 Subject: [PATCH 09/19] Automatic detect click_policy --- holoviews/plotting/bokeh/util.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 7332b4b213..7d6c3afe68 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -36,6 +36,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 bokeh_version = Version(bokeh.__version__) @@ -409,7 +410,7 @@ def merge(tool, group): return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore) if merge_tools else tools) -def sync_legends(plot_layout, click_policy="muted"): +def sync_legends(plot_layout): """This syncs the legends of all plots in a grid based on their name. Only works for Bokeh 3 and above. @@ -418,14 +419,13 @@ def sync_legends(plot_layout, click_policy="muted"): ---------- plot_layout : bokeh.models.{GridPlot, Row, Column} Gridplot to sync legends of. - click_policy : str, optional - The click policy to use for syncing, can be either "muted" or "visible". Defaults to "muted". """ if not bokeh3: return # Collect all glyph with names items = defaultdict(lambda: []) + click_policies = set() for fig in plot_layout.children: if isinstance(fig, tuple): # GridPlot fig = fig[0] @@ -434,13 +434,21 @@ def sync_legends(plot_layout, click_policy="muted"): for r in fig.renderers: if r.name: items[r.name].append(r) + if fig.legend: + click_policies.add(fig.legend.click_policy) + + if len(click_policies) > 1: + warn("Click policy of legends are not the same, no syncing will happen.") + return # Link all glyphs with the same name - code = f"dst.{click_policy} = src.{click_policy}" + 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( - click_policy, + policy, CustomJS(code=code, args=dict(src=src, dst=dst)), ) From c5e7018a7b46aa001c00c86c8a4eee698c8347e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 12:44:43 +0200 Subject: [PATCH 10/19] Ignore 'none' in click_policy --- holoviews/plotting/bokeh/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 7d6c3afe68..39ded8da56 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -437,6 +437,7 @@ def sync_legends(plot_layout): if fig.legend: click_policies.add(fig.legend.click_policy) + click_policies.remove("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 From f7277cc1afb38a0952f94469956fab8ea6df62e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 12:59:06 +0200 Subject: [PATCH 11/19] Only sync label if there are more than 1 children --- holoviews/plotting/bokeh/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 39ded8da56..eef6cbebe0 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -420,7 +420,7 @@ def sync_legends(plot_layout): plot_layout : bokeh.models.{GridPlot, Row, Column} Gridplot to sync legends of. """ - if not bokeh3: + if not bokeh3 or len(plot_layout.children) < 2: return # Collect all glyph with names From 3fddb7ae728b1595ad3019c6963c8b997666dd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 12:59:25 +0200 Subject: [PATCH 12/19] Support one figure in one_legend --- holoviews/plotting/bokeh/util.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index eef6cbebe0..9d7ef1ad1f 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -29,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 ( @@ -453,25 +454,31 @@ def sync_legends(plot_layout): CustomJS(code=code, args=dict(src=src, dst=dst)), ) -def one_legend(plot_grid, legend_no=0, legend_position="top_right"): +def one_legend(plot_layout, legend_no=0, legend_position="top_right"): """ Displays only one legend in a grid of plots. Parameters ---------- - plot_grid : bokeh.models.plots.GridPlot - Gridplot where one legend is chosen. + plot_layout : hv.Layout + Holoviews layout where one legend is chosen. legend_no : int Figure in gridplot which shows the legend. legend_position : str Position of the legend. """ - for i, plot in enumerate(plot_grid): + if not isinstance(plot_layout, Layout): + plot_layout = [plot_layout] + + for i, plot in enumerate(plot_layout): if legend_no == i: plot.opts(show_legend=True, legend_position=legend_position) else: plot.opts(show_legend=False) - return plot_grid + if isinstance(plot_layout, list): + return plot_layout[0] + + return plot_layout @contextmanager From d38168a5a58b88f6d0a61f07bee2dca13d820194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 13:03:59 +0200 Subject: [PATCH 13/19] Change remove to discard --- holoviews/plotting/bokeh/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 9d7ef1ad1f..d0322fe8a1 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -438,7 +438,7 @@ def sync_legends(plot_layout): if fig.legend: click_policies.add(fig.legend.click_policy) - click_policies.remove("none") # If legend is not visible, click_policy is "none" + 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 From 42b54c3e13fe5776aefb1fdf623610b8220ca8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 17:28:25 +0200 Subject: [PATCH 14/19] Don't do anything if click_policies is empty --- holoviews/plotting/bokeh/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index d0322fe8a1..191a9e4ace 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -442,6 +442,8 @@ def sync_legends(plot_layout): 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"} From 08125b2f40d560b6e8d20dbdf1dccd60b1d558f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 16 Jun 2023 17:29:40 +0200 Subject: [PATCH 15/19] update docstring to holoviews element --- holoviews/plotting/bokeh/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 191a9e4ace..7098fa0d8c 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -461,8 +461,8 @@ def one_legend(plot_layout, legend_no=0, legend_position="top_right"): Parameters ---------- - plot_layout : hv.Layout - Holoviews layout where one legend is chosen. + plot_layout : Holoviews element + Holoviews element where one legend is chosen. legend_no : int Figure in gridplot which shows the legend. legend_position : str From e7ce433546b05243c3581cc49bd96a2679b0bc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 19 Jul 2023 12:32:31 +0200 Subject: [PATCH 16/19] Rename to select_legends and allow multiple legends --- holoviews/plotting/bokeh/element.py | 2 +- holoviews/plotting/bokeh/util.py | 34 +++++++++++++++++------------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 391ae23725..17316a7ebb 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1171,7 +1171,7 @@ def _init_glyph(self, plot, mapping, properties): 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", None) + properties["name"] = properties.get("legend_label") or properties.get("legend_field") renderer = getattr(plot, plot_method)(**dict(properties, **mapping)) return renderer, renderer.glyph diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index 36c0e52ec1..d04c68b85a 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -457,31 +457,37 @@ def sync_legends(plot_layout): CustomJS(code=code, args=dict(src=src, dst=dst)), ) -def one_legend(plot_layout, legend_no=0, legend_position="top_right"): - """ Displays only one legend in a grid of plots. + +def select_legends(holoviews_layout, figure_index=None, legend_position="top_right"): + """ Only displays selected legends in plot layout. Parameters ---------- - plot_layout : Holoviews element - Holoviews element where one legend is chosen. - legend_no : int - Figure in gridplot which shows the legend. + 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. + Position of the legend(s). """ - if not isinstance(plot_layout, Layout): - plot_layout = [plot_layout] + 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(plot_layout): - if legend_no == i: + 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(plot_layout, list): - return plot_layout[0] + if isinstance(holoviews_layout, list): + return holoviews_layout[0] - return plot_layout + return holoviews_layout @contextmanager From a51638d455f12eb0ba38b912df9d6bfc0a49ff08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 19 Jul 2023 12:34:49 +0200 Subject: [PATCH 17/19] Rename plot_layout to bokeh_layout --- holoviews/plotting/bokeh/util.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index d04c68b85a..a683a43029 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -412,23 +412,23 @@ def merge(tool, group): return Toolbar(tools=group_tools(tools, merge=merge, ignore=ignore) if merge_tools else tools) -def sync_legends(plot_layout): +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 ---------- - plot_layout : bokeh.models.{GridPlot, Row, Column} + bokeh_layout : bokeh.models.{GridPlot, Row, Column} Gridplot to sync legends of. """ - if not bokeh3 or len(plot_layout.children) < 2: + if not bokeh3 or len(bokeh_layout.children) < 2: return # Collect all glyph with names - items = defaultdict(lambda: []) + items = defaultdict(list) click_policies = set() - for fig in plot_layout.children: + for fig in bokeh_layout.children: if isinstance(fig, tuple): # GridPlot fig = fig[0] if not isinstance(fig, figure): From 7e4e0b84d63b7c9a91aae25396baaf73f93b6af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 20 Jul 2023 10:42:07 +0200 Subject: [PATCH 18/19] Add unittest --- holoviews/tests/plotting/bokeh/test_plot.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/holoviews/tests/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py index 18bc2941d7..7be351249c 100644 --- a/holoviews/tests/plotting/bokeh/test_plot.py +++ b/holoviews/tests/plotting/bokeh/test_plot.py @@ -1,3 +1,4 @@ +import numpy as np import pyviz_comms as comms from param import concrete_descendents @@ -5,12 +6,14 @@ 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 + bokeh_renderer = Store.renderers['bokeh'] from .. import option_intersections @@ -76,3 +79,21 @@ 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)) + + +def test_sync_legends(): + 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 isinstance(v[0], CustomJS) + assert v[0].code == "dst.muted = src.muted" From d7e46139cad59987880da957f56189f26c343830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Thu, 20 Jul 2023 12:10:36 +0200 Subject: [PATCH 19/19] Add bokeh3 safeguard and add 3 plot test --- holoviews/tests/plotting/bokeh/test_plot.py | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/holoviews/tests/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py index 7be351249c..aa0e04fa84 100644 --- a/holoviews/tests/plotting/bokeh/test_plot.py +++ b/holoviews/tests/plotting/bokeh/test_plot.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import pyviz_comms as comms from param import concrete_descendents @@ -13,6 +14,7 @@ ) 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'] @@ -81,7 +83,8 @@ def _test_hover_info(self, element, tooltips, line_policy='nearest', formatters= self.assertTrue(any(renderer in h.renderers for h in hover)) -def test_sync_legends(): +@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) @@ -95,5 +98,29 @@ def test_sync_legends(): 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"