import copy
import math
import warnings
from types import FunctionType
import matplotlib.colors as mpl_colors
import numpy as np
import param
from matplotlib import ticker
from matplotlib.dates import date2num
from matplotlib.image import AxesImage
from packaging.version import Version
from ...core import (
CompositeOverlay,
Dataset,
DynamicMap,
Element,
Element3D,
NdOverlay,
util,
)
from ...core.options import Keywords, abbreviated_exception
from ...element import Graph, Path
from ...streams import Stream
from ...util.transform import dim
from ..plot import GenericElementPlot, GenericOverlayPlot
from ..util import color_intervals, dim_range_key, process_cmap
from .plot import MPLPlot, mpl_rc_context
from .util import EqHistNormalize, mpl_version, validate, wrap_formatter
[docs]class ElementPlot(GenericElementPlot, MPLPlot):
apply_ticks = param.Boolean(default=True, doc="""
Whether to apply custom ticks.""")
aspect = param.Parameter(default='square', 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 specifying the ratio between plot
width and height may also be passed. To control the aspect
ratio between the axis scales use the data_aspect option
instead.""")
data_aspect = param.Number(default=None, doc="""
Defines the aspect of the axis scaling, i.e. the ratio of
y-unit to x-unit.""")
invert_zaxis = param.Boolean(default=False, doc="""
Whether to invert the plot z-axis.""")
labelled = param.List(default=['x', 'y'], doc="""
Whether to plot the 'x' and 'y' labels.""")
logz = param.Boolean(default=False, doc="""
Whether to apply log scaling to the y-axis of the Chart.""")
xformatter = param.ClassSelector(
default=None, class_=(str, ticker.Formatter, FunctionType), doc="""
Formatter for ticks along the x-axis.""")
yformatter = param.ClassSelector(
default=None, class_=(str, ticker.Formatter, FunctionType), doc="""
Formatter for ticks along the y-axis.""")
zformatter = param.ClassSelector(
default=None, class_=(str, ticker.Formatter, FunctionType), doc="""
Formatter for ticks along the z-axis.""")
zaxis = param.Boolean(default=True, doc="""
Whether to display the z-axis.""")
zlabel = param.String(default=None, doc="""
An explicit override of the z-axis label, if set takes precedence
over the dimension label.""")
zrotation = param.Integer(default=0, bounds=(0, 360), doc="""
Rotation angle of the zticks.""")
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 and
labels or a matplotlib tick locator object. If set to None
default matplotlib ticking behavior is applied.""")
# Element Plots should declare the valid style options for matplotlib call
style_opts = []
# No custom legend options
_legend_opts = {}
# Declare which styles cannot be mapped to a non-scalar dimension
_nonvectorized_styles = ['marker', 'alpha', 'cmap', 'angle', 'visible']
# Whether plot has axes, disables setting axis limits, labels and ticks
_has_axes = True
def __init__(self, element, **params):
super().__init__(element, **params)
check = self.hmap.last
if isinstance(check, CompositeOverlay):
check = check.values()[0] # Should check if any are 3D plots
if isinstance(check, Element3D):
self.projection = '3d'
for hook in self.initial_hooks:
try:
hook(self, element)
except Exception as e:
self.param.warning(f"Plotting hook {hook!r} could not be "
f"applied:\n\n {e}")
def _finalize_axis(self, key, element=None, title=None, dimensions=None, ranges=None, xticks=None,
yticks=None, zticks=None, xlabel=None, ylabel=None, zlabel=None):
"""
Applies all the axis settings before the axis or figure is returned.
Only plots with zorder 0 get to apply their settings.
When the number of the frame is supplied as n, this method looks
up and computes the appropriate title, axis labels and axis bounds.
"""
if element is None:
element = self._get_frame(key)
self.current_frame = element
if not dimensions and element and not self.subplots:
el = element.traverse(lambda x: x, [Element])
if el:
el = el[0]
dimensions = el.nodes.dimensions() if isinstance(el, Graph) else el.dimensions()
axis = self.handles['axis']
subplots = list(self.subplots.values()) if self.subplots else []
if self.zorder == 0 and key is not None:
if self.bgcolor:
if mpl_version <= Version('1.5.9'):
axis.set_axis_bgcolor(self.bgcolor)
else:
axis.set_facecolor(self.bgcolor)
# Apply title
title = self._format_title(key)
if self.show_title and title is not None:
fontsize = self._fontsize('title')
if 'title' in self.handles:
self.handles['title'].set_text(title)
else:
self.handles['title'] = axis.set_title(title, **fontsize)
# Apply subplot label
self._subplot_label(axis)
# Apply axis options if axes are enabled
if element is not None and not any(not sp._has_axes for sp in [self] + subplots):
# Set axis labels
if dimensions:
self._set_labels(axis, dimensions, xlabel, ylabel, zlabel)
else:
if self.xlabel is not None:
axis.set_xlabel(self.xlabel)
if self.ylabel is not None:
axis.set_ylabel(self.ylabel)
if self.zlabel is not None and hasattr(axis, 'set_zlabel'):
axis.set_zlabel(self.zlabel)
if not subplots:
legend = axis.get_legend()
if legend:
legend.set_visible(self.show_legend)
self.handles["bbox_extra_artists"] += [legend]
axis.xaxis.grid(self.show_grid)
axis.yaxis.grid(self.show_grid)
# Apply log axes
if self.logx:
axis.set_xscale('log')
if self.logy:
axis.set_yscale('log')
if not (isinstance(self.projection, str) and self.projection == '3d'):
self._set_axis_position(axis, 'x', self.xaxis)
self._set_axis_position(axis, 'y', self.yaxis)
# Apply ticks
if self.apply_ticks:
self._finalize_ticks(axis, dimensions, xticks, yticks, zticks)
# Set axes limits
self._set_axis_limits(axis, element, subplots, ranges)
# Apply aspects
if self.aspect is not None and self.projection != 'polar' and not self.adjoined:
self._set_aspect(axis, self.aspect)
if not subplots and not self.drawn:
self._finalize_artist(element)
self._execute_hooks(element)
return super()._finalize_axis(key)
def _execute_hooks(self, element):
super()._execute_hooks(element)
self._update_backend_opts()
def _finalize_ticks(self, axis, dimensions, xticks, yticks, zticks):
"""
Finalizes the ticks on the axes based on the supplied ticks
and Elements. Sets the axes position as well as tick positions,
labels and fontsize.
"""
ndims = len(dimensions) if dimensions else 0
xdim = dimensions[0] if ndims else None
ydim = dimensions[1] if ndims > 1 else None
# Tick formatting
if xdim:
self._set_axis_formatter(axis.xaxis, xdim, self.xformatter)
if ydim:
self._set_axis_formatter(axis.yaxis, ydim, self.yformatter)
if isinstance(self.projection, str) and self.projection == '3d':
zdim = dimensions[2] if ndims > 2 else None
if zdim or self.zformatter is not None:
self._set_axis_formatter(axis.zaxis, zdim, self.zformatter)
xticks = xticks if xticks else self.xticks
self._set_axis_ticks(axis.xaxis, xticks, log=self.logx,
rotation=self.xrotation)
yticks = yticks if yticks else self.yticks
self._set_axis_ticks(axis.yaxis, yticks, log=self.logy,
rotation=self.yrotation)
if isinstance(self.projection, str) and self.projection == '3d':
zticks = zticks if zticks else self.zticks
self._set_axis_ticks(axis.zaxis, zticks, log=self.logz,
rotation=self.zrotation)
axes_str = 'xy'
axes_list = [axis.xaxis, axis.yaxis]
if hasattr(axis, 'zaxis'):
axes_str += 'z'
axes_list.append(axis.zaxis)
for ax, ax_obj in zip(axes_str, axes_list):
tick_fontsize = self._fontsize(f'{ax}ticks','labelsize',common=False)
if tick_fontsize: ax_obj.set_tick_params(**tick_fontsize)
def _update_backend_opts(self):
plot = self.handles["fig"]
model_accessor_aliases = {
"figure": "fig",
"axes": "axis",
"ax": "axis",
"colorbar": "cbar",
}
for opt, val in self.backend_opts.items():
parsed_opt = self._parse_backend_opt(
opt, plot, model_accessor_aliases)
if parsed_opt is None:
continue
model, attr_accessor = parsed_opt
if not attr_accessor.startswith("set_"):
attr_accessor = f"set_{attr_accessor}"
if not isinstance(model, list):
# to reduce the need for many if/else; cast to list
# to do the same thing for both single and multiple models
models = [model]
else:
models = model
try:
for m in models:
getattr(m, attr_accessor)(val)
except AttributeError as exc:
valid_options = [attr for attr in dir(models[0]) if attr.startswith("set_")]
kws = Keywords(values=valid_options)
matches = sorted(kws.fuzzy_match(attr_accessor))
self.param.warning(
f"Encountered error: {exc}, or could not find "
f"{attr_accessor!r} method on {type(models[0]).__name__!r} "
f"model. Ensure the custom option spec {opt!r} you provided references a "
f"valid method on the specified model. Similar options include {matches!r}"
)
def _finalize_artist(self, element):
"""
Allows extending the _finalize_axis method with Element
specific options.
"""
def _set_labels(self, axes, dimensions, xlabel=None, ylabel=None, zlabel=None):
"""
Sets the labels of the axes using the supplied list of dimensions.
Optionally explicit labels may be supplied to override the dimension
label.
"""
xlabel, ylabel, zlabel = self._get_axis_labels(dimensions, xlabel, ylabel, zlabel)
if self.invert_axes:
xlabel, ylabel = ylabel, xlabel
if xlabel and self.xaxis and 'x' in self.labelled:
axes.set_xlabel(xlabel, **self._fontsize('xlabel'))
if ylabel and self.yaxis and 'y' in self.labelled:
axes.set_ylabel(ylabel, **self._fontsize('ylabel'))
if zlabel and self.zaxis and 'z' in self.labelled:
axes.set_zlabel(zlabel, **self._fontsize('zlabel'))
def _set_axis_formatter(self, axis, dim, formatter):
"""
Set axis formatter based on dimension formatter.
"""
if isinstance(dim, list): dim = dim[0]
if formatter is not None or dim is None:
pass
elif dim.value_format:
formatter = dim.value_format
elif dim.type in dim.type_formatters:
formatter = dim.type_formatters[dim.type]
if formatter:
axis.set_major_formatter(wrap_formatter(formatter))
[docs] def get_aspect(self, xspan, yspan):
"""
Computes the aspect ratio of the plot
"""
if isinstance(self.aspect, (int, float)):
return self.aspect
elif self.aspect == 'square':
return 1
elif self.aspect == 'equal':
return xspan/yspan
return 1
def _set_aspect(self, axes, aspect):
"""
Set the aspect on the axes based on the aspect setting.
"""
if isinstance(self.projection, str) and self.projection == '3d':
return
if ((isinstance(aspect, str) and aspect != 'square') or
self.data_aspect):
data_ratio = self.data_aspect or aspect
else:
(x0, x1), (y0, y1) = axes.get_xlim(), axes.get_ylim()
xsize = np.log(x1) - np.log(x0) if self.logx else x1-x0
ysize = np.log(y1) - np.log(y0) if self.logy else y1-y0
xsize = max(abs(xsize), 1e-30)
ysize = max(abs(ysize), 1e-30)
data_ratio = 1./(ysize/xsize)
if aspect != 'square':
data_ratio = data_ratio/aspect
axes.set_aspect(data_ratio)
def _set_axis_limits(self, axis, view, subplots, ranges):
"""
Compute extents for current view and apply as axis limits
"""
# Extents
extents = self.get_extents(view, ranges)
if not extents or self.overlaid:
axis.autoscale_view(scalex=True, scaley=True)
return
valid_lim = lambda c: util.isnumeric(c) and not np.isnan(c)
coords = [coord if isinstance(coord, np.datetime64) or np.isreal(coord) else np.nan for coord in extents]
coords = [date2num(util.dt64_to_dt(c)) if isinstance(c, np.datetime64) else c
for c in coords]
if (isinstance(self.projection, str) and self.projection == '3d') or len(extents) == 6:
l, b, zmin, r, t, zmax = coords
if self.invert_zaxis or any(p.invert_zaxis for p in subplots):
zmin, zmax = zmax, zmin
if zmin != zmax:
if valid_lim(zmin):
axis.set_zlim(bottom=zmin)
if valid_lim(zmax):
axis.set_zlim(top=zmax)
elif isinstance(self.projection, str) and self.projection == "polar":
_, b, _, t = coords
l = 0
r = 2 * np.pi
else:
l, b, r, t = coords
if self.invert_axes:
l, b, r, t = b, l, t, r
invertx = self.invert_xaxis or any(p.invert_xaxis for p in subplots)
xlim, scalex = self._compute_limits(l, r, self.logx, invertx, 'left', 'right')
inverty = self.invert_yaxis or any(p.invert_yaxis for p in subplots)
ylim, scaley = self._compute_limits(b, t, self.logy, inverty, 'bottom', 'top')
if xlim:
axis.set_xlim(**xlim)
if ylim:
axis.set_ylim(**ylim)
axis.autoscale_view(scalex=scalex, scaley=scaley)
def _compute_limits(self, low, high, log, invert, low_key, high_key):
scale = True
lims = {}
valid_lim = lambda c: util.isnumeric(c) and not np.isnan(c)
if not isinstance(low, util.datetime_types) and log and (low is None or low <= 0):
low = 0.01 if high < 0.01 else 10**(np.log10(high)-2)
self.param.warning(
"Logarithmic axis range encountered value less "
"than or equal to zero, please supply explicit "
"lower-bound to override default of %.3f." % low)
if invert:
high, low = low, high
if isinstance(low, util.cftime_types) or low != high:
if valid_lim(low):
lims[low_key] = low
scale = False
if valid_lim(high):
lims[high_key] = high
scale = False
return lims, scale
def _set_axis_position(self, axes, axis, option):
"""
Set the position and visibility of the xaxis or yaxis by
supplying the axes object, the axis to set, i.e. 'x' or 'y'
and an option to specify the position and visibility of the axis.
The option may be None, 'bare' or positional, i.e. 'left' and
'right' for the yaxis and 'top' and 'bottom' for the xaxis.
May also combine positional and 'bare' into for example 'left-bare'.
"""
positions = {'x': ['bottom', 'top'], 'y': ['left', 'right']}[axis]
axis = axes.xaxis if axis == 'x' else axes.yaxis
if option in [None, False]:
axis.set_visible(False)
for pos in positions:
axes.spines[pos].set_visible(False)
else:
if option is True:
option = positions[0]
if 'bare' in option:
axis.set_ticklabels([])
axis.set_label_text('')
if option != 'bare':
option = option.split('-')[0]
axis.set_ticks_position(option)
axis.set_label_position(option)
if not self.overlaid and not self.show_frame and self.projection != 'polar':
pos = (positions[1] if (option and (option == 'bare' or positions[0] in option))
else positions[0])
axes.spines[pos].set_visible(False)
def _set_axis_ticks(self, axis, ticks, log=False, rotation=0):
"""
Allows setting the ticks for a particular axis either with
a tuple of ticks, a tick locator object, an integer number
of ticks, a list of tuples containing positions and labels
or a list of positions. Also supports enabling log ticking
if an integer number of ticks is supplied and setting a
rotation for the ticks.
"""
if isinstance(ticks, np.ndarray):
ticks = list(ticks)
if isinstance(ticks, (list, tuple)) and all(isinstance(l, list) for l in ticks):
axis.set_ticks(ticks[0])
axis.set_ticklabels(ticks[1])
elif isinstance(ticks, ticker.Locator):
axis.set_major_locator(ticks)
elif ticks is not None and not ticks:
axis.set_ticks([])
elif isinstance(ticks, int):
if log:
locator = ticker.LogLocator(numticks=ticks,
subs=range(1,10))
else:
locator = ticker.MaxNLocator(ticks)
axis.set_major_locator(locator)
elif isinstance(ticks, (list, tuple)):
labels = None
if all(isinstance(t, tuple) for t in ticks):
ticks, labels = zip(*ticks)
axis.set_ticks(ticks)
if labels:
axis.set_ticklabels(labels)
for tick in axis.get_ticklabels():
tick.set_rotation(rotation)
@mpl_rc_context
def update_frame(self, key, ranges=None, element=None):
"""
Set the plot(s) to the given frame number. Operates by
manipulating the matplotlib objects held in the self._handles
dictionary.
If n is greater than the number of available frames, update
using the last available frame.
"""
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_key = key
self.current_frame = element
if element is not None:
self.param.update(**self.lookup_options(element, 'plot').options)
axis = self.handles['axis']
axes_visible = element is not None or self.overlaid
axis.xaxis.set_visible(axes_visible and self.xaxis)
axis.yaxis.set_visible(axes_visible and self.yaxis)
axis.patch.set_alpha(np.min([int(axes_visible), 1]))
for hname, handle in self.handles.items():
hideable = hasattr(handle, 'set_visible')
if hname not in ['axis', 'fig'] and hideable:
handle.set_visible(element is not None)
if element is None:
return
ranges = self.compute_ranges(self.hmap, key, ranges)
ranges = util.match_spec(element, ranges)
max_cycles = self.style._max_cycles
style = self.lookup_options(element, 'style')
self.style = style.max_cycles(max_cycles) if max_cycles else style
labels = getattr(self, 'legend_labels', {})
label = element.label if self.show_legend else ''
style = dict(label=labels.get(label, label), zorder=self.zorder, **self.style[self.cyclic_index])
axis_kwargs = self.update_handles(key, axis, element, ranges, style)
self._finalize_axis(key, element=element, ranges=ranges,
**(axis_kwargs if axis_kwargs else {}))
def render_artists(self, element, ranges, style, ax):
plot_data, plot_kwargs, axis_kwargs = self.get_data(element, ranges, style)
legend = plot_kwargs.pop('cat_legend', None)
with abbreviated_exception():
handles = self.init_artists(ax, plot_data, plot_kwargs)
if legend and 'artist' in handles and hasattr(handles['artist'], 'legend_elements'):
legend_handles, _ = handles['artist'].legend_elements()
leg = ax.legend(legend_handles, legend['factors'],
title=legend['title'], **self._legend_opts)
ax.add_artist(leg)
return handles, axis_kwargs
@mpl_rc_context
def initialize_plot(self, ranges=None):
element = self.hmap.last
ax = self.handles['axis']
key = list(self.hmap.data.keys())[-1]
dim_map = dict(zip((d.name for d in self.hmap.kdims), key))
key = tuple(dim_map.get(d.name, None) for d in self.dimensions)
ranges = self.compute_ranges(self.hmap, key, ranges)
self.current_ranges = ranges
self.current_frame = element
self.current_key = key
ranges = util.match_spec(element, ranges)
style = dict(zorder=self.zorder, **self.style[self.cyclic_index])
if self.show_legend:
style['label'] = element.label
handles, axis_kwargs = self.render_artists(element, ranges, style, ax)
self.handles.update(handles)
trigger = self._trigger
self._trigger = []
Stream.trigger(trigger)
return self._finalize_axis(self.keys[-1], element=element, ranges=ranges,
**axis_kwargs)
[docs] def init_artists(self, ax, plot_args, plot_kwargs):
"""
Initializes the artist based on the plot method declared on
the plot.
"""
plot_method = self._plot_methods.get('batched' if self.batched else 'single')
plot_fn = getattr(ax, plot_method)
if 'norm' in plot_kwargs: # vmin/vmax should now be exclusively in norm
plot_kwargs.pop('vmin', None)
plot_kwargs.pop('vmax', None)
with warnings.catch_warnings():
# scatter have a default cmap and with an empty array will emit this warning
warnings.filterwarnings('ignore', "No data for colormapping provided via 'c'")
artist = plot_fn(*plot_args, **plot_kwargs)
return {'artist': artist[0] if isinstance(artist, list) and
len(artist) == 1 else artist}
[docs] def update_handles(self, key, axis, element, ranges, style):
"""
Update the elements of the plot.
"""
self.teardown_handles()
handles, axis_kwargs = self.render_artists(element, ranges, style, axis)
self.handles.update(handles)
return axis_kwargs
def _apply_transforms(self, element, ranges, style):
new_style = dict(style)
for k, v in style.items():
if isinstance(v, str):
if validate(k, v) == True:
continue
elif v in element or (isinstance(element, Graph) and v in element.nodes):
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(
f'Specified {k} dim transform {v!r} could not be '
'applied, as not all dimensions could be resolved.')
continue
if v.dimension in self.overlay_dims:
ds = Dataset({d.name: v for d, v in self.overlay_dims.items()},
list(self.overlay_dims))
val = v.apply(ds, ranges=ranges, flat=True)[0]
elif type(element) is Path:
val = np.concatenate([v.apply(el, ranges=ranges, flat=True)
for el in element.split()])
else:
val = v.apply(element, ranges)
if (not np.isscalar(val) and len(util.unique_array(val)) == 1 and
("color" not in k or validate('color', val))):
val = val[0]
if not np.isscalar(val) and k in self._nonvectorized_styles:
element = type(element).__name__
raise ValueError(f'Mapping a dimension to the "{k}" '
'style option is not supported by the '
f'{element} element using the {self.renderer.backend} '
f'backend. To map the "{v.dimension}" dimension '
f'to the {k} use a groupby operation '
'to overlay your data along the dimension.')
style_groups = getattr(self, '_style_groups', [])
groups = [sg for sg in style_groups if k.startswith(sg)]
group = groups[0] if groups else None
prefix = '' if group is None else group+'_'
if (k in (prefix+'c', prefix+'color') and isinstance(val, util.arraylike_types)
and not validate('color', val)):
new_style.pop(k)
self._norm_kwargs(element, ranges, new_style, v, val, prefix)
if val.dtype.kind in 'OSUM':
range_key = dim_range_key(v)
if range_key in ranges and 'factors' in ranges[range_key]:
factors = ranges[range_key]['factors']
else:
factors = util.unique_array(val)
val = util.search_indices(val, factors)
labels = getattr(self, 'legend_labels', {})
factors = [labels.get(f, f) for f in factors]
new_style['cat_legend'] = {
'title': v.dimension, 'prop': 'c', 'factors': factors
}
k = prefix+'c'
new_style[k] = val
for k, val in list(new_style.items()):
# If mapped to color/alpha override static fill/line style
if k == 'c':
new_style.pop('color', None)
style_groups = getattr(self, '_style_groups', [])
groups = [sg for sg in style_groups if k.startswith(sg)]
group = groups[0] if groups else None
prefix = '' if group is None else group+'_'
# Check if element supports fill and line style
supports_fill = (
(prefix != 'edge' or getattr(self, 'filled', True))
and any(o.startswith(prefix+'face') for o in self.style_opts))
if k in (prefix+'c', prefix+'color') and isinstance(val, util.arraylike_types):
fill_style = new_style.get(prefix+'facecolor')
if fill_style and validate('color', fill_style):
new_style.pop('facecolor')
line_style = new_style.get(prefix+'edgecolor')
# If glyph has fill and line style is set overriding line color
if supports_fill and line_style is not None:
continue
if line_style and validate('color', line_style):
new_style.pop('edgecolor')
elif k == 'facecolors' and not isinstance(new_style.get('color', new_style.get('c')), np.ndarray):
# Color overrides facecolors if defined
new_style.pop('color', None)
new_style.pop('c', None)
return new_style
[docs] def teardown_handles(self):
"""
If no custom update_handles method is supplied this method
is called to tear down any previous handles before replacing
them.
"""
if 'artist' in self.handles:
self.handles['artist'].remove()
[docs]class ColorbarPlot(ElementPlot):
clabel = param.String(default=None, doc="""
An explicit override of the color bar label, if set takes precedence
over the title key in colorbar_opts.""")
clim = param.Tuple(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.""")
cformatter = param.ClassSelector(
default=None, class_=(str, ticker.Formatter, FunctionType), doc="""
Formatter for ticks along the colorbar axis.""")
colorbar = param.Boolean(default=False, doc="""
Whether to draw a colorbar.""")
colorbar_opts = param.Dict(default={}, doc="""
Allows setting specific styling options for the 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.""")
cnorm = param.ObjectSelector(default='linear', objects=['linear', 'log', 'eq_hist'], doc="""
Color normalization to be applied during colormapping.""")
clipping_colors = param.Dict(default={}, doc="""
Dictionary to specify colors for clipped values, allows
setting color for NaN values and for values above and below
the min and max value. The min, max or NaN color may specify
an RGB(A) color as a color hex string of the form #FFFFFF or
#FFFFFFFF or a length 3 or length 4 tuple specifying values in
the range 0-1 or a named HTML color.""")
cbar_padding = param.Number(default=0.01, doc="""
Padding between colorbar and other plots.""")
cbar_ticks = param.Parameter(default=None, doc="""
Ticks along colorbar-axis specified as an integer, explicit
list of tick locations, list of tuples containing the
locations and labels or a matplotlib tick locator object. If
set to None default matplotlib ticking behavior is
applied.""")
cbar_width = param.Number(default=0.05, doc="""
Width of the colorbar as a fraction of the main plot""")
cbar_extend = param.ObjectSelector(
objects=['neither', 'both', 'min', 'max'], default=None, doc="""
If not 'neither', make pointed end(s) for out-of- range values."""
)
rescale_discrete_levels = param.Boolean(default=True, doc="""
If ``cnorm='eq_hist`` and there are only a few discrete values,
then ``rescale_discrete_levels=True`` decreases the lower
limit of the autoranged span so that the values are rendering
towards the (more visible) top of the palette, thus
avoiding washout of the lower values. Has no effect if
``cnorm!=`eq_hist``. Set this value to False if you need to
match historical unscaled behavior, prior to HoloViews 1.14.4.""")
symmetric = param.Boolean(default=False, doc="""
Whether to make the colormap symmetric around zero.""")
_colorbars = {}
_default_nan = '#8b8b8b'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _adjust_cbar(self, cbar, label, dim):
noalpha = math.floor(self.style[self.cyclic_index].get('alpha', 1)) == 1
for lb in ['clabel', 'labels']:
labelsize = self._fontsize(lb, common=False).get('fontsize')
if labelsize is not None:
break
if (cbar.solids and noalpha):
cbar.solids.set_edgecolor("face")
cbar.set_label(label, fontsize=labelsize)
if isinstance(self.cbar_ticks, ticker.Locator):
cbar.ax.yaxis.set_major_locator(self.cbar_ticks)
elif self.cbar_ticks == 0:
cbar.set_ticks([])
elif isinstance(self.cbar_ticks, int):
locator = ticker.MaxNLocator(self.cbar_ticks)
cbar.ax.yaxis.set_major_locator(locator)
elif isinstance(self.cbar_ticks, list):
if all(isinstance(t, tuple) for t in self.cbar_ticks):
ticks, labels = zip(*self.cbar_ticks)
else:
ticks, labels = zip(*[(t, dim.pprint_value(t))
for t in self.cbar_ticks])
cbar.set_ticks(ticks)
cbar.set_ticklabels(labels)
for tk in ['cticks', 'ticks']:
ticksize = self._fontsize(tk, common=False).get('fontsize')
if ticksize is not None:
cbar.ax.tick_params(labelsize=ticksize)
break
def _finalize_artist(self, element):
if self.colorbar:
dims = [h for k, h in self.handles.items() if k.endswith('color_dim')]
for d in dims:
self._draw_colorbar(element, d)
def _draw_colorbar(self, element=None, dimension=None, redraw=True):
if element is None:
element = self.hmap.last
artist = self.handles.get('artist', None)
fig = self.handles['fig']
axis = self.handles['axis']
ax_colorbars, position = ColorbarPlot._colorbars.get(id(axis), ([], None))
specs = [spec[:2] for _, _, spec, _ in ax_colorbars]
spec = util.get_spec(element)
if position is None or not redraw:
if redraw:
fig.canvas.draw()
bbox = axis.get_position()
l, b, w, h = bbox.x0, bbox.y0, bbox.width, bbox.height
else:
l, b, w, h = position
# Get colorbar label
if isinstance(dimension, dim):
dimension = dimension.dimension
dimension = element.get_dimension(dimension)
if self.clabel is not None:
label = self.clabel
elif dimension:
label = dimension.pprint_label
elif element.vdims:
label = element.vdims[0].pprint_label
elif dimension is None:
label = ''
padding = self.cbar_padding
width = self.cbar_width
if spec[:2] not in specs:
offset = len(ax_colorbars)
scaled_w = w*width
cax = fig.add_axes([l+w+padding+(scaled_w+padding+w*0.15)*offset,
b, scaled_w, h])
cbar = fig.colorbar(artist, cax=cax, ax=axis,
extend=self.cbar_extend, **self.colorbar_opts)
self._set_axis_formatter(cbar.ax.yaxis, dimension, self.cformatter)
self._adjust_cbar(cbar, label, dimension)
self.handles['cax'] = cax
self.handles['cbar'] = cbar
ylabel = cax.yaxis.get_label()
self.handles['bbox_extra_artists'] += [cax, ylabel]
ax_colorbars.append((artist, cax, spec, label))
for i, (_artist, cax, _spec, _label) in enumerate(ax_colorbars):
scaled_w = w*width
cax.set_position([l+w+padding+(scaled_w+padding+w*0.15)*i,
b, scaled_w, h])
ColorbarPlot._colorbars[id(axis)] = (ax_colorbars, (l, b, w, h))
def _norm_kwargs(self, element, ranges, opts, vdim, values=None, prefix=''):
"""
Returns valid color normalization kwargs
to be passed to matplotlib plot function.
"""
dim_name = dim_range_key(vdim)
if values is None:
if isinstance(vdim, dim):
values = vdim.apply(element, flat=True)
else:
expanded = not (
isinstance(element, Dataset) and
element.interface.multi and
(getattr(element, 'level', None) is not None or
element.interface.isunique(element, vdim.name, True))
)
values = np.asarray(element.dimension_values(vdim, expanded=expanded))
# Store dimension being colormapped for colorbars
if prefix+'color_dim' not in self.handles:
self.handles[prefix+'color_dim'] = vdim
clim = opts.pop(prefix+'clims', None)
# check if there's an actual value (not np.nan)
if clim is None and self.clim is not None and any(util.isfinite(cl) for cl in self.clim):
clim = self.clim
if clim is None:
if not len(values):
clim = (0, 0)
categorical = False
elif values.dtype.kind in 'uif':
if dim_name in ranges:
if self.clim_percentile and 'robust' in ranges[dim_name]:
clim = ranges[dim_name]['robust']
else:
clim = ranges[dim_name]['combined']
elif isinstance(vdim, dim):
if values.dtype.kind == 'M':
clim = values.min(), values.max()
elif len(values) == 0:
clim = np.nan, np.nan
else:
try:
with warnings.catch_warnings():
warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered')
clim = (np.nanmin(values), np.nanmax(values))
except Exception:
clim = np.nan, np.nan
else:
clim = element.range(vdim)
if self.logz:
# Lower clim must be >0 when logz=True
# Choose the maximum between the lowest non-zero value
# and the overall range
if clim[0] == 0:
clim = (values[values!=0].min(), clim[1])
if self.symmetric:
clim = -np.abs(clim).max(), np.abs(clim).max()
categorical = False
else:
range_key = dim_range_key(vdim)
if range_key in ranges and 'factors' in ranges[range_key]:
factors = ranges[range_key]['factors']
else:
factors = util.unique_array(values)
clim = (0, len(factors)-1)
categorical = True
else:
categorical = values.dtype.kind not in 'uif'
if self.cnorm == 'eq_hist':
opts[prefix+'norm'] = EqHistNormalize(
vmin=clim[0], vmax=clim[1],
rescale_discrete_levels=self.rescale_discrete_levels
)
if self.cnorm == 'log' or self.logz:
if self.symmetric:
norm = mpl_colors.SymLogNorm(vmin=clim[0], vmax=clim[1],
linthresh=clim[1]/np.e)
else:
norm = mpl_colors.LogNorm(vmin=clim[0], vmax=clim[1])
opts[prefix+'norm'] = norm
opts[prefix+'vmin'] = clim[0]
opts[prefix+'vmax'] = clim[1]
cmap = opts.get(prefix+'cmap', opts.get('cmap', 'viridis'))
if values.dtype.kind not in 'OSUM':
ncolors = None
if isinstance(self.color_levels, int):
ncolors = self.color_levels
elif isinstance(self.color_levels, list):
ncolors = len(self.color_levels) - 1
if isinstance(cmap, list) and len(cmap) != ncolors:
raise ValueError('The number of colors in the colormap '
'must match the intervals defined in the '
'color_levels, expected %d colors found %d.'
% (ncolors, len(cmap)))
try:
el_min, el_max = np.nanmin(values), np.nanmax(values)
except ValueError:
el_min, el_max = -np.inf, np.inf
else:
ncolors = clim[-1]+1
el_min, el_max = -np.inf, np.inf
vmin = -np.inf if opts[prefix+'vmin'] is None else opts[prefix+'vmin']
vmax = np.inf if opts[prefix+'vmax'] is None else opts[prefix+'vmax']
if self.cbar_extend is None:
if el_min < vmin and el_max > vmax:
self.cbar_extend = 'both'
elif el_min < vmin:
self.cbar_extend = 'min'
elif el_max > vmax:
self.cbar_extend = 'max'
else:
self.cbar_extend = 'neither'
# Define special out-of-range colors on colormap
colors = {}
for k, val in self.clipping_colors.items():
if val == 'transparent':
colors[k] = {'color': 'w', 'alpha': 0}
elif isinstance(val, tuple):
colors[k] = {'color': val[:3],
'alpha': val[3] if len(val) > 3 else 1}
elif isinstance(val, str):
color = val
alpha = 1
if color.startswith('#') and len(color) == 9:
alpha = int(color[-2:], 16)/255.
color = color[:-2]
colors[k] = {'color': color, 'alpha': alpha}
if not isinstance(cmap, mpl_colors.Colormap):
if isinstance(cmap, dict):
# The palette needs to correspond to the map's limits (vmin/vmax). So use the same
# factors as in the map's clim computation above.
range_key = dim_range_key(vdim)
if range_key in ranges and 'factors' in ranges[range_key]:
factors = ranges[range_key]['factors']
else:
factors = util.unique_array(values)
palette = [cmap.get(f, colors.get('NaN', {'color': self._default_nan})['color'])
for f in factors]
else:
palette = process_cmap(cmap, ncolors, categorical=categorical)
if isinstance(self.color_levels, list):
palette, (vmin, vmax) = color_intervals(palette, self.color_levels, clip=(vmin, vmax))
cmap = mpl_colors.ListedColormap(palette)
cmap = copy.copy(cmap)
if 'max' in colors: cmap.set_over(**colors['max'])
if 'min' in colors: cmap.set_under(**colors['min'])
if 'NaN' in colors: cmap.set_bad(**colors['NaN'])
opts[prefix+'cmap'] = cmap
[docs]class LegendPlot(ElementPlot):
show_legend = param.Boolean(default=True, doc="""
Whether to show legend for the plot.""")
legend_cols = param.Integer(default=None, doc="""
Number of legend columns in the legend.""")
legend_labels = param.Dict(default={}, doc="""
A mapping that allows overriding legend labels.""")
legend_position = param.ObjectSelector(objects=['inner', 'right',
'bottom', 'top',
'left', 'best',
'top_right',
'top_left',
'bottom_left',
'bottom_right'],
default='inner', doc="""
Allows selecting between a number of predefined legend position
options. The predefined options may be customized in the
legend_specs class attribute. By default, 'inner', 'right',
'bottom', 'top', 'left', 'best', 'top_right', 'top_left',
'bottom_right' and 'bottom_left' are supported.""")
legend_opts = param.Dict(default={}, doc="""
Allows setting specific styling options for the colorbar.""")
legend_specs = {'inner': {},
'best': {},
'left': dict(bbox_to_anchor=(-.15, 1), loc=1),
'right': dict(bbox_to_anchor=(1.05, 1), loc=2),
'top': dict(bbox_to_anchor=(0., 1.02, 1., .102),
ncol=3, loc=3, mode="expand", borderaxespad=0.),
'bottom': dict(ncol=3, mode="expand", loc=2,
bbox_to_anchor=(0., -0.25, 1., .102),
borderaxespad=0.1),
'top_right': dict(loc=1),
'top_left': dict(loc=2),
'bottom_left': dict(loc=3),
'bottom_right': dict(loc=4)}
@property
def _legend_opts(self):
leg_spec = self.legend_specs[self.legend_position]
if self.legend_cols: leg_spec['ncol'] = self.legend_cols
legend_opts = self.legend_opts.copy()
legend_opts.update(**dict(leg_spec, **self._fontsize('legend')))
return legend_opts
[docs]class OverlayPlot(LegendPlot, GenericOverlayPlot):
"""
OverlayPlot supports compositors processing of Overlays across maps.
"""
_passed_handles = ['fig', 'axis']
_propagate_options = ['aspect', 'fig_size', 'xaxis', 'yaxis', 'zaxis',
'labelled', 'bgcolor', 'fontsize', 'invert_axes',
'show_frame', 'show_grid', 'logx', 'logy', 'logz',
'xticks', 'yticks', 'zticks', 'xrotation', 'yrotation',
'zrotation', 'invert_xaxis', 'invert_yaxis',
'invert_zaxis', 'title', 'title_format', 'padding',
'xlabel', 'ylabel', 'zlabel', 'xlim', 'ylim', 'zlim',
'xformatter', 'yformatter', 'data_aspect', 'fontscale',
'legend_opts']
def __init__(self, overlay, ranges=None, **params):
if 'projection' not in params:
params['projection'] = self._get_projection(overlay)
super().__init__(overlay, ranges=ranges, **params)
def _finalize_artist(self, element):
for subplot in self.subplots.values():
subplot._finalize_artist(element)
def _adjust_legend(self, overlay, axis):
"""
Accumulate the legend handles and labels for all subplots
and set up the legend
"""
legend_data = []
legend_plot = True
dimensions = overlay.kdims
title = ', '.join([d.label for d in dimensions])
labels = self.legend_labels
for key, subplot in self.subplots.items():
element = overlay.data.get(key, False)
if not subplot.show_legend or not element: continue
title = ', '.join([d.name for d in dimensions])
handle = subplot.traverse(lambda p: p.handles['artist'],
[lambda p: 'artist' in p.handles])
if getattr(subplot, '_legend_plot', None) is not None:
legend_plot = True
elif isinstance(overlay, NdOverlay):
label = ','.join([dim.pprint_value(k, print_unit=True)
for k, dim in zip(key, dimensions)])
if handle:
legend_data.append((handle, label))
elif isinstance(subplot, OverlayPlot):
legend_data += subplot.handles.get('legend_data', {}).items()
elif element.label and handle:
legend_data.append((handle, labels.get(element.label, element.label)))
all_handles, all_labels = list(zip(*legend_data)) if legend_data else ([], [])
data = {}
used_labels = []
for handle, label in zip(all_handles, all_labels):
# Ensure that artists with multiple handles are supported
if isinstance(handle, list): handle = tuple(handle)
handle = tuple(h for h in handle if not isinstance(h, (AxesImage, list)))
if not handle:
continue
if handle and (handle not in data) and label and label not in used_labels:
data[handle] = label
used_labels.append(label)
if (not len(set(data.values())) > 0) or not self.show_legend:
legend = axis.get_legend()
if legend and not (legend_plot or self.show_legend):
legend.set_visible(False)
else:
leg = axis.legend(list(data.keys()), list(data.values()),
title=title, **self._legend_opts)
title_fontsize = self._fontsize('legend_title')
if title_fontsize:
leg.get_title().set_fontsize(title_fontsize['fontsize'])
leg.set_zorder(10e6)
self.handles['legend'] = leg
self.handles['bbox_extra_artists'].append(leg)
self.handles['legend_data'] = data
@mpl_rc_context
def initialize_plot(self, ranges=None):
axis = self.handles['axis']
key = self.keys[-1]
element = self._get_frame(key)
ranges = self.compute_ranges(self.hmap, key, ranges)
for k, subplot in self.subplots.items():
subplot.initialize_plot(ranges=ranges)
if isinstance(element, CompositeOverlay):
frame = element.get(k, None)
subplot.current_frame = frame
if self.show_legend and element is not None:
self._adjust_legend(element, axis)
return self._finalize_axis(key, element=element, ranges=ranges,
title=self._format_title(key))
@mpl_rc_context
def update_frame(self, key, ranges=None, element=None):
axis = self.handles['axis']
reused = isinstance(self.hmap, DynamicMap) and self.overlaid
self.prev_frame = self.current_frame
if element is None and not reused:
element = self._get_frame(key)
elif element is not None:
self.current_frame = element
self.current_key = key
empty = element is None
if isinstance(self.hmap, DynamicMap):
range_obj = element
else:
range_obj = self.hmap
items = [] if element is None else list(element.data.items())
if not empty:
ranges = self.compute_ranges(range_obj, key, ranges)
for k, subplot in self.subplots.items():
el = None if empty else element.get(k, None)
if isinstance(self.hmap, DynamicMap) and not empty:
idx, spec, exact = self._match_subplot(k, subplot, items, element)
if idx is not None:
_, el = items.pop(idx)
if not exact:
self._update_subplot(subplot, spec)
subplot.update_frame(key, ranges, el)
if isinstance(self.hmap, DynamicMap) and items:
self._create_dynamic_subplots(key, items, ranges)
# 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)
if self.show_legend and not empty:
self._adjust_legend(element, axis)
self._finalize_axis(key, element=element, ranges=ranges)