Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync legends of LayoutPlot #5763

Merged
merged 21 commits into from
Jul 21, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions holoviews/plotting/bokeh/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions holoviews/plotting/bokeh/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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""")

Expand Down Expand Up @@ -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)

Expand Down
82 changes: 82 additions & 0 deletions holoviews/plotting/bokeh/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from collections import defaultdict
from contextlib import contextmanager
from itertools import permutations
from types import FunctionType

import param
Expand All @@ -28,13 +29,15 @@
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 (
arraylike_types, callable_name, cftime_types,
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

Expand Down Expand Up @@ -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):
"""
Expand Down