From 432ab07e91987d03d58a0fc93b3434861d5b865a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Nov 2017 16:46:25 +0000 Subject: [PATCH 01/28] Added TriMesh element --- holoviews/element/graphs.py | 67 ++++++++++++++++++++++++++++ holoviews/plotting/bokeh/__init__.py | 6 ++- holoviews/plotting/bokeh/graphs.py | 8 ++++ holoviews/plotting/mpl/__init__.py | 1 + 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 514a3c308d..6c4a11cd54 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -392,3 +392,70 @@ class EdgePaths(Path): """ group = param.String(default='EdgePaths', constant=True) + + +class TriMesh(Graph): + """ + A TriMesh represents a mesh of triangles represented as the + simplices and nodes. The simplices represent a indices into the + nodes array. The mesh therefore follows a datastructure very + similar to a graph, with the abstract connectivity between nodes + stored on the TriMesh element itself, the node positions stored on + a Nodes element and the concrete paths making up each triangle + generated when required by accessing the edgepaths. + """ + + kdims = param.List(default=['node1', 'node2', 'node3']) + + group = param.String(default='TriMesh') + + def __init__(self, data, kdims=None, vdims=None, **params): + if isinstance(data, tuple): + data = data + (None,)* (3-len(data)) + edges, nodes, edgepaths = data + else: + edges, nodes, edgepaths = data, None, None + if nodes is None: + raise ValueError("TriMesh expects both simplices and nodes " + "to be supplied.") + + if isinstance(nodes, Nodes): + pass + elif isinstance(nodes, Points): + nodes = Nodes(Dataset(nodes).add_dimension('index', 2, np.arange(len(nodes)))) + elif not isinstance(nodes, Dataset) or nodes.ndims in [2, 3]: + try: + nodes = Nodes(nodes) + except: + points = Dataset(Points(nodes)).add_dimension('index', 2, np.arange(len(nodes))) + nodes = Nodes(points) + if not isinstance(nodes, Nodes): + raise ValueError("Nodes argument could not be interpreted, expected " + "data with two or three columns representing the " + "x/y positions and optionally the node indices.") + if edgepaths is not None and not isinstance(edgepaths, EdgePaths): + edgepaths = EdgePaths(edgepaths) + super(TriMesh, self).__init__(edges, kdims=kdims, vdims=vdims, **params) + self._nodes = nodes + self._edgepaths = edgepaths + + + @property + def edgepaths(self): + """ + Returns the EdgePaths by generating a triangle for each simplex. + """ + if self._edgepaths: + return self._edgepaths + + paths = [] + simplices = self.array([0, 1, 2]) + pts = self.nodes.array([0, 1]) + empty = np.array([np.NaN, np.NaN]) + for tri in pts[simplices]: + paths.append(np.vstack([tri[0, :], tri[1, :], empty, + tri[1, :], tri[2, :], empty, + tri[2, :], tri[0, :]])) + edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) + self._edgepaths = edgepaths + return edgepaths diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py index 6cf7aabb64..e860455194 100644 --- a/holoviews/plotting/bokeh/__init__.py +++ b/holoviews/plotting/bokeh/__init__.py @@ -13,7 +13,8 @@ Box, Bounds, Ellipse, Polygons, BoxWhisker, Arrow, ErrorBars, Text, HLine, VLine, Spline, Spikes, Table, ItemTable, Area, HSV, QuadMesh, VectorField, - Graph, Nodes, EdgePaths, Distribution, Bivariate) + Graph, Nodes, EdgePaths, Distribution, Bivariate, + TriMesh) from ...core.options import Options, Cycle, Palette from ...core.util import VersionError @@ -33,7 +34,7 @@ from .chart import (PointPlot, CurvePlot, SpreadPlot, ErrorPlot, HistogramPlot, SideHistogramPlot, BarPlot, SpikesPlot, SideSpikesPlot, AreaPlot, VectorFieldPlot, BoxWhiskerPlot) -from .graphs import GraphPlot, NodePlot +from .graphs import GraphPlot, NodePlot, TriMeshPlot from .path import PathPlot, PolygonPlot, ContourPlot from .plot import GridPlot, LayoutPlot, AdjointLayoutPlot from .raster import RasterPlot, RGBPlot, HeatMapPlot, HSVPlot, QuadMeshPlot @@ -97,6 +98,7 @@ Graph: GraphPlot, Nodes: NodePlot, EdgePaths: PathPlot, + TriMesh: TriMeshPlot, # Tabular Table: TablePlot, diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 6ed47ec595..531ec7e9c4 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -249,3 +249,11 @@ class NodePlot(PointPlot): def _hover_opts(self, element): return element.dimensions()[2:], {} + + +class TriMeshPlot(GraphPlot): + + def get_data(self, element, ranges, style): + # Ensure the edgepaths for the triangles are generated + element.edgepaths + return super(TriMeshPlot, self).get_data(element, ranges, style) diff --git a/holoviews/plotting/mpl/__init__.py b/holoviews/plotting/mpl/__init__.py index 06608eb228..2abcf966d5 100644 --- a/holoviews/plotting/mpl/__init__.py +++ b/holoviews/plotting/mpl/__init__.py @@ -148,6 +148,7 @@ def grid_selector(grid): # Graph Elements Graph: GraphPlot, + TriMesh: GraphPlot, Nodes: PointPlot, EdgePaths: PathPlot, From c1e60ad7b5772b738a761b8ed26a3a2336a1e3fd Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Nov 2017 17:13:55 +0000 Subject: [PATCH 02/28] Simplified TriMesh path generation --- holoviews/element/graphs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 6c4a11cd54..1f02fea061 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -453,9 +453,9 @@ def edgepaths(self): pts = self.nodes.array([0, 1]) empty = np.array([np.NaN, np.NaN]) for tri in pts[simplices]: - paths.append(np.vstack([tri[0, :], tri[1, :], empty, - tri[1, :], tri[2, :], empty, - tri[2, :], tri[0, :]])) + paths.append(np.vstack([tri[[0, 1], :], empty, + tri[[1, 2], :], empty, + tri[[2, 1], :]])) edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) self._edgepaths = edgepaths return edgepaths From c095010ed990fd93292eb76dc68440cb3e3f9d72 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 22 Nov 2017 17:50:55 +0000 Subject: [PATCH 03/28] Declared TriMesh.group constant --- holoviews/element/graphs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 1f02fea061..c8a9f12d1e 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -407,7 +407,7 @@ class TriMesh(Graph): kdims = param.List(default=['node1', 'node2', 'node3']) - group = param.String(default='TriMesh') + group = param.String(default='TriMesh', constant=True) def __init__(self, data, kdims=None, vdims=None, **params): if isinstance(data, tuple): From 75b36763ceddc27eb3dd5dd3e664bf7f59297982 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 23 Nov 2017 03:03:11 +0000 Subject: [PATCH 04/28] Simplified TriMesh edgepaths code --- holoviews/element/graphs.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index c8a9f12d1e..2cbb7b1d57 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -448,14 +448,9 @@ def edgepaths(self): if self._edgepaths: return self._edgepaths - paths = [] simplices = self.array([0, 1, 2]) pts = self.nodes.array([0, 1]) - empty = np.array([np.NaN, np.NaN]) - for tri in pts[simplices]: - paths.append(np.vstack([tri[[0, 1], :], empty, - tri[[1, 2], :], empty, - tri[[2, 1], :]])) + paths = [tri[[0, 1, 2, 0], :] for tri in pts[simplices]] edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) self._edgepaths = edgepaths return edgepaths From 21451e6fade0acf65c2774e84c883dcf2d636b8e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 13:27:43 +0000 Subject: [PATCH 05/28] Improvements for TriMesh --- holoviews/element/graphs.py | 44 ++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 2cbb7b1d57..7bfa126c12 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -403,15 +403,20 @@ class TriMesh(Graph): stored on the TriMesh element itself, the node positions stored on a Nodes element and the concrete paths making up each triangle generated when required by accessing the edgepaths. + + Unlike a Graph each simplex is represented as the node indices of + the three corners of each triangle. """ - kdims = param.List(default=['node1', 'node2', 'node3']) + kdims = param.List(default=['node1', 'node2', 'node3'], + bounds=(3, 3), doc=""" + Dimensions declaring the node indices of each triangle.""") group = param.String(default='TriMesh', constant=True) def __init__(self, data, kdims=None, vdims=None, **params): if isinstance(data, tuple): - data = data + (None,)* (3-len(data)) + data = data + (None,)*(3-len(data)) edges, nodes, edgepaths = data else: edges, nodes, edgepaths = data, None, None @@ -422,17 +427,21 @@ def __init__(self, data, kdims=None, vdims=None, **params): if isinstance(nodes, Nodes): pass elif isinstance(nodes, Points): + # Add index to make it a valid Nodes object nodes = Nodes(Dataset(nodes).add_dimension('index', 2, np.arange(len(nodes)))) elif not isinstance(nodes, Dataset) or nodes.ndims in [2, 3]: try: + # Try assuming data contains indices (3 columns) nodes = Nodes(nodes) except: - points = Dataset(Points(nodes)).add_dimension('index', 2, np.arange(len(nodes))) - nodes = Nodes(points) - if not isinstance(nodes, Nodes): - raise ValueError("Nodes argument could not be interpreted, expected " - "data with two or three columns representing the " - "x/y positions and optionally the node indices.") + # Try assuming data contains just coordinates (2 columns) + try: + points = Points(nodes) + nodes = Nodes(Dataset(points).add_dimension('index', 2, np.arange(len(nodes)))) + except: + raise ValueError("Nodes argument could not be interpreted, expected " + "data with two or three columns representing the " + "x/y positions and optionally the node indices.") if edgepaths is not None and not isinstance(edgepaths, EdgePaths): edgepaths = EdgePaths(edgepaths) super(TriMesh, self).__init__(edges, kdims=kdims, vdims=vdims, **params) @@ -454,3 +463,22 @@ def edgepaths(self): edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) self._edgepaths = edgepaths return edgepaths + + def select(self, selection_specs=None, selection_mode='edges', **selection): + """ + Allows selecting data by the slices, sets and scalar values + along a particular dimension. The indices should be supplied as + keywords mapping between the selected dimension and + value. Additionally selection_specs (taking the form of a list + of type.group.label strings, types or functions) may be + supplied, which will ensure the selection is only applied if the + specs match the selected object. + + Selecting by a node dimensions selects all edges and nodes that are + connected to the selected nodes. To select only edges between the + selected nodes set the selection_mode to 'nodes'. + """ + self.edgepaths + return super(TriMesh, self).select(selection_specs=None, + selection_mode='edges', + **selection) From 63067ce9e1137d934a3f43392dc35637a788b68b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 14:00:04 +0000 Subject: [PATCH 06/28] Added support for filled TriMesh simplices --- holoviews/plotting/bokeh/graphs.py | 50 ++++++++++++++++++++++-------- holoviews/plotting/mpl/graphs.py | 29 +++++++++++++++-- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 531ec7e9c4..f63b76597c 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -2,7 +2,7 @@ import numpy as np from bokeh.models import HoverTool, ColumnDataSource from bokeh.models import (StaticLayoutProvider, NodesAndLinkedEdges, - EdgesAndLinkedNodes) + EdgesAndLinkedNodes, Patches) from ...core.util import basestring, dimension_sanitizer, unique_array from ...core.options import Cycle @@ -33,10 +33,14 @@ class GraphPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): A list of plugin tools to use on the plot.""") # Map each glyph to a style group - _style_groups = {'scatter': 'node', 'multi_line': 'edge'} + _style_groups = {'scatter': 'node', 'multi_line': 'edge', 'patches': 'edge'} - style_opts = (['edge_'+p for p in line_properties] +\ - ['node_'+p for p in fill_properties+line_properties]+['node_size', 'cmap', 'edge_cmap']) + style_opts = (['edge_'+p for p in line_properties] + + ['node_'+p for p in fill_properties+line_properties] + + ['node_size', 'cmap', 'edge_cmap']) + + # Filled is only supported for subclasses + filled = False def _hover_opts(self, element): if self.inspection_policy == 'nodes': @@ -101,9 +105,10 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): mapper = self._get_colormapper(cdim, element, ranges, edge_style, factors, colors, 'edge_colormapper') transform = {'field': field, 'transform': mapper} - edge_mapping['edge_line_color'] = transform - edge_mapping['edge_nonselection_line_color'] = transform - edge_mapping['edge_selection_line_color'] = transform + color_type = 'fill_color' if self.filled else 'line_color' + edge_mapping['edge_'+color_type] = transform + edge_mapping['edge_nonselection_'+color_type] = transform + edge_mapping['edge_selection_'+color_type] = transform def get_data(self, element, ranges, style): @@ -170,8 +175,9 @@ def get_data(self, element, ranges, style): elif self.inspection_policy == 'edges': for d in element.dimensions(): path_data[dimension_sanitizer(d.name)] = element.dimension_values(d) - data = {'scatter_1': point_data, 'multi_line_1': path_data, 'layout': layout} - mapping = {'scatter_1': point_mapping, 'multi_line_1': edge_mapping} + edge_glyph = 'patches_1' if self.filled else 'multi_line_1' + data = {'scatter_1': point_data, edge_glyph: path_data, 'layout': layout} + mapping = {'scatter_1': point_mapping, edge_glyph: edge_mapping} return data, mapping, style @@ -190,11 +196,12 @@ def _init_glyphs(self, plot, element, ranges, source): style = self.style[self.cyclic_index] data, mapping, style = self.get_data(element, ranges, style) self.handles['previous_id'] = element._plot_id + edge_glyph = 'patches_1' if self.filled else 'multi_line_1' properties = {} mappings = {} for key in list(mapping): - if not any(glyph in key for glyph in ('scatter_1', 'multi_line_1')): + if not any(glyph in key for glyph in ('scatter_1', edge_glyph)): continue source = self._init_datasource(data.pop(key, {})) self.handles[key+'_source'] = source @@ -212,7 +219,7 @@ def _init_glyphs(self, plot, element, ranges, source): # Define static layout layout = StaticLayoutProvider(graph_layout=layout) node_source = self.handles['scatter_1_source'] - edge_source = self.handles['multi_line_1_source'] + edge_source = self.handles[edge_glyph+'_source'] renderer = plot.graph(node_source, edge_source, layout, **properties) # Initialize GraphRenderer @@ -233,9 +240,19 @@ def _init_glyphs(self, plot, element, ranges, source): self.handles['layout_source'] = layout self.handles['glyph_renderer'] = renderer self.handles['scatter_1_glyph_renderer'] = renderer.node_renderer - self.handles['multi_line_1_glyph_renderer'] = renderer.edge_renderer + self.handles[edge_glyph+'_glyph_renderer'] = renderer.edge_renderer self.handles['scatter_1_glyph'] = renderer.node_renderer.glyph - self.handles['multi_line_1_glyph'] = renderer.edge_renderer.glyph + if self.filled: + allowed_properties = Patches.properties() + for glyph_type in ('', 'selection_', 'nonselection_', 'hover_', 'muted_'): + glyph = getattr(renderer.edge_renderer, glyph_type+'glyph', None) + if glyph is None: + continue + props = self._process_properties(edge_glyph, properties, mappings) + filtered = self._filter_properties(props, glyph_type, allowed_properties) + patches = Patches(**dict(filtered, xs='xs', ys='ys')) + glyph = setattr(renderer.edge_renderer, glyph_type+'glyph', patches) + self.handles[edge_glyph+'_glyph'] = renderer.edge_renderer.glyph if 'hover' in self.handles: self.handles['hover'].renderers.append(renderer) @@ -253,6 +270,13 @@ def _hover_opts(self, element): class TriMeshPlot(GraphPlot): + filled = param.Boolean(default=False, doc=""" + Whether the triangles should be drawn as filled.""") + + style_opts = (['edge_'+p for p in line_properties+fill_properties] + + ['node_'+p for p in fill_properties+line_properties] + + ['node_size', 'cmap', 'edge_cmap']) + def get_data(self, element, ranges, style): # Ensure the edgepaths for the triangles are generated element.edgepaths diff --git a/holoviews/plotting/mpl/graphs.py b/holoviews/plotting/mpl/graphs.py index dedbc8f3a3..183521d034 100644 --- a/holoviews/plotting/mpl/graphs.py +++ b/holoviews/plotting/mpl/graphs.py @@ -1,7 +1,7 @@ import param import numpy as np -from matplotlib.collections import LineCollection +from matplotlib.collections import LineCollection, PolyCollection from ...core.options import Cycle from ...core.util import basestring, unique_array, search_indices @@ -26,6 +26,8 @@ class GraphPlot(ColorbarPlot): _style_groups = ['node', 'edge'] + filled = False + def _compute_styles(self, element, ranges, style): elstyle = self.lookup_options(element, 'style') color = elstyle.kwargs.get('node_color') @@ -81,6 +83,7 @@ def _compute_styles(self, element, ranges, style): style['edge_clim'] = (style.pop('edge_vmin'), style.pop('edge_vmax')) return style + def get_data(self, element, ranges, style): xidx, yidx = (1, 0) if self.invert_axes else (0, 1) pxs, pys = (element.nodes.dimension_values(i) for i in range(2)) @@ -92,6 +95,7 @@ def get_data(self, element, ranges, style): paths = [p[:, ::-1] for p in paths] return {'nodes': (pxs, pys), 'edges': paths}, style, {'dimensions': dims} + def get_extents(self, element, ranges): """ Extents are set to '' and None because x-axis is categorical and @@ -101,6 +105,7 @@ def get_extents(self, element, ranges): y0, y1 = element.nodes.range(1) return (x0, y0, x1, y1) + def init_artists(self, ax, plot_args, plot_kwargs): # Draw edges color_opts = ['c', 'cmap', 'vmin', 'vmax', 'norm'] @@ -110,7 +115,13 @@ def init_artists(self, ax, plot_args, plot_kwargs): if not any(k.startswith(p) for p in groups) and k not in color_opts} paths = plot_args['edges'] - edges = LineCollection(paths, **edge_opts) + if self.filled: + coll = PolyCollection + if 'colors' in edge_opts: + edge_opts['facecolors'] = edge_opts.pop('colors') + else: + coll = LineCollection + edges = coll(paths, **edge_opts) ax.add_collection(edges) # Draw nodes @@ -150,7 +161,10 @@ def _update_edges(self, element, data, style): if 'norm' in style: edges.norm = style['edge_norm'] elif 'edge_colors' in style: - edges.set_edgecolors(style['edge_colors']) + if self.filled: + edges.set_facecolors(style['edge_colors']) + else: + edges.set_edgecolors(style['edge_colors']) def update_handles(self, key, axis, element, ranges, style): @@ -158,3 +172,12 @@ def update_handles(self, key, axis, element, ranges, style): self._update_nodes(element, data, style) self._update_edges(element, data, style) return axis_kwargs + + + +class TriMeshPlot(GraphPlot): + + filled = param.Boolean(default=False, doc=""" + Whether the triangles should be drawn as filled.""") + + style_opts = GraphPlot.style_opts + ['edge_facecolors'] From c6e9146dfffd128d206dd2cfc7106630d0953ec2 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 15:22:46 +0000 Subject: [PATCH 07/28] Defined default TriMesh styling --- holoviews/plotting/bokeh/__init__.py | 9 +++++++++ holoviews/plotting/mpl/__init__.py | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py index e860455194..b5a8dfbd80 100644 --- a/holoviews/plotting/bokeh/__init__.py +++ b/holoviews/plotting/bokeh/__init__.py @@ -199,6 +199,15 @@ def colormap_generator(palette): edge_line_color='black', edge_line_width=2, edge_nonselection_line_color='black', edge_hover_line_color='limegreen') +options.TriMesh = Options('style', node_size=5, node_line_color='black', + node_fill_color='white', edge_line_color='black', + node_hover_fill_color='limegreen', + edge_hover_line_color='limegreen', + edge_nonselection_alpha=0.2, + edge_nonselection_line_color='black', + node_nonselection_alpha=0.2, + edge_line_width=1) +options.TriMesh = Options('plot', tools=[]) options.Nodes = Options('style', line_color='black', color=Cycle(), size=20, nonselection_fill_color=Cycle(), selection_fill_color='limegreen', diff --git a/holoviews/plotting/mpl/__init__.py b/holoviews/plotting/mpl/__init__.py index 2abcf966d5..d202c2f3da 100644 --- a/holoviews/plotting/mpl/__init__.py +++ b/holoviews/plotting/mpl/__init__.py @@ -148,7 +148,7 @@ def grid_selector(grid): # Graph Elements Graph: GraphPlot, - TriMesh: GraphPlot, + TriMesh: TriMeshPlot, Nodes: PointPlot, EdgePaths: PathPlot, @@ -263,7 +263,9 @@ def grid_selector(grid): # Graphs options.Graph = Options('style', node_edgecolors='black', node_facecolors=Cycle(), - edge_color='black', node_size=20) + edge_color='black', node_size=15) +options.TriMesh = Options('style', node_edgecolors='black', node_facecolors='white', + edge_color='black', node_size=5, edge_linewidth=1) options.Nodes = Options('style', edgecolors='black', facecolors=Cycle(), marker='o', s=20**2) options.EdgePaths = Options('style', color='black') From 3f61ccac3d2aec20f556db7fc4dd6cf966cdd4b4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 15:23:20 +0000 Subject: [PATCH 08/28] Small fix for TriMesh path computation --- holoviews/element/graphs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 7bfa126c12..2e3a80ed4a 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -457,7 +457,7 @@ def edgepaths(self): if self._edgepaths: return self._edgepaths - simplices = self.array([0, 1, 2]) + simplices = self.array([0, 1, 2]).astype(np.int32) pts = self.nodes.array([0, 1]) paths = [tri[[0, 1, 2, 0], :] for tri in pts[simplices]] edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) From bcbb7bf79e7a8941c07179b6450be5748d8c373e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 15:23:48 +0000 Subject: [PATCH 09/28] Added TriMesh reference entries --- .../reference/elements/bokeh/TriMesh.ipynb | 165 ++++++++++++++++++ .../elements/matplotlib/TriMesh.ipynb | 165 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 examples/reference/elements/bokeh/TriMesh.ipynb create mode 100644 examples/reference/elements/matplotlib/TriMesh.ipynb diff --git a/examples/reference/elements/bokeh/TriMesh.ipynb b/examples/reference/elements/bokeh/TriMesh.ipynb new file mode 100644 index 0000000000..387638fd16 --- /dev/null +++ b/examples/reference/elements/bokeh/TriMesh.ipynb @@ -0,0 +1,165 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "
\n", + "
Title
TriMesh Element
\n", + "
Dependencies
Bokeh
\n", + "
Backends
Bokeh
Matplotlib
\n", + "
\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import holoviews as hv\n", + "from scipy.spatial import Delaunay\n", + "\n", + "hv.extension('bokeh')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A ``TriMesh`` represents a mesh of triangles represented as the simplices and nodes. The simplices represent the indices into the node data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths.\n", + "\n", + "Unlike a Graph each simplex is represented as the node indices of the three corners of each triangle rather than the usual source and target node.\n", + "\n", + "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplices** and the points (or **nodes**)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "n_verts = 100\n", + "pts = np.random.randint(1, n_verts, (n_verts, 2))\n", + "tris = Delaunay(pts)\n", + "\n", + "trimesh = hv.TriMesh((tris.simplices, pts))\n", + "trimesh" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Just like the ``Graph`` element we can access the ``Nodes`` and ``EdgePaths`` via the ``.nodes`` and ``.edgepaths`` attributes respectively." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trimesh.nodes + trimesh.edgepaths" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's make a slightly more interesting example by generating a more complex geometry. Here we will compute a geometry, then apply Delaunay triangulation again and finally apply a mask to drop nodes in the center." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# First create the x and y coordinates of the points.\n", + "n_angles = 36\n", + "n_radii = 8\n", + "min_radius = 0.25\n", + "radii = np.linspace(min_radius, 0.95, n_radii)\n", + "\n", + "angles = np.linspace(0, 2*np.pi, n_angles, endpoint=False)\n", + "angles = np.repeat(angles[..., np.newaxis], n_radii, axis=1)\n", + "angles[:, 1::2] += np.pi/n_angles\n", + "\n", + "x = (radii*np.cos(angles)).flatten()\n", + "y = (radii*np.sin(angles)).flatten()\n", + "nodes = np.column_stack([x, y])\n", + "z = (np.cos(radii)*np.cos(angles*3.0)).flatten()\n", + "\n", + "# Apply Delaunay triangulation\n", + "delauney = Delaunay(np.column_stack([x, y]))\n", + "\n", + "# Mask off unwanted triangles.\n", + "xmid = x[delauney.simplices].mean(axis=1)\n", + "ymid = y[delauney.simplices].mean(axis=1)\n", + "mask = np.where(xmid*xmid + ymid*ymid < min_radius*min_radius, 1, 0)\n", + "simplices = delauney.simplices[np.logical_not(mask)]\n", + "z = z[simplices].mean(axis=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again we can simply supply the simplices and nodes to the ``TriMesh``." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.TriMesh((simplices, nodes))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also do something more interesting, e.g. adding a value dimension to the simplices, which we can color each triangle by." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%opts TriMesh [filled=True edge_color_index='z' width=400 height=400 tools=['hover'] inspection_policy='edges'] (cmap='viridis')\n", + "hv.TriMesh((np.column_stack([simplices, z]), nodes), vdims='z')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/reference/elements/matplotlib/TriMesh.ipynb b/examples/reference/elements/matplotlib/TriMesh.ipynb new file mode 100644 index 0000000000..189a0aa8d5 --- /dev/null +++ b/examples/reference/elements/matplotlib/TriMesh.ipynb @@ -0,0 +1,165 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "
\n", + "
Title
TriMesh Element
\n", + "
Dependencies
Bokeh
\n", + "
Backends
Matplotlib
Bokeh
\n", + "
\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import holoviews as hv\n", + "from scipy.spatial import Delaunay\n", + "\n", + "hv.extension('matplotlib')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A ``TriMesh`` represents a mesh of triangles represented as the simplices and nodes. The simplices represent the indices into the node data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths.\n", + "\n", + "Unlike a Graph each simplex is represented as the node indices of the three corners of each triangle rather than the usual source and target node.\n", + "\n", + "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplices** and the points (or **nodes**)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "n_verts = 100\n", + "pts = np.random.randint(1, n_verts, (n_verts, 2))\n", + "tris = Delaunay(pts)\n", + "\n", + "trimesh = hv.TriMesh((tris.simplices, pts))\n", + "trimesh" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Just like the ``Graph`` element we can access the ``Nodes`` and ``EdgePaths`` via the ``.nodes`` and ``.edgepaths`` attributes respectively." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trimesh.nodes + trimesh.edgepaths" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now let's make a slightly more interesting example by generating a more complex geometry. Here we will compute a geometry, then apply Delaunay triangulation again and finally apply a mask to drop nodes in the center." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# First create the x and y coordinates of the points.\n", + "n_angles = 36\n", + "n_radii = 8\n", + "min_radius = 0.25\n", + "radii = np.linspace(min_radius, 0.95, n_radii)\n", + "\n", + "angles = np.linspace(0, 2*np.pi, n_angles, endpoint=False)\n", + "angles = np.repeat(angles[..., np.newaxis], n_radii, axis=1)\n", + "angles[:, 1::2] += np.pi/n_angles\n", + "\n", + "x = (radii*np.cos(angles)).flatten()\n", + "y = (radii*np.sin(angles)).flatten()\n", + "nodes = np.column_stack([x, y])\n", + "z = (np.cos(radii)*np.cos(angles*3.0)).flatten()\n", + "\n", + "# Apply Delaunay triangulation\n", + "delauney = Delaunay(np.column_stack([x, y]))\n", + "\n", + "# Mask off unwanted triangles.\n", + "xmid = x[delauney.simplices].mean(axis=1)\n", + "ymid = y[delauney.simplices].mean(axis=1)\n", + "mask = np.where(xmid*xmid + ymid*ymid < min_radius*min_radius, 1, 0)\n", + "simplices = delauney.simplices[np.logical_not(mask)]\n", + "z = z[simplices].mean(axis=1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again we can simply supply the simplices and nodes to the ``TriMesh``." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.TriMesh((simplices, nodes))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also do something more interesting, e.g. adding a value dimension to the simplices, which we can color each triangle by." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%opts TriMesh [filled=True edge_color_index='z' fig_size=200] (cmap='viridis')\n", + "hv.TriMesh((np.column_stack([simplices, z]), nodes), vdims='z')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From bdbb3cca3bdb83c099cd8b20169d7c2206797a4e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 24 Nov 2017 16:42:44 +0000 Subject: [PATCH 10/28] Added tests and small fixes for TriMesh element --- holoviews/element/graphs.py | 10 ++-- holoviews/plotting/bokeh/graphs.py | 10 +++- tests/testbokehgraphs.py | 75 ++++++++++++++++++++++++++++-- tests/testgraphelement.py | 38 ++++++++++++++- tests/testmplgraphs.py | 71 +++++++++++++++++++++++++++- 5 files changed, 188 insertions(+), 16 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 2e3a80ed4a..93bf84e183 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -437,7 +437,7 @@ def __init__(self, data, kdims=None, vdims=None, **params): # Try assuming data contains just coordinates (2 columns) try: points = Points(nodes) - nodes = Nodes(Dataset(points).add_dimension('index', 2, np.arange(len(nodes)))) + nodes = Nodes(Dataset(points).add_dimension('index', 2, np.arange(len(points)))) except: raise ValueError("Nodes argument could not be interpreted, expected " "data with two or three columns representing the " @@ -464,7 +464,7 @@ def edgepaths(self): self._edgepaths = edgepaths return edgepaths - def select(self, selection_specs=None, selection_mode='edges', **selection): + def select(self, selection_specs=None, **selection): """ Allows selecting data by the slices, sets and scalar values along a particular dimension. The indices should be supplied as @@ -473,12 +473,8 @@ def select(self, selection_specs=None, selection_mode='edges', **selection): of type.group.label strings, types or functions) may be supplied, which will ensure the selection is only applied if the specs match the selected object. - - Selecting by a node dimensions selects all edges and nodes that are - connected to the selected nodes. To select only edges between the - selected nodes set the selection_mode to 'nodes'. """ self.edgepaths return super(TriMesh, self).select(selection_specs=None, - selection_mode='edges', + selection_mode='nodes', **selection) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index f63b76597c..40c166df22 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -42,6 +42,9 @@ class GraphPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): # Filled is only supported for subclasses filled = False + # Declares which columns in the data refer to node indices + _node_indices = [0, 1] + def _hover_opts(self, element): if self.inspection_policy == 'nodes': dims = element.nodes.dimensions() @@ -77,7 +80,7 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): idx = element.get_dimension_index(cdim) field = dimension_sanitizer(cdim.name) cvals = element.dimension_values(cdim) - if idx in [0, 1]: + if idx in self._node_indices: factors = element.nodes.dimension_values(2, expanded=False) elif idx == 2 and cvals.dtype.kind in 'if': factors = None @@ -86,7 +89,7 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): default_cmap = 'viridis' if factors is None else 'tab20' cmap = style.get('edge_cmap', style.get('cmap', default_cmap)) - if factors is None or (factors.dtype.kind == 'f' and idx not in [0, 1]): + if factors is None or (factors.dtype.kind in 'if' and idx not in self._node_indices): colors, factors = None, None else: if factors.dtype.kind == 'f': @@ -277,6 +280,9 @@ class TriMeshPlot(GraphPlot): ['node_'+p for p in fill_properties+line_properties] + ['node_size', 'cmap', 'edge_cmap']) + # Declares that three columns in TriMesh refer to edges + _node_indices = [0, 1, 2] + def get_data(self, element, ranges, style): # Ensure the edgepaths for the triangles are generated element.edgepaths diff --git a/tests/testbokehgraphs.py b/tests/testbokehgraphs.py index b596e82e22..03a2718882 100644 --- a/tests/testbokehgraphs.py +++ b/tests/testbokehgraphs.py @@ -5,21 +5,20 @@ import numpy as np from holoviews.core.data import Dataset from holoviews.core.options import Store -from holoviews.element import Graph, circular_layout +from holoviews.element import Graph, TriMesh, circular_layout from holoviews.element.comparison import ComparisonTestCase from holoviews.plotting import comms try: from holoviews.plotting.bokeh.util import bokeh_version bokeh_renderer = Store.renderers['bokeh'] - from bokeh.models import (NodesAndLinkedEdges, EdgesAndLinkedNodes) + from bokeh.models import (NodesAndLinkedEdges, EdgesAndLinkedNodes, Patches) from bokeh.models.mappers import CategoricalColorMapper, LinearColorMapper except : bokeh_renderer = None class BokehGraphPlotTests(ComparisonTestCase): - def setUp(self): if not bokeh_renderer: @@ -156,7 +155,7 @@ def test_graph_edges_categorical_colormapped(self): self.assertEqual(cmapper.factors, factors) self.assertEqual(edge_source.data['start_str'], factors) self.assertEqual(glyph.line_color, {'field': 'start_str', 'transform': cmapper}) - + def test_graph_nodes_numerically_colormapped(self): g = self.graph4.opts(plot=dict(edge_color_index='Weight'), style=dict(edge_cmap=['#FFFFFF', '#000000'])) @@ -169,3 +168,71 @@ def test_graph_nodes_numerically_colormapped(self): self.assertEqual(cmapper.high, self.weights.max()) self.assertEqual(edge_source.data['Weight'], self.node_info2['Weight']) self.assertEqual(glyph.line_color, {'field': 'Weight', 'transform': cmapper}) + + +class TestBokehTriMeshPlots(ComparisonTestCase): + + def setUp(self): + if not bokeh_renderer: + raise SkipTest("Bokeh required to test plot instantiation") + self.previous_backend = Store.current_backend + Store.current_backend = 'bokeh' + self.default_comm = bokeh_renderer.comms['default'] + + self.nodes = [(0, 0, 0), (0.5, 1, 1), (1., 0, 2), (1.5, 1, 3)] + self.simplices = [(0, 1, 2, 0), (1, 2, 3, 1)] + self.trimesh = TriMesh((self.simplices, self.nodes)) + self.trimesh_weighted = TriMesh((self.simplices, self.nodes), vdims='weight') + + def tearDown(self): + Store.current_backend = self.previous_backend + bokeh_renderer.comms['default'] = self.default_comm + + def test_plot_simple_trimesh(self): + plot = bokeh_renderer.get_plot(self.trimesh) + node_source = plot.handles['scatter_1_source'] + edge_source = plot.handles['multi_line_1_source'] + layout_source = plot.handles['layout_source'] + self.assertEqual(node_source.data['index'], np.arange(4)) + self.assertEqual(edge_source.data['start'], np.arange(2)) + self.assertEqual(edge_source.data['end'], np.arange(1, 3)) + layout = {str(int(z)): (x, y) for x, y, z in self.trimesh.nodes.array()} + self.assertEqual(layout_source.graph_layout, layout) + + def test_plot_simple_trimesh_filled(self): + plot = bokeh_renderer.get_plot(self.trimesh.opts(plot=dict(filled=True))) + node_source = plot.handles['scatter_1_source'] + edge_source = plot.handles['patches_1_source'] + layout_source = plot.handles['layout_source'] + self.assertIsInstance(plot.handles['patches_1_glyph'], Patches) + self.assertEqual(node_source.data['index'], np.arange(4)) + self.assertEqual(edge_source.data['start'], np.arange(2)) + self.assertEqual(edge_source.data['end'], np.arange(1, 3)) + layout = {str(int(z)): (x, y) for x, y, z in self.trimesh.nodes.array()} + self.assertEqual(layout_source.graph_layout, layout) + + def test_graph_edges_categorical_colormapped(self): + g = self.trimesh.opts(plot=dict(edge_color_index='node1'), + style=dict(edge_cmap=['#FFFFFF', '#000000'])) + plot = bokeh_renderer.get_plot(g) + cmapper = plot.handles['edge_colormapper'] + edge_source = plot.handles['multi_line_1_source'] + glyph = plot.handles['multi_line_1_glyph'] + self.assertIsInstance(cmapper, CategoricalColorMapper) + factors = ['0', '1', '2', '3'] + self.assertEqual(cmapper.factors, factors) + self.assertEqual(edge_source.data['node1_str'], ['0', '1']) + self.assertEqual(glyph.line_color, {'field': 'node1_str', 'transform': cmapper}) + + def test_graph_nodes_numerically_colormapped(self): + g = self.trimesh_weighted.opts(plot=dict(edge_color_index='weight'), + style=dict(edge_cmap=['#FFFFFF', '#000000'])) + plot = bokeh_renderer.get_plot(g) + cmapper = plot.handles['edge_colormapper'] + edge_source = plot.handles['multi_line_1_source'] + glyph = plot.handles['multi_line_1_glyph'] + self.assertIsInstance(cmapper, LinearColorMapper) + self.assertEqual(cmapper.low, 0) + self.assertEqual(cmapper.high, 1) + self.assertEqual(edge_source.data['weight'], np.array([0, 1])) + self.assertEqual(glyph.line_color, {'field': 'weight', 'transform': cmapper}) diff --git a/tests/testgraphelement.py b/tests/testgraphelement.py index 7547b9f123..da4b48f74b 100644 --- a/tests/testgraphelement.py +++ b/tests/testgraphelement.py @@ -3,8 +3,10 @@ """ import numpy as np from holoviews.core.data import Dataset +from holoviews.element.chart import Points from holoviews.element.graphs import ( - Graph, Nodes, circular_layout, connect_edges, connect_edges_pd) + Graph, Nodes, EdgePaths, TriMesh, circular_layout, connect_edges, + connect_edges_pd) from holoviews.element.comparison import ComparisonTestCase @@ -123,3 +125,37 @@ def test_graph_redim_nodes(self): redimmed = graph.redim(x='x2', y='y2') self.assertEqual(redimmed.nodes, graph.nodes.redim(x='x2', y='y2')) self.assertEqual(redimmed.edgepaths, graph.edgepaths.redim(x='x2', y='y2')) + + +class TriMeshTests(ComparisonTestCase): + + def setUp(self): + self.nodes = [(0, 0, 0), (0.5, 1, 1), (1., 0, 2), (1.5, 1, 3)] + self.simplices = [(0, 1, 2), (1, 2, 3)] + + def test_trimesh_constructor(self): + trimesh = TriMesh((self.simplices, self.nodes)) + self.assertEqual(trimesh.array(), np.array(self.simplices)) + self.assertEqual(trimesh.nodes.array(), np.array(self.nodes)) + + def test_trimesh_constructor_tuple_nodes(self): + nodes = tuple(zip(*self.nodes))[:2] + trimesh = TriMesh((self.simplices, nodes)) + self.assertEqual(trimesh.array(), np.array(self.simplices)) + self.assertEqual(trimesh.nodes.array(), np.array(self.nodes)) + + def test_trimesh_constructor_point_nodes(self): + trimesh = TriMesh((self.simplices, Points([n[:2] for n in self.nodes]))) + self.assertEqual(trimesh.array(), np.array(self.simplices)) + self.assertEqual(trimesh.nodes.array(), np.array(self.nodes)) + + def test_trimesh_edgepaths(self): + trimesh = TriMesh((self.simplices, self.nodes)) + paths = [np.array([(0, 0), (0.5, 1), (1, 0), (0, 0)]), + np.array([(0.5, 1), (1, 0), (1.5, 1), (0.5, 1)])] + for p1, p2 in zip(trimesh.edgepaths.split(datatype='array'), paths): + self.assertEqual(p1, p2) + + def test_trimesh_select(self): + trimesh = TriMesh((self.simplices, self.nodes)).select(x=(0.1, None)) + self.assertEqual(trimesh.array(), np.array(self.simplices[1:])) diff --git a/tests/testmplgraphs.py b/tests/testmplgraphs.py index 8a27b67b4a..4276294f32 100644 --- a/tests/testmplgraphs.py +++ b/tests/testmplgraphs.py @@ -4,8 +4,8 @@ import numpy as np from holoviews.core.data import Dataset -from holoviews.core.options import Store -from holoviews.element import Graph, circular_layout +from holoviews.core.options import Store, Cycle +from holoviews.element import Graph, TriMesh, circular_layout from holoviews.element.comparison import ComparisonTestCase from holoviews.plotting import comms @@ -14,6 +14,7 @@ from matplotlib import pyplot pyplot.switch_backend('agg') from holoviews.plotting.mpl import OverlayPlot + from matplotlib.collections import LineCollection, PolyCollection mpl_renderer = Store.renderers['matplotlib'] except: mpl_renderer = None @@ -97,3 +98,69 @@ def test_plot_graph_numerically_colored_edges(self): edges = plot.handles['edges'] self.assertEqual(edges.get_array(), self.weights) self.assertEqual(edges.get_clim(), (self.weights.min(), self.weights.max())) + + + +class TestMplTriMeshPlots(ComparisonTestCase): + + def setUp(self): + if not mpl_renderer: + raise SkipTest('Matplotlib tests require matplotlib to be available') + self.previous_backend = Store.current_backend + Store.current_backend = 'matplotlib' + self.default_comm = mpl_renderer.comms['default'] + mpl_renderer.comms['default'] = (comms.Comm, '') + + self.nodes = [(0, 0, 0), (0.5, 1, 1), (1., 0, 2), (1.5, 1, 3)] + self.simplices = [(0, 1, 2, 0), (1, 2, 3, 1)] + self.trimesh = TriMesh((self.simplices, self.nodes)) + self.trimesh_weighted = TriMesh((self.simplices, self.nodes), vdims='weight') + + def tearDown(self): + mpl_renderer.comms['default'] = self.default_comm + Store.current_backend = self.previous_backend + + def test_plot_simple_trimesh(self): + plot = mpl_renderer.get_plot(self.trimesh) + nodes = plot.handles['nodes'] + edges = plot.handles['edges'] + self.assertIsInstance(edges, LineCollection) + self.assertEqual(nodes.get_offsets(), self.trimesh.nodes.array([0, 1])) + self.assertEqual([p.vertices for p in edges.get_paths()], + [p.array() for p in self.trimesh.edgepaths.split()]) + + def test_plot_simple_trimesh_filled(self): + plot = mpl_renderer.get_plot(self.trimesh.opts(plot=dict(filled=True))) + nodes = plot.handles['nodes'] + edges = plot.handles['edges'] + self.assertIsInstance(edges, PolyCollection) + self.assertEqual(nodes.get_offsets(), self.trimesh.nodes.array([0, 1])) + self.assertEqual([p.vertices for p in edges.get_paths()], + [p.array()[[0, 1, 2, 3, 3]] + for p in self.trimesh.edgepaths.split()]) + + def test_plot_trimesh_colored_edges(self): + opts = dict(plot=dict(edge_color_index='weight'), style=dict(edge_cmap='Greys')) + plot = mpl_renderer.get_plot(self.trimesh_weighted.opts(**opts)) + edges = plot.handles['edges'] + colors = np.array([[ 1., 1., 1., 1.], + [ 0., 0., 0., 1.]]) + self.assertEqual(edges.get_edgecolors(), colors) + + def test_plot_trimesh_categorically_colored_edges(self): + opts = dict(plot=dict(edge_color_index='node1'), style=dict(edge_color=Cycle('Set1'))) + plot = mpl_renderer.get_plot(self.trimesh_weighted.opts(**opts)) + edges = plot.handles['edges'] + colors = np.array([[0.894118, 0.101961, 0.109804, 1.], + [0.215686, 0.494118, 0.721569, 1.]]) + self.assertEqual(edges.get_edgecolors(), colors) + + def test_plot_trimesh_categorically_colored_edges_filled(self): + opts = dict(plot=dict(edge_color_index='node1', filled=True), + style=dict(edge_color=Cycle('Set1'))) + plot = mpl_renderer.get_plot(self.trimesh_weighted.opts(**opts)) + edges = plot.handles['edges'] + colors = np.array([[0.894118, 0.101961, 0.109804, 1.], + [0.215686, 0.494118, 0.721569, 1.]]) + self.assertEqual(edges.get_facecolors(), colors) + From 58b98b044a5a5ea5b8bcaf878a233f52ebdc3b18 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 28 Nov 2017 21:43:38 +0000 Subject: [PATCH 11/28] Various TriMesh improvements --- holoviews/element/graphs.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 93bf84e183..b5552fbb9e 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -414,6 +414,10 @@ class TriMesh(Graph): group = param.String(default='TriMesh', constant=True) + _node_type = Nodes + + _edge_type = EdgePaths + def __init__(self, data, kdims=None, vdims=None, **params): if isinstance(data, tuple): data = data + (None,)*(3-len(data)) @@ -424,30 +428,46 @@ def __init__(self, data, kdims=None, vdims=None, **params): raise ValueError("TriMesh expects both simplices and nodes " "to be supplied.") - if isinstance(nodes, Nodes): + if isinstance(nodes, self._node_type): pass elif isinstance(nodes, Points): # Add index to make it a valid Nodes object - nodes = Nodes(Dataset(nodes).add_dimension('index', 2, np.arange(len(nodes)))) + nodes = self._node_type(Dataset(nodes).add_dimension('index', 2, np.arange(len(nodes)))) elif not isinstance(nodes, Dataset) or nodes.ndims in [2, 3]: try: # Try assuming data contains indices (3 columns) - nodes = Nodes(nodes) + nodes = self._node_type(nodes) except: # Try assuming data contains just coordinates (2 columns) try: points = Points(nodes) - nodes = Nodes(Dataset(points).add_dimension('index', 2, np.arange(len(points)))) + ds = Dataset(points).add_dimension('index', 2, np.arange(len(points))) + nodes = self._node_type(ds) except: raise ValueError("Nodes argument could not be interpreted, expected " "data with two or three columns representing the " "x/y positions and optionally the node indices.") - if edgepaths is not None and not isinstance(edgepaths, EdgePaths): - edgepaths = EdgePaths(edgepaths) + if edgepaths is not None and not isinstance(edgepaths, self._edge_type): + edgepaths = self._edge_type(edgepaths) super(TriMesh, self).__init__(edges, kdims=kdims, vdims=vdims, **params) self._nodes = nodes self._edgepaths = edgepaths + @classmethod + def from_points(cls, points): + """ + Uses Delauney triangulation to compute triangle simplices for + each point. + """ + try: + from scipy.spatial import Delaunay + except: + raise ImportError("Generating triangles from points requires, " + "SciPy to be installed.") + if not isinstance(points, Points): + points = Points(points) + tris = Delaunay(points.array([0, 1])) + return cls((tris.simplices, points)) @property def edgepaths(self): @@ -459,8 +479,9 @@ def edgepaths(self): simplices = self.array([0, 1, 2]).astype(np.int32) pts = self.nodes.array([0, 1]) - paths = [tri[[0, 1, 2, 0], :] for tri in pts[simplices]] - edgepaths = EdgePaths(paths, kdims=self.nodes.kdims[:2]) + empty = np.array([[np.NaN, np.NaN]]) + paths = [arr for tri in pts[simplices] for arr in (tri[[0, 1, 2, 0], :], empty)][:-1] + edgepaths = self._edge_type([np.concatenate(paths)], kdims=self.nodes.kdims[:2]) self._edgepaths = edgepaths return edgepaths From 2dcbc26e77e92bab147f9c1b756fc0e0dee0797e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 28 Nov 2017 21:44:36 +0000 Subject: [PATCH 12/28] Added trimesh rasterization and generalized API --- holoviews/operation/datashader.py | 81 ++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 104ca30a10..244198da96 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division from collections import Callable, Iterable +from functools import partial from distutils.version import LooseVersion import warnings @@ -25,7 +26,7 @@ from ..core.data import PandasInterface, XArrayInterface from ..core.sheetcoords import BoundingBox from ..core.util import get_param_values, basestring, datetime_types, dt_to_int -from ..element import Image, Path, Curve, RGB, Graph +from ..element import Image, Path, Curve, RGB, Graph, TriMesh, Points, Scatter, Dataset from ..streams import RangeXY, PlotSize @@ -184,7 +185,7 @@ class aggregate(ResamplingOperation): """ aggregator = param.ClassSelector(class_=ds.reductions.Reduction, - default=ds.count()) + default=None) @classmethod def get_agg_data(cls, obj, category=None): @@ -264,7 +265,8 @@ def _aggregate_ndoverlay(self, element, agg_fn): """ # Compute overall bounds x, y = element.last.dimensions()[0:2] - (x_range, y_range), (xs, ys), (width, height), (xtype, ytype) = self._get_sampling(element, x, y) + info = self._get_sampling(element, x, y) + (x_range, y_range), (xs, ys), (width, height), (xtype, ytype) = info agg_params = dict({k: v for k, v in self.p.items() if k in aggregate.params()}, x_range=x_range, y_range=y_range) @@ -343,7 +345,7 @@ def _process(self, element, key=None): cvs = ds.Canvas(plot_width=width, plot_height=height, x_range=x_range, y_range=y_range) - column = agg_fn.column + column = agg_fn.column if agg_fn else None if column and isinstance(agg_fn, ds.count_cat): name = '%s Count' % agg_fn.column else: @@ -420,7 +422,8 @@ def _process(self, element, key=None): raise RuntimeError('regrid operation requires datashader>=0.6.0') x, y = element.kdims - (x_range, y_range), _, (width, height), (xtype, ytype) = self._get_sampling(element, x, y) + info = self._get_sampling(element, x, y) + (x_range, y_range), _, (width, height), (xtype, ytype) = info coords = tuple(element.dimension_values(d, expanded=False) for d in [x, y]) @@ -478,6 +481,70 @@ def _process(self, element, key=None): return element.clone(regridded, bounds=bbox, datatype=['xarray']) +class trimesh_rasterize(aggregate): + """ + Rasterize the TriMesh element using the supplied aggregator. If + the TriMesh nodes or edges define a value dimension will plot + filled and shaded polygons otherwise returns a wiremesh of the + data. + """ + + aggregator = param.ClassSelector(class_=ds.reductions.Reduction, + default=None) + + def _process(self, element, key=None): + x, y = element.nodes.kdims[:2] + info = self._get_sampling(element, x, y) + (x_range, y_range), _, (width, height), (xtype, ytype) = info + cvs = ds.Canvas(plot_width=width, plot_height=height, + x_range=x_range, y_range=y_range) + + if element.vdims: + simplices = element.dframe([0, 1, 2, 3]) + pts = element.nodes.dframe([0, 1]) + vdim = element.vdims[0] + elif element.nodes.vdims: + simplices = element.dframe([0, 1, 2]) + pts = element.nodes.dframe([0, 1, 3]) + vdim = element.nodes.vdims[0] + else: + return aggregate._process(self, element, key) + + agg = cvs.trimesh(pts, simplices, agg=self.p.aggregator) + params = dict(get_param_values(element), kdims=[x, y], + datatype=['xarray'], vdims=[vdim]) + return Image(agg, **params) + + +class rasterize(trimesh_rasterize): + """ + Rasterize is a high-level operation which will rasterize any + Element or combination of Elements supplied as an (Nd)Overlay + by aggregating with the supplied aggregation function. + """ + + aggregator = param.ClassSelector(class_=ds.reductions.Reduction, + default=ds.count()) + + def _process(self, element, key=None): + # Get input Images to avoid multiple rasterization + imgs = element.traverse(lambda x: x, [Image]) + + # Rasterize TriMeshes + trirasterize = partial(trimesh_rasterize._process, self) + element = element.map(trirasterize, TriMesh) + + # Rasterize NdOverlay of objects + dsrasterize = partial(aggregate._process, self) + predicate = lambda x: (isinstance(x, NdOverlay) and issubclass(x.type, Dataset) + and not issubclass(x.type, Image)) + element = element.map(dsrasterize, predicate) + + # Rasterize other Dataset types + predicate = lambda x: isinstance(x, Dataset) and (not isinstance(x, Image) or x in imgs) + element = element.map(dsrasterize, predicate) + return element + class shade(Operation): """ @@ -623,7 +690,7 @@ def _process(self, element, key=None): -class datashade(aggregate, shade): +class datashade(rasterize, shade): """ Applies the aggregate and shade operations, aggregating all elements in the supplied object and then applying normalization @@ -633,7 +700,7 @@ class datashade(aggregate, shade): """ def _process(self, element, key=None): - agg = aggregate._process(self, element, key) + agg = rasterize._process(self, element, key) shaded = shade._process(self, agg, key) return shaded From d16a637c3f0588e903c703f74201d954360701fa Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 3 Dec 2017 02:31:09 +0000 Subject: [PATCH 13/28] Split matplotlib paths before displaying them --- holoviews/element/graphs.py | 2 +- holoviews/plotting/mpl/graphs.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index b5552fbb9e..fa0056c8bb 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -293,7 +293,7 @@ def select(self, selection_specs=None, selection_mode='edges', **selection): @property def _split_edgepaths(self): - if len(self) == len(self._edgepaths.data): + if len(self) == len(self.edgepaths.data): return self._edgepaths else: return self._edgepaths.clone(split_path(self._edgepaths)) diff --git a/holoviews/plotting/mpl/graphs.py b/holoviews/plotting/mpl/graphs.py index 183521d034..2e69ba5411 100644 --- a/holoviews/plotting/mpl/graphs.py +++ b/holoviews/plotting/mpl/graphs.py @@ -90,7 +90,7 @@ def get_data(self, element, ranges, style): dims = element.nodes.dimensions() self._compute_styles(element, ranges, style) - paths = element.edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) + paths = element._split_edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) if self.invert_axes: paths = [p[:, ::-1] for p in paths] return {'nodes': (pxs, pys), 'edges': paths}, style, {'dimensions': dims} From d5a6ea6e383b17eb2afec326ed7cd98abeb724f9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 3 Dec 2017 13:40:19 +0000 Subject: [PATCH 14/28] Optimized static bokeh Graph plots --- holoviews/plotting/bokeh/graphs.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 40c166df22..3c25b3c772 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -115,6 +115,10 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): def get_data(self, element, ranges, style): + # Force static source to False + static = self.static_source + self.handles['static_source'] = static + self.static_source = False xidx, yidx = (1, 0) if self.invert_axes else (0, 1) # Get node data @@ -159,7 +163,7 @@ def get_data(self, element, ranges, style): end = np.array([node_indices.get(y, nan_node) for y in end], dtype=np.int32) path_data = dict(start=start, end=end) self._get_edge_colors(element, ranges, path_data, edge_mapping, style) - if element._edgepaths and not self.static_source: + if element._edgepaths and not static: edges = element._split_edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) if len(edges) == len(start): path_data['xs'] = [path[:, 0] for path in edges] @@ -189,7 +193,10 @@ def _update_datasource(self, source, data): Update datasource with data for a new frame. """ if isinstance(source, ColumnDataSource): - source.data.update(data) + if self.handles['static_source']: + source.trigger('data') + else: + source.data.update(data) else: source.graph_layout = data From 3f2a28d50b5641a85855079972067f0379bd1056 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 3 Dec 2017 15:24:37 +0000 Subject: [PATCH 15/28] Fixed various Graph edgepaths bugs --- holoviews/element/graphs.py | 12 +++++++----- holoviews/element/util.py | 7 ++++--- tests/testmplgraphs.py | 8 ++++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index fa0056c8bb..73fd3de16f 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -294,9 +294,9 @@ def select(self, selection_specs=None, selection_mode='edges', **selection): @property def _split_edgepaths(self): if len(self) == len(self.edgepaths.data): - return self._edgepaths + return self.edgepaths else: - return self._edgepaths.clone(split_path(self._edgepaths)) + return self.edgepaths.clone(split_path(self.edgepaths)) def range(self, dimension, data_range=True): @@ -478,10 +478,12 @@ def edgepaths(self): return self._edgepaths simplices = self.array([0, 1, 2]).astype(np.int32) - pts = self.nodes.array([0, 1]) + pts = self.nodes.array([0, 1]).astype(float) empty = np.array([[np.NaN, np.NaN]]) - paths = [arr for tri in pts[simplices] for arr in (tri[[0, 1, 2, 0], :], empty)][:-1] - edgepaths = self._edge_type([np.concatenate(paths)], kdims=self.nodes.kdims[:2]) + paths = [arr for tri in pts[simplices] for arr in + (tri[[0, 1, 2, 0], :], empty)][:-1] + edgepaths = self._edge_type([np.concatenate(paths)], + kdims=self.nodes.kdims[:2]) self._edgepaths = edgepaths return edgepaths diff --git a/holoviews/element/util.py b/holoviews/element/util.py index f9084be52b..94bab31bb7 100644 --- a/holoviews/element/util.py +++ b/holoviews/element/util.py @@ -46,13 +46,14 @@ def split_path(path): Split a Path type containing a single NaN separated path into multiple subpaths. """ - path = path.split()[0] + path = path.split(0, 1)[0] values = path.dimension_values(0) - splits = np.concatenate([[0], np.where(np.isnan(values))[0]+1, [0]]) + splits = np.concatenate([[0], np.where(np.isnan(values))[0]+1, [None]]) subpaths = [] data = PandasInterface.as_dframe(path) if pd else path.array() for i in range(len(splits)-1): - slc = slice(splits[i], splits[i+1]-1) + end = splits[i+1] + slc = slice(splits[i], None if end is None else end-1) subpath = data.iloc[slc] if pd else data[slc] if len(subpath): subpaths.append(subpath) diff --git a/tests/testmplgraphs.py b/tests/testmplgraphs.py index 4276294f32..6476eadf9f 100644 --- a/tests/testmplgraphs.py +++ b/tests/testmplgraphs.py @@ -127,7 +127,7 @@ def test_plot_simple_trimesh(self): self.assertIsInstance(edges, LineCollection) self.assertEqual(nodes.get_offsets(), self.trimesh.nodes.array([0, 1])) self.assertEqual([p.vertices for p in edges.get_paths()], - [p.array() for p in self.trimesh.edgepaths.split()]) + [p.array() for p in self.trimesh._split_edgepaths.split()]) def test_plot_simple_trimesh_filled(self): plot = mpl_renderer.get_plot(self.trimesh.opts(plot=dict(filled=True))) @@ -135,9 +135,9 @@ def test_plot_simple_trimesh_filled(self): edges = plot.handles['edges'] self.assertIsInstance(edges, PolyCollection) self.assertEqual(nodes.get_offsets(), self.trimesh.nodes.array([0, 1])) - self.assertEqual([p.vertices for p in edges.get_paths()], - [p.array()[[0, 1, 2, 3, 3]] - for p in self.trimesh.edgepaths.split()]) + paths = self.trimesh._split_edgepaths.split(datatype='array') + self.assertEqual([p.vertices[:4] for p in edges.get_paths()], + paths) def test_plot_trimesh_colored_edges(self): opts = dict(plot=dict(edge_color_index='weight'), style=dict(edge_cmap='Greys')) From d8cfd66fa0504d7bf1de1313bfaca8ca1d178b5b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 3 Dec 2017 15:52:48 +0000 Subject: [PATCH 16/28] Restored default datashading aggregator --- holoviews/operation/datashader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 244198da96..d44187cdbd 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -185,7 +185,7 @@ class aggregate(ResamplingOperation): """ aggregator = param.ClassSelector(class_=ds.reductions.Reduction, - default=None) + default=ds.count()) @classmethod def get_agg_data(cls, obj, category=None): From 1c9585df8b579c98cdd6eb405a7e5d1c38b1dd1e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 3 Dec 2017 15:57:52 +0000 Subject: [PATCH 17/28] Updated unit test --- tests/testgraphelement.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/testgraphelement.py b/tests/testgraphelement.py index da4b48f74b..06a9dd52b9 100644 --- a/tests/testgraphelement.py +++ b/tests/testgraphelement.py @@ -151,8 +151,8 @@ def test_trimesh_constructor_point_nodes(self): def test_trimesh_edgepaths(self): trimesh = TriMesh((self.simplices, self.nodes)) - paths = [np.array([(0, 0), (0.5, 1), (1, 0), (0, 0)]), - np.array([(0.5, 1), (1, 0), (1.5, 1), (0.5, 1)])] + paths = [np.array([(0, 0), (0.5, 1), (1, 0), (0, 0), (np.NaN, np.NaN), + (0.5, 1), (1, 0), (1.5, 1), (0.5, 1)])] for p1, p2 in zip(trimesh.edgepaths.split(datatype='array'), paths): self.assertEqual(p1, p2) From 9f9b569ca1d4107f47d2467fb7a2267088c837b8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 7 Dec 2017 13:02:10 +0000 Subject: [PATCH 18/28] Improved datashader dimension lookup --- holoviews/operation/datashader.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index d44187cdbd..8a5385ce11 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -346,12 +346,17 @@ def _process(self, element, key=None): x_range=x_range, y_range=y_range) column = agg_fn.column if agg_fn else None - if column and isinstance(agg_fn, ds.count_cat): - name = '%s Count' % agg_fn.column + if column: + dims = [d for d in element.dimensions('ranges') if d == column] + if not dims: + raise ValueError("Aggregation column %s not found on %s element. " + "Ensure the aggregator references an existing " + "dimension.") + if isinstance(agg_fn, ds.count_cat): + name = '%s Count' % agg_fn.column + vdims = [dims[0](column)] else: - name = column - vdims = [element.get_dimension(column)(name) if column - else Dimension('Count')] + vdims = Dimension('Count') params = dict(get_param_values(element), kdims=[x, y], datatype=['xarray'], vdims=vdims) From b7f04aecea3d4304e7690321af463830e5c1cf94 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 7 Dec 2017 13:02:31 +0000 Subject: [PATCH 19/28] Added interpolation support for trimesh rasterization --- holoviews/operation/datashader.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 8a5385ce11..ea576159b6 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -497,12 +497,18 @@ class trimesh_rasterize(aggregate): aggregator = param.ClassSelector(class_=ds.reductions.Reduction, default=None) + interpolation = param.ObjectSelector(default='bilinear', + objects=['bilinear'], doc=""" + The interpolation method to apply during rasterization.""") + def _process(self, element, key=None): x, y = element.nodes.kdims[:2] info = self._get_sampling(element, x, y) (x_range, y_range), _, (width, height), (xtype, ytype) = info + interpolate = bool(self.p.interpolation) cvs = ds.Canvas(plot_width=width, plot_height=height, - x_range=x_range, y_range=y_range) + x_range=x_range, y_range=y_range, + interp=interpolate) if element.vdims: simplices = element.dframe([0, 1, 2, 3]) From 635a8ceadc9e3f89a7523f378d5ebb98dd118409 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Dec 2017 16:47:30 +0000 Subject: [PATCH 20/28] Improved datashader operations --- holoviews/operation/datashader.py | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index ea576159b6..51c167e1d6 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -164,8 +164,8 @@ class aggregate(ResamplingOperation): """ aggregate implements 2D binning for any valid HoloViews Element type using datashader. I.e., this operation turns a HoloViews - Element or overlay of Elements into an hv.Image or an overlay of - hv.Images by rasterizing it, which provides a fixed-sized + Element or overlay of Elements into an hImage or an overlay of + .Images by rasterizing it, which provides a fixed-sized representation independent of the original dataset size. By default it will simply count the number of values in each bin @@ -498,17 +498,15 @@ class trimesh_rasterize(aggregate): default=None) interpolation = param.ObjectSelector(default='bilinear', - objects=['bilinear'], doc=""" + objects=['bilinear', None], doc=""" The interpolation method to apply during rasterization.""") def _process(self, element, key=None): x, y = element.nodes.kdims[:2] info = self._get_sampling(element, x, y) (x_range, y_range), _, (width, height), (xtype, ytype) = info - interpolate = bool(self.p.interpolation) cvs = ds.Canvas(plot_width=width, plot_height=height, - x_range=x_range, y_range=y_range, - interp=interpolate) + x_range=x_range, y_range=y_range) if element.vdims: simplices = element.dframe([0, 1, 2, 3]) @@ -521,7 +519,9 @@ def _process(self, element, key=None): else: return aggregate._process(self, element, key) - agg = cvs.trimesh(pts, simplices, agg=self.p.aggregator) + interpolate = bool(self.p.interpolation) + agg = cvs.trimesh(pts, simplices, agg=self.p.aggregator, + interp=interpolate) params = dict(get_param_values(element), kdims=[x, y], datatype=['xarray'], vdims=[vdim]) return Image(agg, **params) @@ -532,10 +532,25 @@ class rasterize(trimesh_rasterize): Rasterize is a high-level operation which will rasterize any Element or combination of Elements supplied as an (Nd)Overlay by aggregating with the supplied aggregation function. + + By default it will simply count the number of values in each bin + but other aggregators can be supplied implementing mean, max, min + and other reduction operations. + + The bins of the aggregate are defined by the width and height and + the x_range and y_range. If x_sampling or y_sampling are supplied + the operation will ensure that a bin is no smaller than the minimum + sampling distance by reducing the width and height when zoomed in + beyond the minimum sampling distance. + + By default, the PlotSize stream is applied when this operation + is used dynamically, which means that the height and width + will automatically be set to match the inner dimensions of + the linked plot. """ aggregator = param.ClassSelector(class_=ds.reductions.Reduction, - default=ds.count()) + default=None) def _process(self, element, key=None): # Get input Images to avoid multiple rasterization @@ -547,12 +562,14 @@ def _process(self, element, key=None): # Rasterize NdOverlay of objects dsrasterize = partial(aggregate._process, self) - predicate = lambda x: (isinstance(x, NdOverlay) and issubclass(x.type, Dataset) + predicate = lambda x: (isinstance(x, NdOverlay) and + issubclass(x.type, Dataset) and not issubclass(x.type, Image)) element = element.map(dsrasterize, predicate) # Rasterize other Dataset types - predicate = lambda x: isinstance(x, Dataset) and (not isinstance(x, Image) or x in imgs) + predicate = lambda x: (isinstance(x, Dataset) and + (not isinstance(x, Image) or x in imgs)) element = element.map(dsrasterize, predicate) return element From cf0d3c429ae19aa6dd39e57d44272343352f0473 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Dec 2017 16:48:28 +0000 Subject: [PATCH 21/28] Improvements for bokeh GraphPlot --- holoviews/plotting/bokeh/graphs.py | 72 +++++++++++++++++++----------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 3c25b3c772..77fbae3673 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -2,7 +2,7 @@ import numpy as np from bokeh.models import HoverTool, ColumnDataSource from bokeh.models import (StaticLayoutProvider, NodesAndLinkedEdges, - EdgesAndLinkedNodes, Patches) + EdgesAndLinkedNodes, Patches, Bezier) from ...core.util import basestring, dimension_sanitizer, unique_array from ...core.options import Cycle @@ -33,7 +33,7 @@ class GraphPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): A list of plugin tools to use on the plot.""") # Map each glyph to a style group - _style_groups = {'scatter': 'node', 'multi_line': 'edge', 'patches': 'edge'} + _style_groups = {'scatter': 'node', 'multi_line': 'edge', 'patches': 'edge', 'bezier': 'edge'} style_opts = (['edge_'+p for p in line_properties] + ['node_'+p for p in fill_properties+line_properties] + @@ -42,6 +42,9 @@ class GraphPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): # Filled is only supported for subclasses filled = False + # Bezier paths + bezier = False + # Declares which columns in the data refer to node indices _node_indices = [0, 1] @@ -114,12 +117,25 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): edge_mapping['edge_selection_'+color_type] = transform + def _get_edge_paths(self, element): + path_data = {} + xidx, yidx = (1, 0) if self.invert_axes else (0, 1) + if element._edgepaths: + edges = element._split_edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) + if len(edges) == len(element): + path_data['xs'] = [path[:, xidx] for path in edges] + path_data['ys'] = [path[:, yidx] for path in edges] + else: + self.warning('Graph edge paths do not match the number of abstract edges ' + 'and will be skipped') + return path_data, {'xs': 'xs', 'ys': 'ys'} + + def get_data(self, element, ranges, style): # Force static source to False static = self.static_source self.handles['static_source'] = static self.static_source = False - xidx, yidx = (1, 0) if self.invert_axes else (0, 1) # Get node data nodes = element.nodes.dimension_values(2) @@ -152,7 +168,6 @@ def get_data(self, element, ranges, style): ['node_fill_color', 'node_nonselection_fill_color']} point_mapping['node_nonselection_fill_color'] = point_mapping['node_fill_color'] - # Get edge data edge_mapping = {} nan_node = index.max()+1 start, end = (element.dimension_values(i) for i in range(2)) @@ -163,14 +178,10 @@ def get_data(self, element, ranges, style): end = np.array([node_indices.get(y, nan_node) for y in end], dtype=np.int32) path_data = dict(start=start, end=end) self._get_edge_colors(element, ranges, path_data, edge_mapping, style) - if element._edgepaths and not static: - edges = element._split_edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) - if len(edges) == len(start): - path_data['xs'] = [path[:, 0] for path in edges] - path_data['ys'] = [path[:, 1] for path in edges] - else: - self.warning('Graph edge paths do not match the number of abstract edges ' - 'and will be skipped') + if not static: + pdata, pmapping = self._get_edge_paths(element) + path_data.update(pdata) + edge_mapping.update(pmapping) # Get hover data if any(isinstance(t, HoverTool) for t in self.state.tools): @@ -182,11 +193,19 @@ def get_data(self, element, ranges, style): elif self.inspection_policy == 'edges': for d in element.dimensions(): path_data[dimension_sanitizer(d.name)] = element.dimension_values(d) - edge_glyph = 'patches_1' if self.filled else 'multi_line_1' - data = {'scatter_1': point_data, edge_glyph: path_data, 'layout': layout} - mapping = {'scatter_1': point_mapping, edge_glyph: edge_mapping} + data = {'scatter_1': point_data, self.edge_glyph: path_data, 'layout': layout} + mapping = {'scatter_1': point_mapping, self.edge_glyph: edge_mapping} return data, mapping, style + @property + def edge_glyph(self): + if self.filled: + edge_glyph = 'patches_1' + elif self.bezier: + edge_glyph = 'bezier_1' + else: + edge_glyph = 'multi_line_1' + return edge_glyph def _update_datasource(self, source, data): """ @@ -205,13 +224,14 @@ def _init_glyphs(self, plot, element, ranges, source): # Get data and initialize data source style = self.style[self.cyclic_index] data, mapping, style = self.get_data(element, ranges, style) + edge_mapping = {k: v for k, v in mapping[self.edge_glyph].items() + if 'color' not in k} self.handles['previous_id'] = element._plot_id - edge_glyph = 'patches_1' if self.filled else 'multi_line_1' properties = {} mappings = {} for key in list(mapping): - if not any(glyph in key for glyph in ('scatter_1', edge_glyph)): + if not any(glyph in key for glyph in ('scatter_1', self.edge_glyph)): continue source = self._init_datasource(data.pop(key, {})) self.handles[key+'_source'] = source @@ -229,7 +249,7 @@ def _init_glyphs(self, plot, element, ranges, source): # Define static layout layout = StaticLayoutProvider(graph_layout=layout) node_source = self.handles['scatter_1_source'] - edge_source = self.handles[edge_glyph+'_source'] + edge_source = self.handles[self.edge_glyph+'_source'] renderer = plot.graph(node_source, edge_source, layout, **properties) # Initialize GraphRenderer @@ -250,19 +270,20 @@ def _init_glyphs(self, plot, element, ranges, source): self.handles['layout_source'] = layout self.handles['glyph_renderer'] = renderer self.handles['scatter_1_glyph_renderer'] = renderer.node_renderer - self.handles[edge_glyph+'_glyph_renderer'] = renderer.edge_renderer + self.handles[self.edge_glyph+'_glyph_renderer'] = renderer.edge_renderer self.handles['scatter_1_glyph'] = renderer.node_renderer.glyph - if self.filled: - allowed_properties = Patches.properties() + if self.filled or self.bezier: + glyph_model = Patches if self.filled else Bezier + allowed_properties = glyph_model.properties() for glyph_type in ('', 'selection_', 'nonselection_', 'hover_', 'muted_'): glyph = getattr(renderer.edge_renderer, glyph_type+'glyph', None) if glyph is None: continue - props = self._process_properties(edge_glyph, properties, mappings) + props = self._process_properties(self.edge_glyph, properties, mappings) filtered = self._filter_properties(props, glyph_type, allowed_properties) - patches = Patches(**dict(filtered, xs='xs', ys='ys')) - glyph = setattr(renderer.edge_renderer, glyph_type+'glyph', patches) - self.handles[edge_glyph+'_glyph'] = renderer.edge_renderer.glyph + new_glyph = glyph_model(**dict(filtered, **edge_mapping)) + setattr(renderer.edge_renderer, glyph_type+'glyph', new_glyph) + self.handles[self.edge_glyph+'_glyph'] = renderer.edge_renderer.glyph if 'hover' in self.handles: self.handles['hover'].renderers.append(renderer) @@ -294,3 +315,4 @@ def get_data(self, element, ranges, style): # Ensure the edgepaths for the triangles are generated element.edgepaths return super(TriMeshPlot, self).get_data(element, ranges, style) + From 2243bf6968b5841ed08df851c6ce387762e70f70 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 8 Dec 2017 22:09:27 +0000 Subject: [PATCH 22/28] Cleaned up datashader class hierarchy --- holoviews/operation/datashader.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 51c167e1d6..3e36bee465 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -164,9 +164,10 @@ class aggregate(ResamplingOperation): """ aggregate implements 2D binning for any valid HoloViews Element type using datashader. I.e., this operation turns a HoloViews - Element or overlay of Elements into an hImage or an overlay of - .Images by rasterizing it, which provides a fixed-sized - representation independent of the original dataset size. + Element or overlay of Elements into an Image or an overlay of + Images by rasterizing it. This allows quickly aggregating large + datasets computing a fixed-sized representation independent + of the original dataset size. By default it will simply count the number of values in each bin but other aggregators can be supplied implementing mean, max, min @@ -527,11 +528,13 @@ def _process(self, element, key=None): return Image(agg, **params) -class rasterize(trimesh_rasterize): + +class rasterize(ResamplingOperation): """ Rasterize is a high-level operation which will rasterize any - Element or combination of Elements supplied as an (Nd)Overlay - by aggregating with the supplied aggregation function. + Element or combination of Elements supplied as an (Nd)Overlay by + aggregating with the supplied aggregation it with the declared + aggregator and interpolation methods. By default it will simply count the number of values in each bin but other aggregators can be supplied implementing mean, max, min @@ -552,16 +555,24 @@ class rasterize(trimesh_rasterize): aggregator = param.ClassSelector(class_=ds.reductions.Reduction, default=None) + interpolation = param.ObjectSelector(default='bilinear', + objects=['bilinear', None], doc=""" + The interpolation method to apply during rasterization.""") + def _process(self, element, key=None): # Get input Images to avoid multiple rasterization imgs = element.traverse(lambda x: x, [Image]) # Rasterize TriMeshes - trirasterize = partial(trimesh_rasterize._process, self) + tri_params = dict({k: v for k, v in self.p.items() + if k in aggregate.params()}, dynamic=False) + trirasterize = trimesh_rasterize.instance(**tri_params) element = element.map(trirasterize, TriMesh) # Rasterize NdOverlay of objects - dsrasterize = partial(aggregate._process, self) + agg_params = dict({k: v for k, v in self.p.items() + if k in aggregate.params()}, dynamic=False) + dsrasterize = aggregate.instance(**agg_params) predicate = lambda x: (isinstance(x, NdOverlay) and issubclass(x.type, Dataset) and not issubclass(x.type, Image)) From 586a506dd993c13f73ef88ac4f7170b5e962867d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 9 Dec 2017 00:26:15 +0000 Subject: [PATCH 23/28] Handled empty TriMesh --- holoviews/element/graphs.py | 11 +++++++++-- holoviews/plotting/bokeh/graphs.py | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 73fd3de16f..8e1d195c50 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -425,8 +425,11 @@ def __init__(self, data, kdims=None, vdims=None, **params): else: edges, nodes, edgepaths = data, None, None if nodes is None: - raise ValueError("TriMesh expects both simplices and nodes " - "to be supplied.") + if isinstance(edges, list) and len(edges) == 0: + nodes = [] + else: + raise ValueError("TriMesh expects both simplices and nodes " + "to be supplied.") if isinstance(nodes, self._node_type): pass @@ -476,6 +479,10 @@ def edgepaths(self): """ if self._edgepaths: return self._edgepaths + elif not len(self): + edgepaths = self._edge_type([], kdims=self.nodes.kdims[:2]) + self._edgepaths = edgepaths + return edgepaths simplices = self.array([0, 1, 2]).astype(np.int32) pts = self.nodes.array([0, 1]).astype(float) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 77fbae3673..f7eb08e44e 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -120,7 +120,7 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): def _get_edge_paths(self, element): path_data = {} xidx, yidx = (1, 0) if self.invert_axes else (0, 1) - if element._edgepaths: + if element._edgepaths is not None: edges = element._split_edgepaths.split(datatype='array', dimensions=element.edgepaths.kdims) if len(edges) == len(element): path_data['xs'] = [path[:, xidx] for path in edges] @@ -169,7 +169,7 @@ def get_data(self, element, ranges, style): point_mapping['node_nonselection_fill_color'] = point_mapping['node_fill_color'] edge_mapping = {} - nan_node = index.max()+1 + nan_node = index.max()+1 if len(index) else 0 start, end = (element.dimension_values(i) for i in range(2)) if nodes.dtype.kind == 'f': start, end = start.astype(np.int32), end.astype(np.int32) From 3480316a1064091ff42ec56a7ef031ef5f56aff9 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 14 Dec 2017 14:32:42 +0000 Subject: [PATCH 24/28] Addressed various TriMesh review comments --- holoviews/element/graphs.py | 3 ++- holoviews/plotting/bokeh/graphs.py | 32 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/holoviews/element/graphs.py b/holoviews/element/graphs.py index 8e1d195c50..bdd84f7f57 100644 --- a/holoviews/element/graphs.py +++ b/holoviews/element/graphs.py @@ -457,7 +457,7 @@ def __init__(self, data, kdims=None, vdims=None, **params): self._edgepaths = edgepaths @classmethod - def from_points(cls, points): + def from_vertices(cls, points): """ Uses Delauney triangulation to compute triangle simplices for each point. @@ -504,6 +504,7 @@ def select(self, selection_specs=None, **selection): supplied, which will ensure the selection is only applied if the specs match the selected object. """ + # Ensure that edgepaths are initialized so they can be selected on self.edgepaths return super(TriMesh, self).select(selection_specs=None, selection_mode='nodes', diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index f7eb08e44e..900c874ad2 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -46,7 +46,16 @@ class GraphPlot(CompositeElementPlot, ColorbarPlot, LegendPlot): bezier = False # Declares which columns in the data refer to node indices - _node_indices = [0, 1] + _node_columns = [0, 1] + + @property + def edge_glyph(self): + if self.filled: + return 'patches_1' + elif self.bezier: + return 'bezier_1' + else: + return 'multi_line_1' def _hover_opts(self, element): if self.inspection_policy == 'nodes': @@ -83,7 +92,7 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): idx = element.get_dimension_index(cdim) field = dimension_sanitizer(cdim.name) cvals = element.dimension_values(cdim) - if idx in self._node_indices: + if idx in self._node_columns: factors = element.nodes.dimension_values(2, expanded=False) elif idx == 2 and cvals.dtype.kind in 'if': factors = None @@ -92,7 +101,7 @@ def _get_edge_colors(self, element, ranges, edge_data, edge_mapping, style): default_cmap = 'viridis' if factors is None else 'tab20' cmap = style.get('edge_cmap', style.get('cmap', default_cmap)) - if factors is None or (factors.dtype.kind in 'if' and idx not in self._node_indices): + if factors is None or (factors.dtype.kind in 'if' and idx not in self._node_columns): colors, factors = None, None else: if factors.dtype.kind == 'f': @@ -126,8 +135,8 @@ def _get_edge_paths(self, element): path_data['xs'] = [path[:, xidx] for path in edges] path_data['ys'] = [path[:, yidx] for path in edges] else: - self.warning('Graph edge paths do not match the number of abstract edges ' - 'and will be skipped') + raise ValueError("Edge paths do not match the number of supplied edges." + "Expected %d, found %d paths." % (len(element), len(edges))) return path_data, {'xs': 'xs', 'ys': 'ys'} @@ -197,15 +206,6 @@ def get_data(self, element, ranges, style): mapping = {'scatter_1': point_mapping, self.edge_glyph: edge_mapping} return data, mapping, style - @property - def edge_glyph(self): - if self.filled: - edge_glyph = 'patches_1' - elif self.bezier: - edge_glyph = 'bezier_1' - else: - edge_glyph = 'multi_line_1' - return edge_glyph def _update_datasource(self, source, data): """ @@ -309,10 +309,10 @@ class TriMeshPlot(GraphPlot): ['node_size', 'cmap', 'edge_cmap']) # Declares that three columns in TriMesh refer to edges - _node_indices = [0, 1, 2] + _node_columns = [0, 1, 2] def get_data(self, element, ranges, style): - # Ensure the edgepaths for the triangles are generated + # Ensure the edgepaths for the triangles are generated before plotting element.edgepaths return super(TriMeshPlot, self).get_data(element, ranges, style) From ad2fcca9e2da2273eeef842d87db76d2f9088d69 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 14 Dec 2017 14:33:17 +0000 Subject: [PATCH 25/28] Fixed Dataset.add_dimension dtype bug --- holoviews/core/data/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/holoviews/core/data/__init__.py b/holoviews/core/data/__init__.py index c3fea76be3..9f23a5db13 100644 --- a/holoviews/core/data/__init__.py +++ b/holoviews/core/data/__init__.py @@ -275,7 +275,11 @@ def add_dimension(self, dimension, dim_pos, dim_val, vdim=False, **kwargs): dims.insert(dim_pos, dimension) dimensions = dict(kdims=dims) - data = self.interface.add_dimension(self, dimension, dim_pos, dim_val, vdim) + if issubclass(self.interface, ArrayInterface) and np.asarray(dim_val).dtype != self.data.dtype: + element = self.clone(datatype=['pandas', 'dictionary']) + data = element.interface.add_dimension(element, dimension, dim_pos, dim_val, vdim) + else: + data = self.interface.add_dimension(self, dimension, dim_pos, dim_val, vdim) return self.clone(data, **dimensions) From 74f961aabbfa7cb39fddf7f2a6c5e44736322c3d Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 14 Dec 2017 14:33:35 +0000 Subject: [PATCH 26/28] Fixed datashade operation docstrings --- holoviews/operation/datashader.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 3e36bee465..adf7d6d7f1 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -532,13 +532,13 @@ def _process(self, element, key=None): class rasterize(ResamplingOperation): """ Rasterize is a high-level operation which will rasterize any - Element or combination of Elements supplied as an (Nd)Overlay by - aggregating with the supplied aggregation it with the declared - aggregator and interpolation methods. + Element or combination of Elements aggregating it with the supplied + aggregator and interpolation method. - By default it will simply count the number of values in each bin - but other aggregators can be supplied implementing mean, max, min - and other reduction operations. + The default aggregation method depends on the type of Element but + usually defaults to the count of samples in each bin, other + aggregators can be supplied implementing mean, max, min and other + reduction operations. The bins of the aggregate are defined by the width and height and the x_range and y_range. If x_sampling or y_sampling are supplied @@ -546,10 +546,10 @@ class rasterize(ResamplingOperation): sampling distance by reducing the width and height when zoomed in beyond the minimum sampling distance. - By default, the PlotSize stream is applied when this operation - is used dynamically, which means that the height and width - will automatically be set to match the inner dimensions of - the linked plot. + By default, the PlotSize and RangeXY streams are applied when this + operation is used dynamically, which means that the width, height, + x_range and y_range will automatically be set to match the inner + dimensions of the linked plot and the ranges of the axes. """ aggregator = param.ClassSelector(class_=ds.reductions.Reduction, @@ -585,6 +585,7 @@ def _process(self, element, key=None): return element + class shade(Operation): """ shade applies a normalization function followed by colormapping to From 65a44bd89ea84a7a9f1775b98500cc70d7d6a152 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 14 Dec 2017 14:34:43 +0000 Subject: [PATCH 27/28] Added vertex averaging to TriMesh plots --- holoviews/plotting/bokeh/graphs.py | 7 +++++++ holoviews/plotting/mpl/graphs.py | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/holoviews/plotting/bokeh/graphs.py b/holoviews/plotting/bokeh/graphs.py index 900c874ad2..7fbb18432c 100644 --- a/holoviews/plotting/bokeh/graphs.py +++ b/holoviews/plotting/bokeh/graphs.py @@ -313,6 +313,13 @@ class TriMeshPlot(GraphPlot): def get_data(self, element, ranges, style): # Ensure the edgepaths for the triangles are generated before plotting + simplex_dim = element.get_dimension(self.edge_color_index) + vertex_dim = element.nodes.get_dimension(self.edge_color_index) + if not isinstance(self.edge_color_index, int) and vertex_dim and not simplex_dim: + simplices = element.array([0, 1, 2]) + z = element.nodes.dimension_values(vertex_dim) + z = z[simplices].mean(axis=1) + element = element.add_dimension(vertex_dim, len(element.vdims), z, vdim=True) element.edgepaths return super(TriMeshPlot, self).get_data(element, ranges, style) diff --git a/holoviews/plotting/mpl/graphs.py b/holoviews/plotting/mpl/graphs.py index 2e69ba5411..cdc5996b50 100644 --- a/holoviews/plotting/mpl/graphs.py +++ b/holoviews/plotting/mpl/graphs.py @@ -181,3 +181,16 @@ class TriMeshPlot(GraphPlot): Whether the triangles should be drawn as filled.""") style_opts = GraphPlot.style_opts + ['edge_facecolors'] + + def get_data(self, element, ranges, style): + simplex_dim = element.get_dimension(self.edge_color_index) + vertex_dim = element.nodes.get_dimension(self.edge_color_index) + if not isinstance(self.edge_color_index, int) and vertex_dim and not simplex_dim: + simplices = element.array([0, 1, 2]) + z = element.nodes.dimension_values(vertex_dim) + z = z[simplices].mean(axis=1) + element = element.add_dimension(vertex_dim, len(element.vdims), z, vdim=True) + # Ensure the edgepaths for the triangles are generated before plotting + element.edgepaths + return super(TriMeshPlot, self).get_data(element, ranges, style) + From ddf38cd8880e3a9657b231dc955726631ffc06b2 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 14 Dec 2017 14:38:03 +0000 Subject: [PATCH 28/28] Revised TriMesh reference notebooks --- .../reference/elements/bokeh/TriMesh.ipynb | 30 ++++++++++++++----- .../elements/matplotlib/TriMesh.ipynb | 29 +++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/examples/reference/elements/bokeh/TriMesh.ipynb b/examples/reference/elements/bokeh/TriMesh.ipynb index 387638fd16..e2778a81a9 100644 --- a/examples/reference/elements/bokeh/TriMesh.ipynb +++ b/examples/reference/elements/bokeh/TriMesh.ipynb @@ -31,11 +31,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "A ``TriMesh`` represents a mesh of triangles represented as the simplices and nodes. The simplices represent the indices into the node data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths.\n", + "A ``TriMesh`` represents a mesh of triangles represented as the simplexes and vertexes. The simplexes represent the indices into the vertex data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node or vertex positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths attribute.\n", "\n", "Unlike a Graph each simplex is represented as the node indices of the three corners of each triangle rather than the usual source and target node.\n", "\n", - "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplices** and the points (or **nodes**)." + "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplexes** and the **vertices** (or **nodes**)." ] }, { @@ -52,6 +52,22 @@ "trimesh" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To make this easier TriMesh also provides a convenient ``from_vertices`` method, which will apply the Delaunay triangulation and construct the ``TriMesh`` for us:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.TriMesh.from_vertices(np.random.randn(100, 2))" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -93,8 +109,8 @@ "\n", "x = (radii*np.cos(angles)).flatten()\n", "y = (radii*np.sin(angles)).flatten()\n", - "nodes = np.column_stack([x, y])\n", "z = (np.cos(radii)*np.cos(angles*3.0)).flatten()\n", + "nodes = np.column_stack([x, y, z])\n", "\n", "# Apply Delaunay triangulation\n", "delauney = Delaunay(np.column_stack([x, y]))\n", @@ -103,8 +119,7 @@ "xmid = x[delauney.simplices].mean(axis=1)\n", "ymid = y[delauney.simplices].mean(axis=1)\n", "mask = np.where(xmid*xmid + ymid*ymid < min_radius*min_radius, 1, 0)\n", - "simplices = delauney.simplices[np.logical_not(mask)]\n", - "z = z[simplices].mean(axis=1)" + "simplices = delauney.simplices[np.logical_not(mask)]" ] }, { @@ -120,6 +135,7 @@ "metadata": {}, "outputs": [], "source": [ + "nodes = hv.Points(nodes, vdims='z')\n", "hv.TriMesh((simplices, nodes))" ] }, @@ -127,7 +143,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can also do something more interesting, e.g. adding a value dimension to the simplices, which we can color each triangle by." + "We can also do something more interesting, e.g. by adding a value dimension to the vertices and coloring the edges by the vertex averaged value using the ``edge_color_index`` plot option:" ] }, { @@ -137,7 +153,7 @@ "outputs": [], "source": [ "%%opts TriMesh [filled=True edge_color_index='z' width=400 height=400 tools=['hover'] inspection_policy='edges'] (cmap='viridis')\n", - "hv.TriMesh((np.column_stack([simplices, z]), nodes), vdims='z')" + "hv.TriMesh((simplices, nodes))" ] } ], diff --git a/examples/reference/elements/matplotlib/TriMesh.ipynb b/examples/reference/elements/matplotlib/TriMesh.ipynb index 189a0aa8d5..312d4d1a30 100644 --- a/examples/reference/elements/matplotlib/TriMesh.ipynb +++ b/examples/reference/elements/matplotlib/TriMesh.ipynb @@ -31,11 +31,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "A ``TriMesh`` represents a mesh of triangles represented as the simplices and nodes. The simplices represent the indices into the node data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths.\n", + "A ``TriMesh`` represents a mesh of triangles represented as the simplexes and vertexes. The simplexes represent the indices into the vertex data, made up of three indices per triangle. The mesh therefore follows a datastructure very similar to a graph, with the abstract connectivity between nodes stored on the ``TriMesh`` element itself, the node or vertex positions stored on a ``Nodes`` element and the concrete ``EdgePaths`` making up each triangle generated when required by accessing the edgepaths attribute.\n", "\n", "Unlike a Graph each simplex is represented as the node indices of the three corners of each triangle rather than the usual source and target node.\n", "\n", - "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplices** and the points (or **nodes**)." + "We will begin with a simple random mesh, generated by sampling some random integers and then applying Delaunay triangulation, which is available in SciPy. We can then construct the ``TriMesh`` by passing it the **simplexes** and the **vertices** (or **nodes**)." ] }, { @@ -52,6 +52,22 @@ "trimesh" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To make this easier TriMesh also provides a convenient ``from_vertices`` method, which will apply the Delaunay triangulation and construct the ``TriMesh`` for us:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.TriMesh.from_vertices(np.random.randn(100, 2))" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -93,8 +109,8 @@ "\n", "x = (radii*np.cos(angles)).flatten()\n", "y = (radii*np.sin(angles)).flatten()\n", - "nodes = np.column_stack([x, y])\n", "z = (np.cos(radii)*np.cos(angles*3.0)).flatten()\n", + "nodes = np.column_stack([x, y, z])\n", "\n", "# Apply Delaunay triangulation\n", "delauney = Delaunay(np.column_stack([x, y]))\n", @@ -103,8 +119,7 @@ "xmid = x[delauney.simplices].mean(axis=1)\n", "ymid = y[delauney.simplices].mean(axis=1)\n", "mask = np.where(xmid*xmid + ymid*ymid < min_radius*min_radius, 1, 0)\n", - "simplices = delauney.simplices[np.logical_not(mask)]\n", - "z = z[simplices].mean(axis=1)" + "simplices = delauney.simplices[np.logical_not(mask)]" ] }, { @@ -127,7 +142,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can also do something more interesting, e.g. adding a value dimension to the simplices, which we can color each triangle by." + "We can also do something more interesting, e.g. by adding a value dimension to the vertices and coloring the edges by the vertex averaged value using the ``edge_color_index`` plot option:" ] }, { @@ -137,7 +152,7 @@ "outputs": [], "source": [ "%%opts TriMesh [filled=True edge_color_index='z' fig_size=200] (cmap='viridis')\n", - "hv.TriMesh((np.column_stack([simplices, z]), nodes), vdims='z')" + "hv.TriMesh((simplices, hv.Points(nodes, vdims='z')))" ] } ],