Skip to content

Commit

Permalink
Merge be3f850 into 4c1b01d
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed Nov 23, 2017
2 parents 4c1b01d + be3f850 commit e04c6f3
Show file tree
Hide file tree
Showing 8 changed files with 392 additions and 13 deletions.
186 changes: 184 additions & 2 deletions holoviews/element/graphs.py
Expand Up @@ -5,11 +5,12 @@

from ..core import Dimension, Dataset, Element2D
from ..core.dimension import redim
from ..core.util import max_range
from ..core.util import max_range, search_indices
from ..core.operation import Operation
from .chart import Points
from .path import Path
from .util import split_path, pd, circular_layout, connect_edges, connect_edges_pd
from .util import (split_path, pd, circular_layout, connect_edges,
connect_edges_pd, quadratic_bezier)

try:
from datashader.layout import LayoutAlgorithm as ds_layout
Expand All @@ -33,6 +34,7 @@ def __call__(self, specs=None, **dimensions):
return redimmed.clone(new_data)



class layout_nodes(Operation):
"""
Accepts a Graph and lays out the corresponding nodes with the
Expand Down Expand Up @@ -392,3 +394,183 @@ class EdgePaths(Path):
"""

group = param.String(default='EdgePaths', constant=True)


class layout_chords(Operation):
"""
layout_chords computes the locations of each node on a circle and
the chords connecting them. The amount of radial angle devoted to
each node and the number of chords are scaled by the value
dimension of the Chord element. If the values are integers then
the number of chords is directly scaled by the value, if the
values are floats then the number of chords are apportioned such
that the lowest value edge is given one chord and all other nodes
are given nodes proportional to their weight. The max_chords
parameter scales the number of chords to be assigned to an edge.
The chords are computed by interpolating a cubic spline from the
source to the target node in the graph, the number of samples to
interpolate the spline with is given by the chord_samples
parameter.
"""

chord_samples = param.Integer(default=50, bounds=(0, None), doc="""
Number of samples per chord for the spline interpolation.""")

max_chords = param.Integer(default=500, doc="""
Maximum number of chords to render.""")

def _process(self, element, key=None):
nodes_el = element._nodes
if nodes_el:
idx_dim = nodes_el.kdims[-1]
nodes = nodes_el.dimension_values(idx_dim, expanded=False)
else:
source = element.dimension_values(0, expanded=False)
target = element.dimension_values(1, expanded=False)
nodes = np.unique(np.concatenate([source, target]))

# Compute indices and values for connectivity matrix
max_chords = self.p.max_chords
src, tgt = (element.dimension_values(i) for i in range(2))
src_idx = search_indices(src, nodes)
tgt_idx = search_indices(tgt, nodes)
if element.vdims:
values = element.dimension_values(2)
if values.dtype.kind not in 'if':
values = np.ones(len(element), dtype='int')
else:
if values.dtype.kind == 'f':
values = np.ceil(values*(1./values.min()))
if values.sum() > max_chords:
values = np.ceil((values/float(values.sum()))*max_chords)
values = values.astype('int64')
else:
values = np.ones(len(element), dtype='int')

# Compute connectivity matrix
matrix = np.zeros((len(nodes), len(nodes)))
for s, t, v in zip(src_idx, tgt_idx, values):
matrix[s, t] += v

# Compute weighted angular slice for each connection
weights_of_areas = (matrix.sum(axis=0) + matrix.sum(axis=1)) - matrix.diagonal()
areas_in_radians = (weights_of_areas / weights_of_areas.sum()) * (2 * np.pi)

# We add a zero in the begging for the cumulative sum
points = np.zeros((areas_in_radians.shape[0] + 1))
points[1:] = areas_in_radians
points = points.cumsum()

# Compute edge points
xs = np.cos(points)
ys = np.sin(points)

# Compute mid-points for node positions
midpoints = np.convolve(points, [0.5, 0.5], mode='valid')
mxs = np.cos(midpoints)
mys = np.sin(midpoints)

# Compute angles of chords in each edge
all_areas = []
for i in range(areas_in_radians.shape[0]):
n_conn = weights_of_areas[i]
p0, p1 = points[i], points[i+1]
angles = np.linspace(p0, p1, n_conn)
coords = list(zip(np.cos(angles), np.sin(angles)))
all_areas.append(coords)

# Draw each chord by interpolating quadratic splines
# Separate chords in each edge by NaNs
empty = np.array([[np.NaN, np.NaN]])
paths = []
for i in range(len(element)):
src_area, tgt_area = all_areas[src_idx[i]], all_areas[tgt_idx[i]]
subpaths = []
for _ in range(int(values[i])):
x0, y0 = src_area.pop()
x1, y1 = tgt_area.pop()
b = quadratic_bezier((x0, y0), (x1, y1), (x0/2., y0/2.),
(x1/2., y1/2.), steps=self.p.chord_samples)
subpaths.append(b)
subpaths.append(empty)
if subpaths:
paths.append(np.concatenate(subpaths[:-1]))

# Construct Chord element from components
if nodes_el:
if isinstance(nodes_el, Nodes):
kdims = nodes_el.kdims
else:
kdims = Nodes.kdims[:2]+[idx_dim]
vdims = [vd for vd in nodes_el.vdims if vd not in kdims]
values = tuple(nodes_el.dimension_values(vd) for vd in vdims)
else:
kdims = Nodes.kdims
values, vdims = (), []
nodes = Nodes((mxs, mys, nodes)+values, kdims=kdims, vdims=vdims)
edges = EdgePaths(paths)
chord = Chord((element.data, nodes, edges), compute=False)
chord._angles = points
return chord


class Chord(Graph):
"""
Chord is a special type of Graph which computes the locations of
each node on a circle and the chords connecting them. The amount
of radial angle devoted to each node and the number of chords are
scaled by a weight supplied as a value dimension.
If the values are integers then the number of chords is directly
scaled by the value, if the values are floats then the number of
chords are apportioned such that the lowest value edge is given
one chord and all other nodes are given nodes proportional to
their weight.
"""

group = param.String(default='Chord', constant=True)

def __init__(self, data, kdims=None, vdims=None, compute=True, **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 not None:
if not isinstance(nodes, Dataset):
if nodes.ndims == 3:
nodes = Nodes(nodes)
else:
nodes = Dataset(nodes)
nodes = nodes.clone(kdims=nodes.kdims[0],
vdims=nodes.kdims[1:])
node_info = nodes
super(Graph, self).__init__(edges, kdims=kdims, vdims=vdims, **params)
if compute:
self._nodes = nodes
chord = layout_chords(self)
self._nodes = chord.nodes
self._edgepaths = chord.edgepaths
self._angles = chord._angles
else:
if not isinstance(nodes, Nodes):
raise TypeError("Expected Nodes object in data, found %s."
% type(nodes))
self._nodes = nodes
if not isinstance(edgepaths, EdgePaths):
raise TypeError("Expected EdgePaths object in data, found %s."
% type(edgepaths))
self._edgepaths = edgepaths
self._validate()
self.redim = redim_graph(self, mode='dataset')


@property
def edgepaths(self):
return self._edgepaths


@property
def nodes(self):
return self._nodes
17 changes: 17 additions & 0 deletions holoviews/element/util.py
Expand Up @@ -247,6 +247,23 @@ def circular_layout(nodes):
return (x, y, nodes)


def quadratic_bezier(start, end, c0=(0, 0), c1=(0, 0), steps=50):
"""
Compute quadratic bezier spline given start and end coordinate and
two control points.
"""
steps = np.linspace(0, 1, steps)
sx, sy = start
ex, ey = end
cx0, cy0 = c0
cx1, cy1 = c1
xs = ((1-steps)**3*sx + 3*((1-steps)**2)*steps*cx0 +
3*(1-steps)*steps**2*cx1 + steps**3*ex)
ys = ((1-steps)**3*sy + 3*((1-steps)**2)*steps*cy0 +
3*(1-steps)*steps**2*cy1 + steps**3*ey)
return np.column_stack([xs, ys])


def connect_edges_pd(graph):
"""
Given a Graph element containing abstract edges compute edge
Expand Down
25 changes: 22 additions & 3 deletions holoviews/plotting/bokeh/__init__.py
Expand Up @@ -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,
Chord)
from ...core.options import Options, Cycle, Palette
from ...core.util import VersionError

Expand All @@ -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, ChordPlot
from .path import PathPlot, PolygonPlot, ContourPlot
from .plot import GridPlot, LayoutPlot, AdjointLayoutPlot
from .raster import RasterPlot, RGBPlot, HeatMapPlot, HSVPlot, QuadMeshPlot
Expand Down Expand Up @@ -95,6 +96,7 @@

# Graph Elements
Graph: GraphPlot,
Chord: ChordPlot,
Nodes: NodePlot,
EdgePaths: PathPlot,

Expand Down Expand Up @@ -196,7 +198,24 @@ def colormap_generator(palette):
node_nonselection_line_color='black',
edge_line_color='black', edge_line_width=2,
edge_nonselection_line_color='black',
edge_hover_line_color='limegreen')
edge_hover_line_color='limegreen',
edge_selection_line_color='limegreen')
options.Chord = Options('style', node_size=15, node_fill_color=Cycle(),
node_line_color='black',
node_selection_fill_color='limegreen',
node_nonselection_fill_color=Cycle(),
node_hover_line_color='black',
node_nonselection_line_color='black',
node_selection_line_color='black',
node_hover_fill_color='limegreen',
node_nonselection_alpha=0.2,
edge_nonselection_alpha=0.1,
edge_line_color='black', edge_line_width=1,
edge_nonselection_line_color='black',
edge_hover_line_color='limegreen',
edge_selection_line_color='limegreen',
label_text_font_size='8pt')
options.Chord = Options('plot', xaxis=None, yaxis=None)
options.Nodes = Options('style', line_color='black', color=Cycle(),
size=20, nonselection_fill_color=Cycle(),
selection_fill_color='limegreen',
Expand Down
11 changes: 7 additions & 4 deletions holoviews/plotting/bokeh/element.py
Expand Up @@ -945,7 +945,7 @@ def _init_glyphs(self, plot, element, ranges, source, data=None, mapping=None, s
source_cache[id(ds_data)] = source
self.handles[key+'_source'] = source
properties = self._glyph_properties(plot, element, source, ranges, style)
properties = self._process_properties(key, properties)
properties = self._process_properties(key, properties, mapping.get(key, {}))
with abbreviated_exception():
renderer, glyph = self._init_glyph(plot, mapping.get(key, {}), properties, key)
self.handles[key+'_glyph'] = glyph
Expand All @@ -959,13 +959,16 @@ def _init_glyphs(self, plot, element, ranges, source, data=None, mapping=None, s
self._update_glyph(renderer, properties, mapping.get(key, {}), glyph)


def _process_properties(self, key, properties):
def _process_properties(self, key, properties, mapping):
key = '_'.join(key.split('_')[:-1]) if '_' in key else key
style_group = self._style_groups[key]
group_props = {}
for k, v in properties.items():
if k in self.style_opts:
if k.split('_')[0] == style_group:
group = k.split('_')[0]
if group == style_group:
if k in mapping:
v = mapping[k]
k = '_'.join(k.split('_')[1:])
else:
continue
Expand Down Expand Up @@ -1002,7 +1005,7 @@ def order_fn(glyph):

if glyph:
properties = self._glyph_properties(plot, element, source, ranges, style)
properties = self._process_properties(key, properties)
properties = self._process_properties(key, properties, mapping[key])
renderer = self.handles.get(key+'_glyph_renderer')
with abbreviated_exception():
self._update_glyph(renderer, properties, mapping[key], glyph)
Expand Down

0 comments on commit e04c6f3

Please sign in to comment.