Source code for panel.pane.holoviews

"""
HoloViews integration for Panel including a Pane to render HoloViews
objects and their widgets and support for Links
"""
from __future__ import annotations

import sys

from collections import OrderedDict, defaultdict
from functools import partial
from typing import (
    TYPE_CHECKING, Any, ClassVar, Mapping, Optional, Tuple, Type,
)

import param

from bokeh.models import Range1d, Spacer as _BkSpacer
from bokeh.themes.theme import Theme
from packaging.version import Version

from ..io import state, unlocked
from ..layout import (
    Column, HSpacer, Row, WidgetBox,
)
from ..viewable import Layoutable, Viewable
from ..widgets import Player
from .base import PaneBase, RerenderError, panel
from .plot import Bokeh, Matplotlib
from .plotly import Plotly

if TYPE_CHECKING:
    from bokeh.document import Document
    from bokeh.model import Model
    from pyviz_comms import Comm


[docs]class HoloViews(PaneBase): """ `HoloViews` panes render any `HoloViews` object using the currently selected backend ('bokeh' (default), 'matplotlib' or 'plotly'). To be able to use the `plotly` backend you must add `plotly` to `pn.extension`. Reference: https://panel.holoviz.org/reference/panes/HoloViews.html :Example: >>> HoloViews(some_holoviews_object) """ backend = param.ObjectSelector( default=None, objects=['bokeh', 'plotly', 'matplotlib'], doc=""" The HoloViews backend used to render the plot (if None defaults to the currently selected renderer).""") center = param.Boolean(default=False, doc=""" Whether to center the plot.""") linked_axes = param.Boolean(default=True, doc=""" Whether to link the axes of bokeh plots inside this pane across a panel layout.""") renderer = param.Parameter(default=None, doc=""" Explicit renderer instance to use for rendering the HoloViews plot. Overrides the backend.""") theme = param.ClassSelector(default=None, class_=(Theme, str), allow_None=True, doc=""" Bokeh theme to apply to the HoloViews plot.""") widget_location = param.ObjectSelector(default='right_top', objects=[ 'left', 'bottom', 'right', 'top', 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'left_top', 'left_bottom', 'right_top', 'right_bottom'], doc=""" The layout of the plot and the widgets. The value refers to the position of the widgets relative to the plot.""") widget_layout = param.ObjectSelector( objects=[WidgetBox, Row, Column], constant=True, default=WidgetBox, doc=""" The layout object to display the widgets in.""") widget_type = param.ObjectSelector(default='individual', objects=['individual', 'scrubber'], doc=""") Whether to generate individual widgets for each dimension or on global scrubber.""") widgets = param.Dict(default={}, doc=""" A mapping from dimension name to a widget instance which will be used to override the default widgets.""") priority: ClassVar[float | bool | None] = 0.8 _alignments = { 'left': (Row, ('start', 'center'), True), 'right': (Row, ('end', 'center'), False), 'top': (Column, ('center', 'start'), True), 'bottom': (Column, ('center', 'end'), False), 'top_left': (Column, 'start', True), 'top_right': (Column, ('end', 'start'), True), 'bottom_left': (Column, ('start', 'end'), False), 'bottom_right': (Column, 'end', False), 'left_top': (Row, 'start', True), 'left_bottom': (Row, ('start', 'end'), True), 'right_top': (Row, ('end', 'start'), False), 'right_bottom': (Row, 'end', False) } _panes: ClassVar[Mapping[str, Type[PaneBase]]] = { 'bokeh': Bokeh, 'matplotlib': Matplotlib, 'plotly': Plotly } _rename: ClassVar[Mapping[str, str | None]] = { 'backend': None, 'center': None, 'linked_axes': None, 'renderer': None, 'theme': None, 'widgets': None, 'widget_layout': None, 'widget_location': None, 'widget_type': None } _rerender_params = ['object', 'backend'] _skip_layoutable = ( 'background', 'css_classes', 'margin', 'name', 'sizing_mode', 'width', 'height', 'max_width', 'max_height' ) def __init__(self, object=None, **params): self._initialized = False self._height_responsive = None self._width_responsive = None self._restore_plot = None super().__init__(object, **params) self.widget_box = self.widget_layout() self._widget_container = [] self._plots = {} self._syncing_props = False self._overrides = [ p for p, v in params.items() if p in Layoutable.param and v != self.param[p].default ] watcher = self.param.watch(self._update_widgets, self._rerender_params) self._internal_callbacks.append(watcher) self._update_responsive() self._update_widgets() self._initialized = True def _param_change(self, *events: param.parameterized.Event) -> None: if self._object_changing: return self._track_overrides(*(e for e in events if e.name in Layoutable.param)) super()._param_change(*(e for e in events if e.name in self._overrides+['css_classes'])) @param.depends('backend', watch=True, on_init=True) def _load_backend(self): from holoviews import Store, extension if self.backend and self.backend not in Store.renderers: ext = extension._backends[self.backend] __import__(f'holoviews.plotting.{ext}') @property def _layout_sizing_mode(self): if self._width_responsive and self._height_responsive: smode = 'stretch_both' elif self._width_responsive: smode = 'stretch_width' elif self._height_responsive: smode = 'stretch_height' else: smode = None return smode @param.depends('center', 'widget_location', watch=True) def _update_layout(self): loc = self.widget_location center = self.center and not self._width_responsive layout, align, widget_first = self._alignments[loc] self.widget_box.align = align self._widget_container = self.widget_box smode = self._layout_sizing_mode layout_smode = 'stretch_width' if not smode and center else smode if not len(self.widget_box): if center: components = [HSpacer(), self, HSpacer()] else: components = [self] self.layout[:] = components self.layout.sizing_mode = layout_smode return items = (self.widget_box, self) if widget_first else (self, self.widget_box) kwargs = {'sizing_mode': smode} if not center: if self.default_layout is layout: components = list(items) else: components = [layout(*items, **kwargs)] elif layout is Column: components = [HSpacer(), layout(*items, **kwargs), HSpacer()] elif loc.startswith('left'): components = [self.widget_box, HSpacer(), self, HSpacer()] else: components = [HSpacer(), self, HSpacer(), self.widget_box] self.layout[:] = components self.layout.sizing_mode = layout_smode #---------------------------------------------------------------- # Callback API #---------------------------------------------------------------- @param.depends('theme', watch=True) def _update_theme(self, *events): if self.theme is None: return for (model, _) in self._models.values(): if model.document: model.document.theme = self.theme @param.depends('object', watch=True) def _update_responsive(self): from holoviews import HoloMap, Store from holoviews.plotting import Plot obj = self.object if isinstance(obj, Plot): if 'responsive' in obj.param: wresponsive = obj.responsive and not obj.width hresponsive = obj.responsive and not obj.height elif 'sizing_mode' in obj.param: mode = obj.sizing_mode if mode: wresponsive = '_width' in mode or '_both' in mode hresponsive = '_height' in mode or '_both' in mode else: wresponsive = hresponsive = False else: wresponsive = hresponsive = False self._width_responsive = wresponsive self._height_responsive = hresponsive return obj = obj.last if isinstance(obj, HoloMap) else obj if obj is None or not Store.renderers: return backend = self.backend or Store.current_backend renderer = self.renderer or Store.renderers[backend] opts = obj.opts.get('plot', backend=backend).kwargs plot_cls = renderer.plotting_class(obj) if backend == 'matplotlib': self._width_responsive = self._height_responsive = False elif backend == 'plotly': responsive = opts.get('responsive', None) width = opts.get('width', None) height = opts.get('height', None) self._width_responsive = responsive and not width self._height_responsive = responsive and not height elif 'sizing_mode' in plot_cls.param: mode = opts.get('sizing_mode') if mode: self._width_responsive = '_width' in mode or '_both' in mode self._height_responsive = '_height' in mode or '_both' in mode else: self._width_responsive = False self._height_responsive = False else: responsive = opts.get('responsive', None) width = opts.get('width', None) frame_width = opts.get('frame_width', None) height = opts.get('height', None) frame_height = opts.get('frame_height', None) self._width_responsive = responsive and not width and not frame_width self._height_responsive = responsive and not height and not frame_height @param.depends('widget_type', 'widgets', watch=True) def _update_widgets(self, *events): if self.object is None: widgets, values = [], [] else: widgets, values = self.widgets_from_dimensions( self.object, self.widgets, self.widget_type) self._values = values # Clean up anything models listening to the previous widgets for cb in list(self._internal_callbacks): if cb.inst in self.widget_box.objects: cb.inst.param.unwatch(cb) self._internal_callbacks.remove(cb) # Add new widget callbacks for widget in widgets: watcher = widget.param.watch(self._widget_callback, 'value') self._internal_callbacks.append(watcher) self.widget_box[:] = widgets if ((widgets and self.widget_box not in self._widget_container) or (not widgets and self.widget_box in self._widget_container) or not self._initialized): self._update_layout() def _update_plot(self, plot, pane): from holoviews.core.util import cross_index, wrap_tuple_streams widgets = self.widget_box.objects if not widgets: return elif self.widget_type == 'scrubber': key = cross_index([v for v in self._values.values()], widgets[0].value) else: key = tuple(w.value for w in widgets) if plot.dynamic: widget_dims = [w.name for w in widgets] key = [key[widget_dims.index(kdim)] if kdim in widget_dims else None for kdim in plot.dimensions] key = wrap_tuple_streams(tuple(key), plot.dimensions, plot.streams) if plot.backend == 'bokeh': if plot.comm or state._unblocked(plot.document): with unlocked(): plot.update(key) if plot.comm and 'embedded' not in plot.root.tags: plot.push() else: if plot.document.session_context: plot.document.add_next_tick_callback(partial(plot.update, key)) else: plot.update(key) else: plot.update(key) if hasattr(plot.renderer, 'get_plot_state'): pane.object = plot.renderer.get_plot_state(plot) else: # Compatibility with holoviews<1.13.0 pane.object = plot.state def _widget_callback(self, event): for _, (plot, pane) in self._plots.items(): self._update_plot(plot, pane) def _track_overrides(self, *events): if self._syncing_props: return overrides = list(self._overrides) for e in events: if e.name in overrides and self.param[e.name].default == e.new: overrides.remove(e.name) else: overrides.append(e.name) self._overrides = overrides def _sync_sizing_mode(self, plot): state = plot.state backend = plot.renderer.backend if backend == 'bokeh': params = { 'sizing_mode': state.sizing_mode, 'width': state.width, 'height': state.height } elif backend == 'matplotlib': params = { 'sizing_mode': None, 'width': None, 'height': None } elif backend == 'plotly': if state.get('config', {}).get('responsive'): sizing_mode = 'stretch_both' else: sizing_mode = None params = { 'sizing_mode': sizing_mode, 'width': None, 'height': None } self._syncing_props = True try: self.param.update({k: v for k, v in params.items() if k not in self._overrides}) if backend != 'bokeh': return plot_props = plot.state.properties() props = { o: getattr(self, o) for o in self._overrides if o in plot_props } if props: plot.state.update(**props) finally: self._syncing_props = False #---------------------------------------------------------------- # Model API #---------------------------------------------------------------- def _get_model( self, doc: Document, root: Optional[Model] = None, parent: Optional[Model] = None, comm: Optional[Comm] = None ) -> Model: from holoviews.plotting.plot import Plot if root is None: return self.get_root(doc, comm) ref = root.ref['id'] if self.object is None: model = _BkSpacer() self._models[ref] = (model, parent) return model if self._restore_plot is not None: plot = self._restore_plot self._restore_plot = None elif isinstance(self.object, Plot): plot = self.object else: plot = self._render(doc, comm, root) plot.pane = self backend = plot.renderer.backend state = plot.renderer.get_plot_state(plot) # Ensure rerender if content is responsive but layout is centered # or update layout if plot is height responsive but layout wrapper # is not self._sync_sizing_mode(plot) responsive = self.sizing_mode not in ('fixed', None) and not self.width force_width = (self.center and responsive and not self._width_responsive) if force_width: self._update_responsive() self._width_responsive = True self._update_layout() self._restore_plot = plot raise RerenderError(layout=self.layout) elif self._height_responsive is None: self._update_responsive() loc = self.widget_location center = self.center and not self._width_responsive layout, _, _ = self._alignments[loc] smode = self._layout_sizing_mode layout_smode = 'stretch_width' if not smode and center else smode self.layout.sizing_mode = layout_smode if len(self.widget_box): if not center: if self.default_layout is not layout: self.layout[0].sizing_mode = smode elif layout is Column and len(self.layout) == 3: self.layout[1].sizing_mode = smode kwargs = {p: v for p, v in self.param.values().items() if p in Layoutable.param and p != 'name'} if self.sizing_mode and (self.sizing_mode.endswith('width') or self.sizing_mode.endswith('both')): del kwargs['width'] if self.sizing_mode and (self.sizing_mode.endswith('height') or self.sizing_mode.endswith('both')): del kwargs['height'] child_pane = self._get_pane(backend, state, **kwargs) self._update_plot(plot, child_pane) model = child_pane._get_model(doc, root, parent, comm) if ref in self._plots: old_plot, old_pane = self._plots[ref] old_plot.comm = None # Ensures comm does not get cleaned up old_plot.cleanup() self._plots[ref] = (plot, child_pane) self._models[ref] = (model, parent) return model def _get_pane(self, backend, state, **kwargs): pane_type = self._panes.get(backend, panel) if isinstance(pane_type, type): if issubclass(pane_type, Matplotlib): kwargs['tight'] = True if issubclass(pane_type, Bokeh): kwargs['autodispatch'] = False return pane_type(state, **kwargs) def _render(self, doc, comm, root): import holoviews as hv from holoviews import Store, renderer as load_renderer if self.renderer: renderer = self.renderer backend = renderer.backend else: if not Store.renderers: loaded_backend = (self.backend or 'bokeh') load_renderer(loaded_backend) Store.current_backend = loaded_backend backend = self.backend or Store.current_backend renderer = Store.renderers[backend] mode = 'server' if comm is None else 'default' if backend == 'bokeh': params = {} if self.theme is not None: params['theme'] = self.theme elif doc.theme and getattr(doc.theme, '_json') != {'attrs': {}}: params['theme'] = doc.theme elif self._design.theme.bokeh_theme: params['theme'] = self._design.theme.bokeh_theme if mode != renderer.mode: params['mode'] = mode if params: renderer = renderer.instance(**params) kwargs = {'margin': self.margin} if backend == 'bokeh' or Version(str(hv.__version__)) >= Version('1.13.0'): kwargs['doc'] = doc kwargs['root'] = root if comm: kwargs['comm'] = comm return renderer.get_plot(self.object, **kwargs) def _cleanup(self, root: Model | None = None) -> None: """ Traverses HoloViews object to find and clean up any streams connected to existing plots. """ if root: old_plot, old_pane = self._plots.pop(root.ref['id'], (None, None)) if old_plot: old_plot.cleanup() if old_pane: old_pane._cleanup(root) super()._cleanup(root) #---------------------------------------------------------------- # Public API #----------------------------------------------------------------
[docs] @classmethod def applies(cls, obj: Any) -> float | bool | None: if 'holoviews' not in sys.modules: return False from holoviews.core.dimension import Dimensioned from holoviews.plotting.plot import Plot return isinstance(obj, (Dimensioned, Plot))
jslink.__doc__ = PaneBase.jslink.__doc__ @classmethod def widgets_from_dimensions(cls, object, widget_types=None, widgets_type='individual'): from holoviews.core import Dimension, DynamicMap from holoviews.core.options import SkipRendering from holoviews.core.traversal import unique_dimkeys from holoviews.core.util import ( datetime_types, isnumeric, unique_iterator, ) from holoviews.plotting.plot import GenericCompositePlot, Plot from holoviews.plotting.util import get_dynamic_mode from ..widgets import ( DatetimeInput, DiscreteSlider, FloatSlider, IntSlider, Select, Widget, ) if widget_types is None: widget_types = {} if isinstance(object, GenericCompositePlot): object = object.layout elif isinstance(object, Plot): object = object.hmap if isinstance(object, DynamicMap) and object.unbounded: dims = ', '.join('%r' % dim for dim in object.unbounded) msg = ('DynamicMap cannot be displayed without explicit indexing ' 'as {dims} dimension(s) are unbounded. ' '\nSet dimensions bounds with the DynamicMap redim.range ' 'or redim.values methods.') raise SkipRendering(msg.format(dims=dims)) dynamic, bounded = get_dynamic_mode(object) dims, keys = unique_dimkeys(object) if ((dims == [Dimension('Frame')] and keys == [(0,)]) or (not dynamic and len(keys) == 1)): return [], {} nframes = 1 values = {} if dynamic else dict(zip(dims, zip(*keys))) dim_values = OrderedDict() widgets = [] dims = [d for d in dims if values.get(d) is not None or d.values or d.range != (None, None)] for i, dim in enumerate(dims): widget_type, widget, widget_kwargs = None, None, {} if widgets_type == 'individual': if i == 0 and i == (len(dims)-1): margin = (20, 20, 20, 20) elif i == 0: margin = (20, 20, 5, 20) elif i == (len(dims)-1): margin = (5, 20, 20, 20) else: margin = (0, 20, 5, 20) kwargs = {'margin': margin, 'width': 250} else: kwargs = {} vals = dim.values or values.get(dim, None) if vals is not None: vals = list(unique_iterator(vals)) dim_values[dim.name] = vals if widgets_type == 'scrubber': if not vals: raise ValueError('Scrubber widget may only be used if all dimensions define values.') nframes *= len(vals) elif dim.name in widget_types: widget = widget_types[dim.name] if isinstance(widget, Widget): widget.param.update(**kwargs) if not widget.name: widget.name = dim.label widgets.append(widget) continue elif isinstance(widget, dict): widget_type = widget.get('type', widget_type) widget_kwargs = dict(widget) elif isinstance(widget, type) and issubclass(widget, Widget): widget_type = widget else: raise ValueError('Explicit widget definitions expected ' 'to be a widget instance or type, %s ' 'dimension widget declared as %s.' % (dim, widget)) widget_kwargs.update(kwargs) if vals: if all(isnumeric(v) or isinstance(v, datetime_types) for v in vals) and len(vals) > 1: vals = sorted(vals) labels = [str(dim.pprint_value(v)) for v in vals] options = OrderedDict(zip(labels, vals)) widget_type = widget_type or DiscreteSlider else: options = list(vals) widget_type = widget_type or Select default = vals[0] if dim.default is None else dim.default widget_kwargs = dict(dict(name=dim.label, options=options, value=default), **widget_kwargs) widget = widget_type(**widget_kwargs) elif dim.range != (None, None): start, end = dim.range if start == end: continue default = start if dim.default is None else dim.default if widget_type is not None: pass elif all(isinstance(v, int) for v in (start, end, default)): widget_type = IntSlider step = 1 if dim.step is None else dim.step elif isinstance(default, datetime_types): widget_type = DatetimeInput else: widget_type = FloatSlider step = 0.1 if dim.step is None else dim.step widget_kwargs = dict(dict(step=step, name=dim.label, start=dim.range[0], end=dim.range[1], value=default), **widget_kwargs) widget = widget_type(**widget_kwargs) if widget is not None: widgets.append(widget) if widgets_type == 'scrubber': widgets = [Player(length=nframes, width=550)] return widgets, dim_values
[docs]class Interactive(PaneBase): priority: ClassVar[float | bool | None] = None _ignored_refs: ClassVar[Tuple[str]] = ['object'] def __init__(self, object=None, **params): super().__init__(object, **params) self._update_layout() self.param.watch(self._update_layout_properties, list(Layoutable.param))
[docs] @classmethod def applies(cls, object: Any) -> float | bool | None: if 'hvplot.interactive' not in sys.modules: return False from hvplot.interactive import Interactive return 0.8 if isinstance(object, Interactive) else False
@param.depends('object') def _update_layout(self): if self.object is None: self._layout_panel = None else: self._layout_panel = self.object.layout() self._layout_panel.param.update(**{ p: getattr(self, p) for p in Layoutable.param if p != 'name' }) def _update_layout_properties(self, *events): if self._layout_panel is None: return self._layout_panel.param.update(**{e.name: e.new for e in events}) def _get_model( self, doc: Document, root: Optional[Model] = None, parent: Optional[Model] = None, comm: Optional[Comm] = None ) -> Model: if root is None: return self.get_root(doc, comm) if self._layout_panel is None: model = _BkSpacer(**{ p: getattr(self, p) for p in Layoutable.param if p != 'name' }) else: model = self._layout_panel._get_model(doc, root, parent, comm) self._models[root.ref['id']] = (model, parent) return model def _cleanup(self, root: Model | None = None) -> None: if self._layout_panel is not None: self._layout_panel._cleanup(root) super()._cleanup(root)
[docs]def is_bokeh_element_plot(plot): """ Checks whether plotting instance is a HoloViews ElementPlot rendered with the bokeh backend. """ from holoviews.plotting.plot import ( GenericElementPlot, GenericOverlayPlot, Plot, ) if not isinstance(plot, Plot): return False return (plot.renderer.backend == 'bokeh' and isinstance(plot, GenericElementPlot) and not isinstance(plot, GenericOverlayPlot))
[docs]def generate_panel_bokeh_map(root_model, panel_views): """ mapping panel elements to its bokeh models """ map_hve_bk = defaultdict(list) ref = root_model.ref['id'] for pane in panel_views: if root_model.ref['id'] in pane._models: plot, subpane = pane._plots.get(ref, (None, None)) if plot is None: continue bk_plots = plot.traverse(lambda x: x, [is_bokeh_element_plot]) for plot in bk_plots: for hv_elem in plot.link_sources: map_hve_bk[hv_elem].append(plot) return map_hve_bk
Viewable._preprocessing_hooks.append(link_axes) Viewable._preprocessing_hooks.append(find_links)