diff --git a/examples/reference/elements/bokeh/HeatMap.ipynb b/examples/reference/elements/bokeh/HeatMap.ipynb index 929e3bb23f..fa12d41ecb 100644 --- a/examples/reference/elements/bokeh/HeatMap.ipynb +++ b/examples/reference/elements/bokeh/HeatMap.ipynb @@ -43,8 +43,9 @@ "metadata": {}, "outputs": [], "source": [ - "data = [(chr(65+i), chr(97+j), i*j) for i in range(5) for j in range(5) if i!=j]\n", - "hv.HeatMap(data).sort()" + "data = [(i, chr(97+j), i*j) for i in range(5) for j in range(5) if i!=j]\n", + "hm = hv.HeatMap(data).sort()\n", + "hm.opts(xticks=None)" ] }, { @@ -70,7 +71,9 @@ "source": [ "As the above example shows before aggregating the second value for the (0, 0) is ignored unless we aggregate the data first.\n", "\n", - "To reveal the values of a ``HeatMap`` we can either enable a ``colorbar`` or add a hover tool. The hover tools even allows displaying any number of additional value dimensions, providing additional information a static plot could not capture:" + "To reveal the values of a ``HeatMap`` we can either enable a ``colorbar`` or add a hover tool. The hover tools even allows displaying any number of additional value dimensions, providing additional information a static plot could not capture. \n", + "\n", + "Note that a HeatMap allows mixtures of categorical, numeric and datetime values along the x- and y-axes:" ] }, { @@ -79,9 +82,10 @@ "metadata": {}, "outputs": [], "source": [ - "heatmap = hv.HeatMap((np.random.randint(0, 10, 100), np.random.randint(0, 10, 100),\n", - " np.random.randn(100), np.random.randn(100)), vdims=['z', 'z2']).redim.range(z=(-2, 2))\n", - "heatmap.opts(opts.HeatMap(tools=['hover'], colorbar=True, width=325, toolbar='above'))" + "heatmap = hv.HeatMap((np.random.randint(0, 10, 100), np.random.choice(['A', 'B', 'C', 'D', 'E'], 100), \n", + " np.random.randn(100), np.random.randn(100)), vdims=['z', 'z2']).sort().aggregate(function=np.mean)\n", + "\n", + "heatmap.opts(opts.HeatMap(tools=['hover'], colorbar=True, width=325, toolbar='above', clim=(-2, 2)))" ] }, { diff --git a/examples/reference/elements/matplotlib/HeatMap.ipynb b/examples/reference/elements/matplotlib/HeatMap.ipynb index 9dfc3ddc49..5b1986135f 100644 --- a/examples/reference/elements/matplotlib/HeatMap.ipynb +++ b/examples/reference/elements/matplotlib/HeatMap.ipynb @@ -24,6 +24,7 @@ "source": [ "import numpy as np\n", "import holoviews as hv\n", + "\n", "hv.extension('matplotlib')" ] }, @@ -69,7 +70,9 @@ "source": [ "As the above example shows before aggregating the second value for the (0, 0) is ignored unless we aggregate the data first.\n", "\n", - "To reveal the values of a ``HeatMap`` we can enable a ``colorbar`` and if you wish to have interactive hover information, you can use the hover tool in the [Bokeh backend](../bokeh/HeatMap.ipynb):" + "To reveal the values of a ``HeatMap`` we can enable a ``colorbar`` and if you wish to have interactive hover information, you can use the hover tool in the [Bokeh backend](../bokeh/HeatMap.ipynb).\n", + "\n", + "Note that a HeatMap allows mixtures of categorical, numeric and datetime values along the x- and y-axes:" ] }, { @@ -78,10 +81,10 @@ "metadata": {}, "outputs": [], "source": [ - "heatmap = hv.HeatMap((np.random.randint(0, 10, 100), np.random.randint(0, 10, 100),\n", - " np.random.randn(100), np.random.randn(100)), vdims=['z', 'z2']).redim.range(z=(-2, 2))\n", + "heatmap = hv.HeatMap((np.random.randint(0, 10, 100), np.random.choice(['A', 'B', 'C', 'D', 'E'], 100), \n", + " np.random.randn(100), np.random.randn(100)), vdims=['z', 'z2']).sort().aggregate(function=np.mean)\n", "\n", - "heatmap.opts(colorbar=True, fig_size=250)" + "heatmap.opts(colorbar=True, fig_size=250, clim=(-2, 2))" ] }, { diff --git a/examples/reference/elements/plotly/HeatMap.ipynb b/examples/reference/elements/plotly/HeatMap.ipynb index 80c1c7c57b..580e57140e 100644 --- a/examples/reference/elements/plotly/HeatMap.ipynb +++ b/examples/reference/elements/plotly/HeatMap.ipynb @@ -21,6 +21,8 @@ "source": [ "import numpy as np\n", "import holoviews as hv\n", + "from holoviews import opts\n", + "\n", "hv.extension('plotly')" ] }, @@ -39,7 +41,7 @@ "metadata": {}, "outputs": [], "source": [ - "data = [(chr(65+i), chr(97+j), i*j) for i in range(5) for j in range(5) if i!=j]\n", + "data = [(i, chr(97+j), i*j) for i in range(5) for j in range(5) if i!=j]\n", "hv.HeatMap(data).opts(cmap='RdBu_r')" ] }, @@ -65,7 +67,23 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As the above example shows before aggregating the second value for the (0, 0) is ignored unless we aggregate the data first." + "As the above example shows before aggregating the second value for the (0, 0) is ignored unless we aggregate the data first.\n", + "\n", + "To reveal the values of a ``HeatMap`` we can either enable a ``colorbar`` or use the hover tool.\n", + "\n", + "Note that a HeatMap allows mixtures of categorical, numeric and datetime values along the x- and y-axes:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "heatmap = hv.HeatMap((np.random.randint(0, 10, 100), np.random.choice(['A', 'B', 'C', 'D', 'E'], 100), \n", + " np.random.randn(100), np.random.randn(100)), vdims=['z', 'z2']).sort().aggregate(function=np.mean)\n", + "\n", + "heatmap.opts(opts.HeatMap(colorbar=True, clim=(-2, 2)))" ] }, { diff --git a/holoviews/core/data/grid.py b/holoviews/core/data/grid.py index 482386c918..a135db42a1 100644 --- a/holoviews/core/data/grid.py +++ b/holoviews/core/data/grid.py @@ -247,7 +247,10 @@ def _infer_interval_breaks(cls, coord, axis=0): coord = coord.astype('datetime64') if len(coord) == 0: return np.array([], dtype=coord.dtype) - deltas = 0.5 * np.diff(coord, axis=axis) + if len(coord) > 1: + deltas = 0.5 * np.diff(coord, axis=axis) + else: + deltas = np.array([0.5]) first = np.take(coord, [0], axis=axis) - np.take(deltas, [0], axis=axis) last = np.take(coord, [-1], axis=axis) + np.take(deltas, [-1], axis=axis) trim_last = tuple(slice(None, -1) if n == axis else slice(None) diff --git a/holoviews/element/raster.py b/holoviews/element/raster.py index 8965030b5d..cf8b89dbf1 100644 --- a/holoviews/element/raster.py +++ b/holoviews/element/raster.py @@ -937,3 +937,39 @@ class HeatMap(Dataset, Element2D): def __init__(self, data, kdims=None, vdims=None, **params): super(HeatMap, self).__init__(data, kdims=kdims, vdims=vdims, **params) self.gridded = categorical_aggregate2d(self) + + @property + def _unique(self): + """ + Reports if the Dataset is unique. + """ + return self.gridded.label != 'non-unique' + + def range(self, dim, data_range=True, dimension_range=True): + """Return the lower and upper bounds of values along dimension. + + Args: + dimension: The dimension to compute the range on. + data_range (bool): Compute range from data values + dimension_range (bool): Include Dimension ranges + Whether to include Dimension range and soft_range + in range calculation + + Returns: + Tuple containing the lower and upper bound + """ + dim = self.get_dimension(dim) + if dim in self.kdims: + try: + self.gridded._binned = True + if self.gridded is self: + return super(HeatMap, self).range(dim, data_range, dimension_range) + else: + drange = self.gridded.range(dim, data_range, dimension_range) + except: + drange = None + finally: + self.gridded._binned = False + if drange is not None: + return drange + return super(HeatMap, self).range(dim, data_range, dimension_range) diff --git a/holoviews/element/util.py b/holoviews/element/util.py index b3aaa16322..981d6e7eb4 100644 --- a/holoviews/element/util.py +++ b/holoviews/element/util.py @@ -131,6 +131,10 @@ def _get_coords(self, obj): xdim, ydim = obj.dimensions(label=True)[:2] xcoords = obj.dimension_values(xdim, False) ycoords = obj.dimension_values(ydim, False) + if xcoords.dtype.kind not in 'SUO': + xcoords = np.sort(xcoords) + if ycoords.dtype.kind not in 'SUO': + return xcoords, np.sort(ycoords) # Determine global orderings of y-values using topological sort grouped = obj.groupby(xdim, container_type=OrderedDict, @@ -155,14 +159,14 @@ def _get_coords(self, obj): elif not is_cyclic(orderings): coords = list(itertools.chain(*sort_topologically(orderings))) ycoords = coords if len(coords) == len(ycoords) else np.sort(ycoords) - return xcoords, ycoords + return np.asarray(xcoords), np.asarray(ycoords) - - def _aggregate_dataset(self, obj, xcoords, ycoords): + def _aggregate_dataset(self, obj): """ Generates a gridded Dataset from a column-based dataset and lists of xcoords and ycoords """ + xcoords, ycoords = self._get_coords(obj) dim_labels = obj.dimensions(label=True) vdims = obj.dimensions()[2:] xdim, ydim = dim_labels[:2] @@ -195,6 +199,18 @@ def _aggregate_dataset(self, obj, xcoords, ycoords): return agg.clone(grid_data, kdims=[xdim, ydim], vdims=vdims, datatype=self.p.datatype) + def _aggregate_dataset_pandas(self, obj): + index_cols = [d.name for d in obj.kdims] + df = obj.data.set_index(index_cols).groupby(index_cols, sort=False).first() + label = 'unique' if len(df) == len(obj) else 'non-unique' + levels = self._get_coords(obj) + index = pd.MultiIndex.from_product(levels, names=df.index.names) + reindexed = df.reindex(index) + data = tuple(levels) + shape = tuple(d.shape[0] for d in data) + for vdim in obj.vdims: + data += (reindexed[vdim.name].values.reshape(shape).T,) + return obj.clone(data, datatype=self.p.datatype, label=label) def _process(self, obj, key=None): """ @@ -210,10 +226,12 @@ def _process(self, obj, key=None): raise ValueError("Must have at two dimensions to aggregate over" "and one value dimension to aggregate on.") - dtype = 'dataframe' if pd else 'dictionary' - obj = Dataset(obj, datatype=[dtype]) - xcoords, ycoords = self._get_coords(obj) - return self._aggregate_dataset(obj, xcoords, ycoords) + if pd: + obj = Dataset(obj, datatype=['dataframe']) + return self._aggregate_dataset_pandas(obj) + else: + obj = Dataset(obj, datatype=['dictionary']) + return self._aggregate_dataset(obj) def circular_layout(nodes): diff --git a/holoviews/plotting/bokeh/heatmap.py b/holoviews/plotting/bokeh/heatmap.py index 97726a6937..8f0ba2974b 100644 --- a/holoviews/plotting/bokeh/heatmap.py +++ b/holoviews/plotting/bokeh/heatmap.py @@ -3,13 +3,13 @@ import param import numpy as np -from bokeh.models import Span from bokeh.models.glyphs import AnnularWedge +from ...core.data import GridInterface from ...core.util import is_nan, dimension_sanitizer from ...core.spaces import HoloMap from .element import ColorbarPlot, CompositeElementPlot -from .styles import line_properties, fill_properties, mpl_to_bokeh, text_properties +from .styles import line_properties, fill_properties, text_properties class HeatMapPlot(ColorbarPlot): @@ -54,8 +54,6 @@ class HeatMapPlot(ColorbarPlot): ['ymarks_' + p for p in line_properties] + ['cmap', 'color', 'dilate', 'visible'] + line_properties + fill_properties) - _categorical = True - @classmethod def is_radial(cls, heatmap): heatmap = heatmap.last if isinstance(heatmap, HoloMap) else heatmap @@ -70,25 +68,58 @@ def get_data(self, element, ranges, style): x, y, z = [dimension_sanitizer(d) for d in element.dimensions(label=True)[:3]] if self.invert_axes: x, y = y, x cmapper = self._get_colormapper(element.vdims[0], element, ranges, style) - if 'line_alpha' not in style: style['line_alpha'] = 0 + if 'line_alpha' not in style and 'line_width' not in style: + style['line_alpha'] = 0 + elif 'line_color' not in style: + style['line_color'] = 'white' + + if not element._unique: + self.warning('HeatMap element index is not unique, ensure you ' + 'aggregate the data before displaying it, e.g. ' + 'using heatmap.aggregate(function=np.mean). ' + 'Duplicate index values have been dropped.') + if self.static_source: return {}, {'x': x, 'y': y, 'fill_color': {'field': 'zvalues', 'transform': cmapper}}, style aggregate = element.gridded xdim, ydim = aggregate.dimensions()[:2] - xvals, yvals = (aggregate.dimension_values(x), - aggregate.dimension_values(y)) + + xtype = aggregate.interface.dtype(aggregate, xdim) + widths = None + if xtype.kind in 'SUO': + xvals = aggregate.dimension_values(xdim) + width = 1 + else: + xvals = aggregate.dimension_values(xdim, flat=False) + edges = GridInterface._infer_interval_breaks(xvals, axis=1) + widths = np.diff(edges, axis=1).T.flatten() + xvals = xvals.T.flatten() + width = 'width' + + ytype = aggregate.interface.dtype(aggregate, ydim) + heights = None + if ytype.kind in 'SUO': + yvals = aggregate.dimension_values(ydim) + height = 1 + else: + yvals = aggregate.dimension_values(ydim, flat=False) + edges = GridInterface._infer_interval_breaks(yvals, axis=0) + heights = np.diff(edges, axis=0).T.flatten() + yvals = yvals.T.flatten() + height = 'height' + zvals = aggregate.dimension_values(2, flat=False) + zvals = zvals.T.flatten() + if self.invert_axes: - xdim, ydim = ydim, xdim - zvals = zvals.T.flatten() - else: - zvals = zvals.T.flatten() - if xvals.dtype.kind not in 'SU': - xvals = [xdim.pprint_value(xv) for xv in xvals] - if yvals.dtype.kind not in 'SU': - yvals = [ydim.pprint_value(yv) for yv in yvals] + width, height = height, width + data = {x: xvals, y: yvals, 'zvalues': zvals} + if widths is not None: + data['width'] = widths + if heights is not None: + data['height'] = heights if 'hover' in self.handles and not self.static_source: for vdim in element.vdims: @@ -100,48 +131,13 @@ def get_data(self, element, ranges, style): style = {k: v for k, v in style.items() if not any(g in k for g in RadialHeatMapPlot._style_groups.values())} return (data, {'x': x, 'y': y, 'fill_color': {'field': 'zvalues', 'transform': cmapper}, - 'height': 1, 'width': 1}, style) + 'height': height, 'width': width}, style) def _draw_markers(self, plot, element, marks, axis='x'): - if marks is None: + if marks is None or self.radial: return - style = self.style[self.cyclic_index] - mark_opts = {k[7:]: v for k, v in style.items() if axis+'mark' in k} - mark_opts = {'line_'+k if k in ('color', 'alpha') else k: v - for k, v in mpl_to_bokeh(mark_opts).items()} - categories = list(element.dimension_values(0 if axis == 'x' else 1, - expanded=False)) - - if callable(marks): - positions = [i for i, x in enumerate(categories) if marks(x)] - elif isinstance(marks, int): - nth_mark = np.ceil(len(categories) / marks).astype(int) - positions = np.arange(len(categories)+1)[::nth_mark] - elif isinstance(marks, tuple): - positions = [categories.index(m) for m in marks if m in categories] - else: - positions = [m for m in marks if isinstance(m, int) and m < len(categories)] - if axis == 'y': - positions = [len(categories)-p for p in positions] - - prev_markers = self.handles.get(axis+'marks', []) - new_markers = [] - for i, p in enumerate(positions): - if i < len(prev_markers): - span = prev_markers[i] - span.update(**dict(mark_opts, location=p)) - else: - dimension = 'height' if axis == 'x' else 'width' - span = Span(level='annotation', dimension=dimension, - location=p, **mark_opts) - plot.renderers.append(span) - span.visible = True - new_markers.append(span) - for pm in prev_markers: - if pm not in new_markers: - pm.visible = False - new_markers.append(pm) - self.handles[axis+'marks'] = new_markers + self.warning('Only radial HeatMaps supports marks, to make the' + 'HeatMap quads for distinguishable set a line_width') def _init_glyphs(self, plot, element, ranges, source): super(HeatMapPlot, self)._init_glyphs(plot, element, ranges, source) diff --git a/holoviews/plotting/mixins.py b/holoviews/plotting/mixins.py index b27b383f37..dc532fc454 100644 --- a/holoviews/plotting/mixins.py +++ b/holoviews/plotting/mixins.py @@ -46,6 +46,27 @@ def get_extents(self, element, ranges, range_type='combined'): return (x0, y0, x1, y1) +class HeatMapMixin(object): + + def get_extents(self, element, ranges, range_type='combined'): + if range_type in ('data', 'combined'): + agg = element.gridded + xtype = agg.interface.dtype(agg, 0) + shape = agg.interface.shape(agg, gridded=True) + if xtype.kind in 'SUO': + x0, x1 = (0-0.5, shape[1]-0.5) + else: + x0, x1 = element.range(0) + ytype = agg.interface.dtype(agg, 1) + if ytype.kind in 'SUO': + y0, y1 = (-.5, shape[0]-0.5) + else: + y0, y1 = element.range(1) + return (x0, y0, x1, y1) + else: + return super(HeatMapMixin, self).get_extents(element, ranges, range_type) + + class SpikesMixin(object): def get_extents(self, element, ranges, range_type='combined'): diff --git a/holoviews/plotting/mpl/__init__.py b/holoviews/plotting/mpl/__init__.py index 4af2701105..713589b3bf 100644 --- a/holoviews/plotting/mpl/__init__.py +++ b/holoviews/plotting/mpl/__init__.py @@ -256,10 +256,11 @@ def grid_selector(grid): options.Image = Options('style', cmap=dflt_cmap, interpolation='nearest') options.Raster = Options('style', cmap=dflt_cmap, interpolation='nearest') options.QuadMesh = Options('style', cmap=dflt_cmap) -options.HeatMap = Options('style', cmap='RdYlBu_r', interpolation='nearest', +options.HeatMap = Options('style', cmap='RdYlBu_r', edgecolors='white', annular_edgecolors='white', annular_linewidth=0.5, xmarks_edgecolor='white', xmarks_linewidth=3, - ymarks_edgecolor='white', ymarks_linewidth=3) + ymarks_edgecolor='white', ymarks_linewidth=3, + linewidths=0) options.HeatMap = Options('plot', show_values=True) options.RGB = Options('style', interpolation='nearest') # Composites diff --git a/holoviews/plotting/mpl/chart.py b/holoviews/plotting/mpl/chart.py index ba21268d3c..6527786f6d 100644 --- a/holoviews/plotting/mpl/chart.py +++ b/holoviews/plotting/mpl/chart.py @@ -651,6 +651,13 @@ def _compute_styles(self, element, ranges, style): def update_handles(self, key, axis, element, ranges, style): paths = self.handles['artist'] (xs, ys), style, _ = self.get_data(element, ranges, style) + xdim, ydim = element.dimensions()[:2] + if 'factors' in ranges.get(xdim.name, {}): + factors = list(ranges[xdim.name]['factors']) + xs = [factors.index(x) for x in xs if x in factors] + if 'factors' in ranges.get(ydim.name, {}): + factors = list(ranges[ydim.name]['factors']) + ys = [factors.index(y) for y in ys if y in factors] paths.set_offsets(np.column_stack([xs, ys])) if 's' in style: sizes = style['s'] diff --git a/holoviews/plotting/mpl/heatmap.py b/holoviews/plotting/mpl/heatmap.py index cd975612eb..2918455dfc 100644 --- a/holoviews/plotting/mpl/heatmap.py +++ b/holoviews/plotting/mpl/heatmap.py @@ -8,14 +8,16 @@ from matplotlib.patches import Wedge, Circle from matplotlib.collections import LineCollection, PatchCollection -from ...core.util import dimension_sanitizer, unique_array, is_nan +from ...core.data import GridInterface +from ...core.util import dimension_sanitizer, is_nan from ...core.spaces import HoloMap +from ..mixins import HeatMapMixin from .element import ColorbarPlot -from .raster import RasterPlot +from .raster import QuadMeshPlot from .util import filter_styles -class HeatMapPlot(RasterPlot): +class HeatMapPlot(HeatMapMixin, QuadMeshPlot): clipping_colors = param.Dict(default={'NaN': 'white'}, doc=""" Dictionary to specify colors for clipped values, allows @@ -59,12 +61,6 @@ class HeatMapPlot(RasterPlot): Ticks along y-axis/annulars specified as an integer, explicit list of ticks or function. If `None`, no ticks are shown.""") - - def get_extents(self, element, ranges, range_type='combined'): - ys, xs = element.gridded.interface.shape(element.gridded, gridded=True) - return (0, 0, xs, ys) - - @classmethod def is_radial(cls, heatmap): heatmap = heatmap.last if isinstance(heatmap, HoloMap) else heatmap @@ -73,6 +69,8 @@ def is_radial(cls, heatmap): and not (opts.get('radial') == False)) or opts.get('radial', False)) def _annotate_plot(self, ax, annotations): + for a in self.handles.get('annotations', {}).values(): + a.remove() handles = {} for plot_coord, text in annotations.items(): handles[plot_coord] = ax.annotate(text, xy=plot_coord, @@ -82,20 +80,11 @@ def _annotate_plot(self, ax, annotations): return handles - def _annotate_values(self, element): + def _annotate_values(self, element, xvals, yvals): val_dim = element.vdims[0] - vals = element.dimension_values(2, flat=False) - d1uniq, d2uniq = [element.dimension_values(i, False) for i in range(2)] - if self.invert_axes: - d1uniq, d2uniq = d2uniq, d1uniq - else: - vals = vals.T - if self.invert_xaxis: vals = vals[::-1] - if self.invert_yaxis: vals = vals[:, ::-1] - vals = vals.flatten() - num_x, num_y = len(d1uniq), len(d2uniq) - xpos = np.linspace(0.5, num_x-0.5, num_x) - ypos = np.linspace(0.5, num_y-0.5, num_y) + vals = element.dimension_values(val_dim).flatten() + xpos = xvals[:-1] + np.diff(xvals)/2. + ypos = yvals[:-1] + np.diff(yvals)/2. plot_coords = product(xpos, ypos) annotations = {} for plot_coord, v in zip(plot_coords, vals): @@ -104,108 +93,100 @@ def _annotate_values(self, element): return annotations - def _compute_ticks(self, element, ranges): - xdim, ydim = element.dimensions()[:2] - agg = element.gridded - dim1_keys, dim2_keys = [unique_array(agg.dimension_values(i, False)) - for i in range(2)] + def _compute_ticks(self, element, xvals, yvals, xfactors, yfactors): + xdim, ydim = element.kdims if self.invert_axes: - dim1_keys, dim2_keys = dim2_keys, dim1_keys - num_x, num_y = len(dim1_keys), len(dim2_keys) - xpos = np.linspace(.5, num_x-0.5, num_x) - ypos = np.linspace(.5, num_y-0.5, num_y) - xlabels = [xdim.pprint_value(k) for k in dim1_keys] - ylabels = [ydim.pprint_value(k) for k in dim2_keys] - return list(zip(xpos, xlabels)), list(zip(ypos, ylabels)) - - - def _draw_markers(self, ax, element, marks, axis='x'): - if marks is None: + xdim, ydim = ydim, xdim + + opts = self.lookup_options(element, 'plot').options + + xticks = opts.get('xticks') + if xticks is None: + xpos = xvals[:-1] + np.diff(xvals)/2. + if not xfactors: + xfactors = element.gridded.dimension_values(xdim, False) + xlabels = [xdim.pprint_value(k) for k in xfactors] + xticks = list(zip(xpos, xlabels)) + + yticks = opts.get('yticks') + if yticks is None: + ypos = yvals[:-1] + np.diff(yvals)/2. + if not yfactors: + yfactors = element.gridded.dimension_values(ydim, False) + ylabels = [ydim.pprint_value(k) for k in yfactors] + yticks = list(zip(ypos, ylabels)) + return xticks, yticks + + + def _draw_markers(self, ax, element, marks, values, factors, axis='x'): + if marks is None or self.radial: return - style = self.style[self.cyclic_index] - mark_opts = {k[7:]: v for k, v in style.items() if axis+'mark' in k} - mark_opts = {k[4:] if 'edge' in k else k: v for k, v in mark_opts.items()} - categories = list(element.dimension_values(0 if axis == 'x' else 1, - expanded=False)) - - if callable(marks): - positions = [i for i, x in enumerate(categories) if marks(x)] - elif isinstance(marks, int): - nth_mark = np.ceil(len(categories) / marks).astype(int) - positions = np.arange(len(categories)+1)[::nth_mark] - elif isinstance(marks, tuple): - positions = [categories.index(m) for m in marks if m in categories] - else: - positions = [m for m in marks if isinstance(m, int) and m < len(categories)] - - prev_markers = self.handles.get(axis+'marks', []) - new_markers = [] - for p in positions: - if axis == 'x': - line = ax.axvline(p, **mark_opts) - else: - line = ax.axhline(p, **mark_opts) - new_markers.append(line) - for pm in prev_markers: - pm.remove() - self.handles[axis+'marks'] = new_markers + self.warning('Only radial HeatMaps supports marks, to make the' + 'HeatMap quads more distinguishable set linewidths' + 'to a non-zero value.') def init_artists(self, ax, plot_args, plot_kwargs): - ax.set_aspect(plot_kwargs.pop('aspect', 1)) - - handles = {} + xfactors = plot_kwargs.pop('xfactors') + yfactors = plot_kwargs.pop('yfactors') + annotations = plot_kwargs.pop('annotations', None) prefixes = ['annular', 'xmarks', 'ymarks'] plot_kwargs = {k: v for k, v in plot_kwargs.items() if not any(p in k for p in prefixes)} - annotations = plot_kwargs.pop('annotations', None) - handles['artist'] = ax.imshow(*plot_args, **plot_kwargs) + artist = ax.pcolormesh(*plot_args, **plot_kwargs) + if self.show_values and annotations: - handles['annotations'] = self._annotate_plot(ax, annotations) - self._draw_markers(ax, self.current_frame, self.xmarks, axis='x') - self._draw_markers(ax, self.current_frame, self.ymarks, axis='y') - return handles + self.handles['annotations'] = self._annotate_plot(ax, annotations) + self._draw_markers(ax, self.current_frame, self.xmarks, + plot_args[0], xfactors, axis='x') + self._draw_markers(ax, self.current_frame, self.ymarks, + plot_args[1], yfactors, axis='y') + return {'artist': artist} def get_data(self, element, ranges, style): - xticks, yticks = self._compute_ticks(element, ranges) + xdim, ydim = element.kdims + aggregate = element.gridded - data = np.flipud(element.gridded.dimension_values(2, flat=False)) + if not element._unique: + self.warning('HeatMap element index is not unique, ensure you ' + 'aggregate the data before displaying it, e.g. ' + 'using heatmap.aggregate(function=np.mean). ' + 'Duplicate index values have been dropped.') + + data = aggregate.dimension_values(2, flat=False) data = np.ma.array(data, mask=np.logical_not(np.isfinite(data))) - if self.invert_axes: data = data.T[::-1, ::-1] + if self.invert_axes: + xdim, ydim = ydim, xdim + data = data.T[::-1, ::-1] - shape = data.shape - style['aspect'] = shape[0]/shape[1] - style['extent'] = (0, shape[1], 0, shape[0]) - if self.show_values: - style['annotations'] = self._annotate_values(element.gridded) - style['origin'] = 'upper' - vdim = element.vdims[0] - self._norm_kwargs(element, ranges, style, vdim) - return [data], style, {'xticks': xticks, 'yticks': yticks} + xtype = aggregate.interface.dtype(aggregate, xdim) + if xtype.kind in 'SUO': + xvals = np.arange(data.shape[1]+1)-0.5 + else: + xvals = aggregate.dimension_values(xdim, expanded=False) + xvals = GridInterface._infer_interval_breaks(xvals) + + ytype = aggregate.interface.dtype(aggregate, ydim) + if ytype.kind in 'SUO': + yvals = np.arange(data.shape[0]+1)-0.5 + else: + yvals = aggregate.dimension_values(ydim, expanded=False) + yvals = GridInterface._infer_interval_breaks(yvals) + xfactors = list(ranges.get(xdim.name, {}).get('factors', [])) + yfactors = list(ranges.get(ydim.name, {}).get('factors', [])) + xticks, yticks = self._compute_ticks(element, xvals, yvals, xfactors, yfactors) - def update_handles(self, key, axis, element, ranges, style): - im = self.handles['artist'] - data, style, axis_kwargs = self.get_data(element, ranges, style) - im.set_data(data[0]) - im.set_extent(style['extent']) - im.set_clim((style['vmin'], style['vmax'])) - if 'norm' in style: - im.norm = style['norm'] + style['xfactors'] = xfactors + style['yfactors'] = yfactors if self.show_values: - annotations = self.handles['annotations'] - for annotation in annotations.values(): - try: - annotation.remove() - except: - pass - annotations = self._annotate_plot(axis, style['annotations']) - self.handles['annotations'] = annotations - self._draw_markers(axis, element, self.xmarks, axis='x') - self._draw_markers(axis, element, self.ymarks, axis='y') - return axis_kwargs + style['annotations'] = self._annotate_values(element.gridded, xvals, yvals) + vdim = element.vdims[0] + self._norm_kwargs(element, ranges, style, vdim) + return (xvals, yvals, data), style, {'xticks': xticks, 'yticks': yticks} + class RadialHeatMapPlot(ColorbarPlot): @@ -402,7 +383,7 @@ def init_artists(self, ax, plot_args, plot_kwargs): if paths: groups = [g for g in self._style_groups if g != 'xmarks'] xmark_opts = filter_styles(plot_kwargs, 'xmarks', groups, color_opts) - xmark_opts.pop('interpolation', None) + xmark_opts.pop('edgecolors', None) xseparators = LineCollection(paths, **xmark_opts) ax.add_collection(xseparators) artists['xseparator'] = xseparators @@ -411,7 +392,7 @@ def init_artists(self, ax, plot_args, plot_kwargs): if paths: groups = [g for g in self._style_groups if g != 'ymarks'] ymark_opts = filter_styles(plot_kwargs, 'ymarks', groups, color_opts) - ymark_opts.pop('interpolation', None) + ymark_opts.pop('edgecolors', None) yseparators = PatchCollection(paths, facecolor='none', transform=ax.transAxes, **ymark_opts) ax.add_collection(yseparators) diff --git a/holoviews/plotting/plotly/raster.py b/holoviews/plotting/plotly/raster.py index 5300129cce..afbb8b7511 100644 --- a/holoviews/plotting/plotly/raster.py +++ b/holoviews/plotting/plotly/raster.py @@ -4,6 +4,7 @@ from ...core.options import SkipRendering from ...element import Image, Raster +from ..mixins import HeatMapMixin from .element import ColorbarPlot @@ -38,28 +39,59 @@ def get_data(self, element, ranges, style): return [dict(x0=x0, y0=y0, dx=dx, dy=dy, z=array)] -class HeatMapPlot(RasterPlot): - - def get_extents(self, element, ranges, range_type='combined'): - return (np.NaN,)*4 +class HeatMapPlot(HeatMapMixin, RasterPlot): def init_layout(self, key, element, ranges): layout = super(HeatMapPlot, self).init_layout(key, element, ranges) gridded = element.gridded - xlabels, ylabels = (gridded.dimension_values(i, False) for i in range(2)) - xvals = np.arange(len(xlabels)) - yvals = np.arange(len(ylabels)) - layout['xaxis']['tickvals'] = xvals - layout['xaxis']['ticktext'] = xlabels - layout['yaxis']['tickvals'] = yvals - layout['yaxis']['ticktext'] = ylabels + xdim, ydim = gridded.dimensions()[:2] + + if self.invert_axes: + xaxis, yaxis = ('yaxis', 'xaxis') + else: + xaxis, yaxis = ('xaxis', 'yaxis') + + shape = gridded.interface.shape(gridded, gridded=True) + + xtype = gridded.interface.dtype(gridded, xdim) + if xtype.kind in 'SUO': + layout[xaxis]['tickvals'] = np.arange(shape[1]) + layout[xaxis]['ticktext'] = gridded.dimension_values(0, expanded=False) + + ytype = gridded.interface.dtype(gridded, ydim) + if ytype.kind in 'SUO': + layout[yaxis]['tickvals'] = np.arange(shape[0]) + layout[yaxis]['ticktext'] = gridded.dimension_values(1, expanded=False) return layout def get_data(self, element, ranges, style): + if not element._unique: + self.warning('HeatMap element index is not unique, ensure you ' + 'aggregate the data before displaying it, e.g. ' + 'using heatmap.aggregate(function=np.mean). ' + 'Duplicate index values have been dropped.') + gridded = element.gridded - yn, xn = gridded.interface.shape(gridded, True) - return [dict(x=np.arange(xn), y=np.arange(yn), - z=gridded.dimension_values(2, flat=False))] + xdim, ydim = gridded.dimensions()[:2] + data = gridded.dimension_values(2, flat=False) + + xtype = gridded.interface.dtype(gridded, xdim) + if xtype.kind in 'SUO': + xvals = np.arange(data.shape[1]+1)-0.5 + else: + xvals = gridded.interface.coords(gridded, xdim, edges=True, ordered=True) + + ytype = gridded.interface.dtype(gridded, ydim) + if ytype.kind in 'SUO': + yvals = np.arange(data.shape[0]+1)-0.5 + else: + yvals = gridded.interface.coords(gridded, ydim, edges=True, ordered=True) + + if self.invert_axes: + xvals, yvals = yvals, xvals + data = data.T + + return [dict(x=xvals, y=yvals, z=data)] class QuadMeshPlot(RasterPlot): diff --git a/holoviews/tests/element/testelementconstructors.py b/holoviews/tests/element/testelementconstructors.py index e27bff85e4..8c5b743598 100644 --- a/holoviews/tests/element/testelementconstructors.py +++ b/holoviews/tests/element/testelementconstructors.py @@ -80,13 +80,13 @@ def test_hist_curve_int_edges_construct(self): def test_heatmap_construct(self): hmap = HeatMap([('A', 'a', 1), ('B', 'b', 2)]) dataset = Dataset({'x': ['A', 'B'], 'y': ['a', 'b'], 'z': [[1, np.NaN], [np.NaN, 2]]}, - kdims=['x', 'y'], vdims=['z']) + kdims=['x', 'y'], vdims=['z'], label='unique') self.assertEqual(hmap.gridded, dataset) def test_heatmap_construct_unsorted(self): hmap = HeatMap([('B', 'b', 2), ('A', 'a', 1)]) dataset = Dataset({'x': ['B', 'A'], 'y': ['b', 'a'], 'z': [[2, np.NaN], [np.NaN, 1]]}, - kdims=['x', 'y'], vdims=['z']) + kdims=['x', 'y'], vdims=['z'], label='unique') self.assertEqual(hmap.gridded, dataset) def test_heatmap_construct_partial_sorted(self): @@ -94,7 +94,7 @@ def test_heatmap_construct_partial_sorted(self): hmap = HeatMap(data) dataset = Dataset({'x': ['A', 'B', 'C'], 'y': ['c', 'b', 'a'], 'z': [[0, 2, np.NaN], [np.NaN, 0, 0], [0, np.NaN, 2]]}, - kdims=['x', 'y'], vdims=['z']) + kdims=['x', 'y'], vdims=['z'], label='unique') self.assertEqual(hmap.gridded, dataset) def test_heatmap_construct_and_sort(self): @@ -102,7 +102,7 @@ def test_heatmap_construct_and_sort(self): hmap = HeatMap(data).sort() dataset = Dataset({'x': ['A', 'B', 'C'], 'y': ['a', 'b', 'c'], 'z': [[np.NaN, 0, 0], [0, np.NaN, 2], [0, 2, np.NaN]]}, - kdims=['x', 'y'], vdims=['z']) + kdims=['x', 'y'], vdims=['z'], label='unique') self.assertEqual(hmap.gridded, dataset) diff --git a/holoviews/tests/plotting/bokeh/testheatmapplot.py b/holoviews/tests/plotting/bokeh/testheatmapplot.py index 23dd35d888..2025f05192 100644 --- a/holoviews/tests/plotting/bokeh/testheatmapplot.py +++ b/holoviews/tests/plotting/bokeh/testheatmapplot.py @@ -3,7 +3,7 @@ from holoviews.element import HeatMap, Points, Image try: - from bokeh.models import FactorRange, HoverTool + from bokeh.models import FactorRange, HoverTool, Range1d except: pass @@ -39,109 +39,72 @@ def test_heatmap_colormapping(self): self._test_colormapping(hm, 2) def test_heatmap_categorical_axes_string_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]) + hmap = HeatMap([('A', 1, 1), ('B', 2, 2)]) plot = bokeh_renderer.get_plot(hmap) x_range = plot.handles['x_range'] y_range = plot.handles['y_range'] self.assertIsInstance(x_range, FactorRange) self.assertEqual(x_range.factors, ['A', 'B']) - self.assertIsInstance(y_range, FactorRange) - self.assertEqual(y_range.factors, ['1', '2']) + self.assertIsInstance(y_range, Range1d) + self.assertEqual(y_range.start, 0.5) + self.assertEqual(y_range.end, 2.5) def test_heatmap_categorical_axes_string_int_invert_xyaxis(self): opts = dict(invert_xaxis=True, invert_yaxis=True) - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).opts(plot=opts) + hmap = HeatMap([('A', 1, 1), ('B', 2, 2)]).opts(**opts) plot = bokeh_renderer.get_plot(hmap) x_range = plot.handles['x_range'] y_range = plot.handles['y_range'] self.assertIsInstance(x_range, FactorRange) self.assertEqual(x_range.factors, ['A', 'B'][::-1]) - self.assertIsInstance(y_range, FactorRange) - self.assertEqual(y_range.factors, ['1', '2'][::-1]) + self.assertIsInstance(y_range, Range1d) + self.assertEqual(y_range.start, 2.5) + self.assertEqual(y_range.end, 0.5) def test_heatmap_categorical_axes_string_int_inverted(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).opts(plot=dict(invert_axes=True)) + hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).opts(invert_axes=True) plot = bokeh_renderer.get_plot(hmap) x_range = plot.handles['x_range'] y_range = plot.handles['y_range'] - self.assertIsInstance(x_range, FactorRange) - self.assertEqual(x_range.factors, ['1', '2']) + self.assertIsInstance(x_range, Range1d) + self.assertEqual(x_range.start, 0.5) + self.assertEqual(x_range.end, 2.5) self.assertIsInstance(y_range, FactorRange) self.assertEqual(y_range.factors, ['A', 'B']) def test_heatmap_points_categorical_axes_string_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]) + hmap = HeatMap([('A', 1, 1), ('B', 2, 2)]) points = Points([('A', 2), ('B', 1), ('C', 3)]) plot = bokeh_renderer.get_plot(hmap*points) x_range = plot.handles['x_range'] y_range = plot.handles['y_range'] self.assertIsInstance(x_range, FactorRange) self.assertEqual(x_range.factors, ['A', 'B', 'C']) - self.assertIsInstance(y_range, FactorRange) - self.assertEqual(y_range.factors, ['1', '2', '3']) + self.assertIsInstance(y_range, Range1d) + self.assertEqual(y_range.start, 0.5) + self.assertEqual(y_range.end, 3) def test_heatmap_points_categorical_axes_string_int_inverted(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).opts(plot=dict(invert_axes=True)) + hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).opts(invert_axes=True) points = Points([('A', 2), ('B', 1), ('C', 3)]) plot = bokeh_renderer.get_plot(hmap*points) x_range = plot.handles['x_range'] y_range = plot.handles['y_range'] - self.assertIsInstance(x_range, FactorRange) - self.assertEqual(x_range.factors, ['1', '2', '3']) + self.assertIsInstance(x_range, Range1d) + self.assertEqual(x_range.start, 0.5) + self.assertEqual(x_range.end, 3) self.assertIsInstance(y_range, FactorRange) self.assertEqual(y_range.factors, ['A', 'B', 'C']) def test_heatmap_invert_axes(self): arr = np.array([[0, 1, 2], [3, 4, 5]]) - hm = HeatMap(Image(arr)).opts(plot=dict(invert_axes=True)) + hm = HeatMap(Image(arr)).opts(invert_axes=True) plot = bokeh_renderer.get_plot(hm) xdim, ydim = hm.kdims source = plot.handles['source'] self.assertEqual(source.data['zvalues'], hm.dimension_values(2, flat=False).T.flatten()) - self.assertEqual(source.data['x'], [xdim.pprint_value(v) for v in hm.dimension_values(0)]) - self.assertEqual(source.data['y'], [ydim.pprint_value(v) for v in hm.dimension_values(1)]) - - def test_heatmap_xmarks_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=2) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'height') - - def test_heatmap_xmarks_tuple(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=('A', 'B')) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'height') - - def test_heatmap_xmarks_list(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=[0, 1]) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'height') - - def test_heatmap_ymarks_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=2) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (2, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'width') - - def test_heatmap_ymarks_tuple(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=('A', 'B')) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (0, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'width') - - def test_heatmap_ymarks_list(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=[0, 1]) - plot = bokeh_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (2, 1)): - self.assertEqual(marker.location, pos) - self.assertEqual(marker.dimension, 'width') + self.assertEqual(source.data['x'], hm.dimension_values(1)) + self.assertEqual(source.data['y'], hm.dimension_values(0)) def test_heatmap_dilate(self): hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(dilate=True) diff --git a/holoviews/tests/plotting/matplotlib/testheatmapplot.py b/holoviews/tests/plotting/matplotlib/testheatmapplot.py index 5c126f240d..840a801720 100644 --- a/holoviews/tests/plotting/matplotlib/testheatmapplot.py +++ b/holoviews/tests/plotting/matplotlib/testheatmapplot.py @@ -12,55 +12,18 @@ def test_heatmap_invert_axes(self): hm = HeatMap(Image(arr)).opts(plot=dict(invert_axes=True)) plot = mpl_renderer.get_plot(hm) artist = plot.handles['artist'] - self.assertEqual(artist.get_array().data, arr.T[::-1, ::-1]) - self.assertEqual(artist.get_extent(), (0, 2, 0, 3)) + self.assertEqual(artist.get_array().data, arr.T[::-1].flatten()) def test_heatmap_extents(self): hmap = HeatMap([('A', 50, 1), ('B', 2, 2), ('C', 50, 1)]) plot = mpl_renderer.get_plot(hmap) - self.assertEqual(plot.get_extents(hmap, {}), (0, 0, 3, 2)) - - def test_heatmap_xmarks_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=2) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.get_xdata(), [pos, pos]) - - def test_heatmap_xmarks_tuple(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=('A', 'B')) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.get_xdata(), [pos, pos]) - - def test_heatmap_xmarks_list(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(xmarks=[0, 1]) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['xmarks'], (0, 1)): - self.assertEqual(marker.get_xdata(), [pos, pos]) - - def test_heatmap_ymarks_int(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=2) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (0, 1)): - self.assertEqual(marker.get_ydata(), [pos, pos]) - - def test_heatmap_ymarks_tuple(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=('A', 'B')) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (0, 1)): - self.assertEqual(marker.get_ydata(), [pos, pos]) - - def test_heatmap_ymarks_list(self): - hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(ymarks=[0, 1]) - plot = mpl_renderer.get_plot(hmap) - for marker, pos in zip(plot.handles['ymarks'], (0, 1)): - self.assertEqual(marker.get_ydata(), [pos, pos]) + self.assertEqual(plot.get_extents(hmap, {}), (-.5, -22, 2.5, 74)) def test_heatmap_invert_xaxis(self): hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(invert_xaxis=True) plot = mpl_renderer.get_plot(hmap) array = plot.handles['artist'].get_array() - expected = np.array([[np.NaN, 2.], [1., np.NaN]]) + expected = np.array([1, np.inf, np.inf, 2]) masked = np.ma.array(expected, mask=np.logical_not(np.isfinite(expected))) self.assertEqual(array, masked) @@ -68,6 +31,6 @@ def test_heatmap_invert_yaxis(self): hmap = HeatMap([('A',1, 1), ('B', 2, 2)]).options(invert_yaxis=True) plot = mpl_renderer.get_plot(hmap) array = plot.handles['artist'].get_array() - expected = np.array([[np.NaN, 2.], [1., np.NaN]]) + expected = np.array([1, np.inf, np.inf, 2]) masked = np.ma.array(expected, mask=np.logical_not(np.isfinite(expected))) self.assertEqual(array, masked)