Source code for holoviews.plotting.plotly.element

import re
import uuid

import numpy as np
import param

from ... import Tiles
from ...core import util
from ...core.element import Element
from ...core.spaces import DynamicMap
from ...streams import Stream
from ...util.transform import dim
from ..plot import GenericElementPlot, GenericOverlayPlot
from ..util import dim_range_key
from .plot import PlotlyPlot
from .util import (
    STYLE_ALIASES,
    get_colorscale,
    legend_trace_types,
    merge_figure,
    merge_layout,
)


[docs]class ElementPlot(PlotlyPlot, GenericElementPlot): aspect = param.Parameter(default='cube', doc=""" The aspect ratio mode of the plot. By default, a plot may select its own appropriate aspect ratio but sometimes it may be necessary to force a square aspect ratio (e.g. to display the plot as an element of a grid). The modes 'auto' and 'equal' correspond to the axis modes of the same name in matplotlib, a numeric value may also be passed.""") bgcolor = param.ClassSelector(class_=(str, tuple), default=None, doc=""" If set bgcolor overrides the background color of the axis.""") invert_axes = param.ObjectSelector(default=False, doc=""" Inverts the axes of the plot. Note that this parameter may not always be respected by all plots but should be respected by adjoined plots when appropriate.""") invert_xaxis = param.Boolean(default=False, doc=""" Whether to invert the plot x-axis.""") invert_yaxis = param.Boolean(default=False, doc=""" Whether to invert the plot y-axis.""") invert_zaxis = param.Boolean(default=False, doc=""" Whether to invert the plot z-axis.""") labelled = param.List(default=['x', 'y', 'z'], doc=""" Whether to label the 'x' and 'y' axes.""") logx = param.Boolean(default=False, doc=""" Whether to apply log scaling to the x-axis of the Chart.""") logy = param.Boolean(default=False, doc=""" Whether to apply log scaling to the y-axis of the Chart.""") logz = param.Boolean(default=False, doc=""" Whether to apply log scaling to the y-axis of the Chart.""") margins = param.NumericTuple(default=(50, 50, 50, 50), doc=""" Margins in pixel values specified as a tuple of the form (left, bottom, right, top).""") responsive = param.Boolean(default=False, doc=""" Whether the plot should stretch to fill the available space.""") show_legend = param.Boolean(default=False, doc=""" Whether to show legend for the plot.""") xaxis = param.ObjectSelector(default='bottom', objects=['top', 'bottom', 'bare', 'top-bare', 'bottom-bare', None], doc=""" Whether and where to display the xaxis, bare options allow suppressing all axis labels including ticks and xlabel. Valid options are 'top', 'bottom', 'bare', 'top-bare' and 'bottom-bare'.""") xticks = param.Parameter(default=None, doc=""" Ticks along x-axis specified as an integer, explicit list of tick locations, list of tuples containing the locations.""") yaxis = param.ObjectSelector(default='left', objects=['left', 'right', 'bare', 'left-bare', 'right-bare', None], doc=""" Whether and where to display the yaxis, bare options allow suppressing all axis labels including ticks and ylabel. Valid options are 'left', 'right', 'bare' 'left-bare' and 'right-bare'.""") yticks = param.Parameter(default=None, doc=""" Ticks along y-axis specified as an integer, explicit list of tick locations, list of tuples containing the locations.""") zlabel = param.String(default=None, doc=""" An explicit override of the z-axis label, if set takes precedence over the dimension label.""") zticks = param.Parameter(default=None, doc=""" Ticks along z-axis specified as an integer, explicit list of tick locations, list of tuples containing the locations.""") _style_key = None # Whether vectorized styles are applied per trace _per_trace = False # Whether plot type can be displayed on mapbox plot _supports_geo = False # Declare which styles cannot be mapped to a non-scalar dimension _nonvectorized_styles = [] def __init__(self, element, plot=None, **params): super().__init__(element, **params) self.trace_uid = str(uuid.uuid4()) self.static = len(self.hmap) == 1 and len(self.keys) == len(self.hmap) self.callbacks, self.source_streams = self._construct_callbacks() @classmethod def trace_kwargs(cls, **kwargs): return {}
[docs] def initialize_plot(self, ranges=None, is_geo=False): """ Initializes a new plot object with the last available frame. """ # Get element key and ranges for frame fig = self.generate_plot(self.keys[-1], ranges, is_geo=is_geo) self.drawn = True trigger = self._trigger self._trigger = [] Stream.trigger(trigger) return fig
def generate_plot(self, key, ranges, element=None, is_geo=False): self.prev_frame = self.current_frame if element is None: element = self._get_frame(key) else: self.current_frame = element if is_geo and not self._supports_geo: raise ValueError( f"Elements of type {type(element)} cannot be overlaid " "with Tiles elements using the plotly backend" ) if element is None: return self.handles['fig'] # Set plot options plot_opts = self.lookup_options(element, 'plot').options self.param.update(**{k: v for k, v in plot_opts.items() if k in self.param}) # Get ranges ranges = self.compute_ranges(self.hmap, key, ranges) ranges = util.match_spec(element, ranges) # Get style self.style = self.lookup_options(element, 'style') style = self.style[self.cyclic_index] # Validate style properties are supported in geo mode if is_geo: unsupported_opts = [ style_opt for style_opt in style if style_opt in self.unsupported_geo_style_opts ] if unsupported_opts: raise ValueError( "The following {typ} style options are not supported by the Plotly " "backend when overlaid on Tiles:\n" " {unsupported_opts}".format( typ=type(element).__name__, unsupported_opts=unsupported_opts ) ) # Get data and options and merge them data = self.get_data(element, ranges, style, is_geo=is_geo) opts = self.graph_options(element, ranges, style, is_geo=is_geo) components = { 'traces': [], 'images': [], 'annotations': [], 'shapes': [], } for i, d in enumerate(data): # Initialize traces datum_components = self.init_graph(d, opts, index=i, is_geo=is_geo) # Handle traces traces = datum_components.get('traces', []) components['traces'].extend(traces) if i == 0 and traces: # Associate element with trace.uid property of the first # plotly trace that is used to render the element. This is # used to associate the element with the trace during callbacks traces[0]['uid'] = self.trace_uid # Handle images, shapes, annotations for k in ['images', 'shapes', 'annotations']: components[k].extend(datum_components.get(k, [])) # Handle mapbox if "mapbox" in datum_components: components["mapbox"] = datum_components["mapbox"] self.handles['components'] = components # Initialize layout layout = self.init_layout(key, element, ranges, is_geo=is_geo) for k in ['images', 'shapes', 'annotations']: layout.setdefault(k, []) layout[k].extend(components.get(k, [])) if "mapbox" in components: merge_layout(layout.setdefault("mapbox", {}), components["mapbox"]) self.handles['layout'] = layout # Create figure and return it layout['autosize'] = self.responsive fig = dict(data=components['traces'], layout=layout, config=dict(responsive=self.responsive)) self.handles['fig'] = fig self._execute_hooks(element) self.drawn = True return fig def graph_options(self, element, ranges, style, is_geo=False, **kwargs): if self.overlay_dims: legend = ', '.join([d.pprint_value_string(v) for d, v in self.overlay_dims.items()]) else: legend = element.label opts = dict( name=legend, **self.trace_kwargs(is_geo=is_geo)) if self.trace_kwargs(is_geo=is_geo).get('type', None) in legend_trace_types: opts.update( showlegend=self.show_legend, legendgroup=element.group+'_'+legend) # make legendgroup unique for single trace enable/disable if self._style_key is not None: styles = self._apply_transforms(element, ranges, style) # If style starts with '{_style_key}_', remove the prefix. This way # a line_color property with self._style_key of 'line' doesn't end up # as `line_line_color` key_prefix_re = re.compile('^' + self._style_key + '_') styles = {key_prefix_re.sub('', k): v for k, v in styles.items()} opts[self._style_key] = {STYLE_ALIASES.get(k, k): v for k, v in styles.items()} # Move certain options from the style key back to root for k in ['selectedpoints', 'visible']: if k in opts.get(self._style_key, {}): opts[k] = opts[self._style_key].pop(k) else: opts.update({STYLE_ALIASES.get(k, k): v for k, v in style.items() if k != 'cmap'}) return opts
[docs] def init_graph(self, datum, options, index=0, **kwargs): """ Initialize the plotly components that will represent the element Parameters ---------- datum: dict An element of the data list returned by the get_data method options: dict Graph options that were returned by the graph_options method index: int Index of datum in the original list returned by the get_data method Returns ------- dict Dictionary of the plotly components that represent the element. Keys may include: - 'traces': List of trace dicts - 'annotations': List of annotations dicts - 'images': List of image dicts - 'shapes': List of shape dicts """ trace = dict(options) for k, v in datum.items(): if k in trace and isinstance(trace[k], dict): trace[k].update(v) else: trace[k] = v if self._style_key and self._per_trace: vectorized = {k: v for k, v in options[self._style_key].items() if isinstance(v, np.ndarray)} trace[self._style_key] = dict(trace[self._style_key]) for s, val in vectorized.items(): trace[self._style_key][s] = val[index] return {'traces': [trace]}
def get_data(self, element, ranges, style, is_geo=False): return []
[docs] def get_aspect(self, xspan, yspan): """ Computes the aspect ratio of the plot """ return self.width/self.height
def _get_axis_dims(self, element): """Returns the dimensions corresponding to each axis. Should return a list of dimensions or list of lists of dimensions, which will be formatted to label the axis and to link axes. """ dims = element.dimensions()[:3] pad = [None]*max(3-len(dims), 0) return dims + pad def _apply_transforms(self, element, ranges, style): new_style = dict(style) for k, v in dict(style).items(): if isinstance(v, str): if k == 'marker' and v in 'xsdo': continue elif v in element: v = dim(v) elif any(d==v for d in self.overlay_dims): v = dim(next(d for d in self.overlay_dims if d==v)) if not isinstance(v, dim): continue elif (not v.applies(element) and v.dimension not in self.overlay_dims): new_style.pop(k) self.param.warning('Specified {} dim transform {!r} could not be applied, as not all ' 'dimensions could be resolved.'.format(k, v)) continue if len(v.ops) == 0 and v.dimension in self.overlay_dims: val = self.overlay_dims[v.dimension] else: val = v.apply(element, ranges=ranges, flat=True) if (not util.isscalar(val) and len(util.unique_array(val)) == 1 and 'color' not in k): val = val[0] if not util.isscalar(val): if k in self._nonvectorized_styles: element = type(element).__name__ raise ValueError('Mapping a dimension to the "{style}" ' 'style option is not supported by the ' '{element} element using the {backend} ' 'backend. To map the "{dim}" dimension ' 'to the {style} use a groupby operation ' 'to overlay your data along the dimension.'.format( style=k, dim=v.dimension, element=element, backend=self.renderer.backend)) # If color is not valid colorspec add colormapper numeric = isinstance(val, np.ndarray) and val.dtype.kind in 'uifMm' if ('color' in k and isinstance(val, np.ndarray) and numeric): copts = self.get_color_opts(v, element, ranges, style) new_style.pop('cmap', None) new_style.update(copts) new_style[k] = val return new_style def init_layout(self, key, element, ranges, is_geo=False): el = element.traverse(lambda x: x, [Element]) el = el[0] if el else element layout = dict( title=self._format_title(key, separator=' '), plot_bgcolor=self.bgcolor, uirevision=True ) if not self.responsive: layout['width'] = self.width layout['height'] = self.height extent = self.get_extents(element, ranges) if len(extent) == 4: l, b, r, t = extent else: l, b, z0, r, t, z1 = extent dims = self._get_axis_dims(el) if len(dims) > 2: xdim, ydim, zdim = dims else: xdim, ydim = dims zdim = None xlabel, ylabel, zlabel = self._get_axis_labels(dims) if self.invert_axes: if is_geo: raise ValueError( "The invert_axes parameter is not supported on Tiles elements " "with the plotly backend" ) xlabel, ylabel = ylabel, xlabel ydim, xdim = xdim, ydim l, b, r, t = b, l, t, r if 'x' not in self.labelled: xlabel = '' if 'y' not in self.labelled: ylabel = '' if 'z' not in self.labelled: zlabel = '' xaxis = {} if xdim and not is_geo: try: if any(np.isnan([r, l])): r, l = 0, 1 except TypeError: # r and l not numeric, don't change anything pass xrange = [r, l] if self.invert_xaxis else [l, r] xaxis = dict(range=xrange, title=xlabel) if self.logx: xaxis['type'] = 'log' xaxis['range'] = np.log10(xaxis['range']) self._get_ticks(xaxis, self.xticks) if self.projection != '3d' and self.xaxis: xaxis['automargin'] = False # Create dimension string used to compute matching axes if isinstance(xdim, (list, tuple)): dim_str = "-".join([f"{d.name}^{d.label}^{d.unit}" for d in xdim]) else: dim_str = f"{xdim.name}^{xdim.label}^{xdim.unit}" xaxis['_dim'] = dim_str if 'bare' in self.xaxis: xaxis['ticks'] = '' xaxis['showticklabels'] = False xaxis['title'] = '' if 'top' in self.xaxis: xaxis['side'] = 'top' else: xaxis['side'] = 'bottom' yaxis = {} if ydim and not is_geo: try: if any(np.isnan([b, t])): b, t = 0, 1 except TypeError: # b and t not numeric, don't change anything pass yrange = [t, b] if self.invert_yaxis else [b, t] yaxis = dict(range=yrange, title=ylabel) if self.logy: yaxis['type'] = 'log' yaxis['range'] = np.log10(yaxis['range']) self._get_ticks(yaxis, self.yticks) if self.projection != '3d' and self.yaxis: yaxis['automargin'] = False # Create dimension string used to compute matching axes if isinstance(ydim, (list, tuple)): dim_str = "-".join([f"{d.name}^{d.label}^{d.unit}" for d in ydim]) else: dim_str = f"{ydim.name}^{ydim.label}^{ydim.unit}" yaxis['_dim'] = dim_str, if 'bare' in self.yaxis: yaxis['ticks'] = '' yaxis['showticklabels'] = False yaxis['title'] = '' if 'right' in self.yaxis: yaxis['side'] = 'right' else: yaxis['side'] = 'left' if is_geo: mapbox = {} if all(np.isfinite(v) for v in (l, b, r, t)): x_center = (l + r) / 2.0 y_center = (b + t) / 2.0 lons, lats = Tiles.easting_northing_to_lon_lat([x_center], [y_center]) mapbox["center"] = dict(lat=lats[0], lon=lons[0]) # Compute zoom level margin_left, margin_bottom, margin_right, margin_top = self.margins viewport_width = self.width - margin_left - margin_right viewport_height = self.height - margin_top - margin_bottom mapbox_tile_size = 512 max_delta = 2 * np.pi * 6378137 x_delta = r - l y_delta = t - b with np.errstate(divide="ignore"): max_x_zoom = (np.log2(max_delta / x_delta) - np.log2(mapbox_tile_size / viewport_width)) max_y_zoom = (np.log2(max_delta / y_delta) - np.log2(mapbox_tile_size / viewport_height)) mapbox["zoom"] = min(max_x_zoom, max_y_zoom) layout["mapbox"] = mapbox if isinstance(self.projection, str) and self.projection == '3d': scene = dict(xaxis=xaxis, yaxis=yaxis) if zdim: zrange = [z1, z0] if self.invert_zaxis else [z0, z1] zaxis = dict(range=zrange, title=zlabel) if self.logz: zaxis['type'] = 'log' self._get_ticks(zaxis, self.zticks) scene['zaxis'] = zaxis if self.aspect == 'cube': scene['aspectmode'] = 'cube' else: scene['aspectmode'] = 'manual' scene['aspectratio'] = self.aspect layout['scene'] = scene else: l, b, r, t = self.margins layout['margin'] = dict(l=l, r=r, b=b, t=t, pad=4) if not is_geo: layout['xaxis'] = xaxis layout['yaxis'] = yaxis return layout def _get_ticks(self, axis, ticker): axis_props = {} if isinstance(ticker, (tuple, list)): if all(isinstance(t, tuple) for t in ticker): ticks, labels = zip(*ticker) labels = [l if isinstance(l, str) else str(l) for l in labels] axis_props['tickvals'] = ticks axis_props['ticktext'] = labels else: axis_props['tickvals'] = ticker axis.update(axis_props)
[docs] def update_frame(self, key, ranges=None, element=None, is_geo=False): """ Updates an existing plot with data corresponding to the key. """ self.generate_plot(key, ranges, element, is_geo=is_geo)
[docs]class ColorbarPlot(ElementPlot): clim = param.NumericTuple(default=(np.nan, np.nan), length=2, doc=""" User-specified colorbar axis range limits for the plot, as a tuple (low,high). If specified, takes precedence over data and dimension ranges.""") clim_percentile = param.ClassSelector(default=False, class_=(int, float, bool), doc=""" Percentile value to compute colorscale robust to outliers. If True, uses 2nd and 98th percentile; otherwise uses the specified numerical percentile value.""") colorbar = param.Boolean(default=False, doc=""" Whether to display a colorbar.""") color_levels = param.ClassSelector(default=None, class_=(int, list), doc=""" Number of discrete colors to use when colormapping or a set of color intervals defining the range of values to map each color to.""") colorbar_opts = param.Dict(default={}, doc=""" Allows setting including borderwidth, showexponent, nticks, outlinecolor, thickness, bgcolor, outlinewidth, bordercolor, ticklen, xpad, ypad, tickangle...""") symmetric = param.Boolean(default=False, doc=""" Whether to make the colormap symmetric around zero.""") def get_color_opts(self, eldim, element, ranges, style): opts = {} dim_name = dim_range_key(eldim) if self.colorbar: opts['colorbar'] = dict(**self.colorbar_opts) if 'title' not in opts['colorbar']: if isinstance(eldim, dim): title = str(eldim) if eldim.ops: pass elif title.startswith("dim('") and title.endswith("')"): title = title[5:-2] else: title = title[1:-1] else: title = eldim.pprint_label opts['colorbar']['title']=title opts['showscale'] = True else: opts['showscale'] = False if eldim: auto = False if util.isfinite(self.clim).all(): cmin, cmax = self.clim elif dim_name in ranges: if self.clim_percentile and 'robust' in ranges[dim_name]: low, high = ranges[dim_name]['robust'] else: cmin, cmax = ranges[dim_name]['combined'] elif isinstance(eldim, dim): cmin, cmax = np.nan, np.nan auto = True else: cmin, cmax = element.range(dim_name) if self.symmetric: cabs = np.abs([cmin, cmax]) cmin, cmax = -cabs.max(), cabs.max() else: auto = True cmin, cmax = None, None cmap = style.pop('cmap', 'viridis') colorscale = get_colorscale(cmap, self.color_levels, cmin, cmax) # Reduce colorscale length to <= 255 to work around # https://github.com/plotly/plotly.js/issues/3699. Plotly.js performs # colorscale interpolation internally so reducing the number of colors # here makes very little difference to the displayed colorscale. # # Note that we need to be careful to make sure the first and last # colorscale pairs, colorscale[0] and colorscale[-1], are preserved # as the first and last in the subsampled colorscale if isinstance(colorscale, list) and len(colorscale) > 255: last_clr_pair = colorscale[-1] step = int(np.ceil(len(colorscale) / 255)) colorscale = colorscale[0::step] colorscale[-1] = last_clr_pair if cmin is not None: opts['cmin'] = cmin if cmax is not None: opts['cmax'] = cmax opts['cauto'] = auto opts['colorscale'] = colorscale return opts
[docs]class OverlayPlot(GenericOverlayPlot, ElementPlot): _propagate_options = [ 'width', 'height', 'xaxis', 'yaxis', 'labelled', 'bgcolor', 'invert_axes', 'show_frame', 'show_grid', 'logx', 'logy', 'xticks', 'toolbar', 'yticks', 'xrotation', 'yrotation', 'responsive', 'invert_xaxis', 'invert_yaxis', 'sizing_mode', 'title', 'title_format', 'padding', 'xlabel', 'ylabel', 'zlabel', 'xlim', 'ylim', 'zlim']
[docs] def initialize_plot(self, ranges=None, is_geo=False): """ Initializes a new plot object with the last available frame. """ # Get element key and ranges for frame return self.generate_plot(next(iter(self.hmap.data.keys())), ranges, is_geo=is_geo)
def generate_plot(self, key, ranges, element=None, is_geo=False): if element is None: element = self._get_frame(key) items = [] if element is None else list(element.data.items()) # Update plot options plot_opts = self.lookup_options(element, 'plot').options inherited = self._traverse_options(element, 'plot', self._propagate_options, defaults=False) plot_opts.update(**{k: v[0] for k, v in inherited.items() if k not in plot_opts}) self.param.update(**plot_opts) ranges = self.compute_ranges(self.hmap, key, ranges) figure = None # Check if elements should be overlaid in geographic coordinates using mapbox # # Pass this through to generate_plot to build geo version of plot for _, el in items: if isinstance(el, Tiles): is_geo = True break for okey, subplot in self.subplots.items(): if element is not None and subplot.drawn: idx, spec, exact = self._match_subplot(okey, subplot, items, element) if idx is not None: _, el = items.pop(idx) else: el = None else: el = None # propagate plot options to subplots subplot.param.update(**plot_opts) fig = subplot.generate_plot(key, ranges, el, is_geo=is_geo) if figure is None: figure = fig else: merge_figure(figure, fig) layout = self.init_layout(key, element, ranges, is_geo=is_geo) merge_layout(figure['layout'], layout) self.drawn = True self.handles['fig'] = figure return figure
[docs] def update_frame(self, key, ranges=None, element=None, is_geo=False): reused = isinstance(self.hmap, DynamicMap) and self.overlaid self.prev_frame = self.current_frame if not reused and element is None: element = self._get_frame(key) elif element is not None: self.current_frame = element self.current_key = key items = [] if element is None else list(element.data.items()) for _, el in items: if isinstance(el, Tiles): is_geo = True # Instantiate dynamically added subplots for k, subplot in self.subplots.items(): # If in Dynamic mode propagate elements to subplots if not (isinstance(self.hmap, DynamicMap) and element is not None): continue idx, _, _ = self._match_subplot(k, subplot, items, element) if idx is not None: items.pop(idx) if isinstance(self.hmap, DynamicMap) and items: self._create_dynamic_subplots(key, items, ranges) self.generate_plot(key, ranges, element, is_geo=is_geo)