Repository: holoviz/holoviews

Issue:
Support syncing visibility between legend labels in different figures
As shown in https://github.com/holoviz/hvplot/issues/937#issuecomment-1498749914 it is possible to sync the visibility between lines in different figures. 

This seems like a great addition to have in Holoviews. 

https://user-images.githubusercontent.com/19758978/230332683-7a8d6794-146a-4f34-8ced-d39e186e2cf5.mp4


Discussion:

Resolving patch:
diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py
--- 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")
         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
--- 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/util.py b/holoviews/plotting/bokeh/util.py
--- 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):
     """


Test patch:
diff --git a/holoviews/tests/plotting/bokeh/test_plot.py b/holoviews/tests/plotting/bokeh/test_plot.py
--- 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"