From ceb73bd715e9abebd37c921e938df85119cb0d63 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 13:54:01 -0400 Subject: [PATCH 01/18] Reformat code --- pyveplot/__init__.py | 76 +++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index c717037..f127deb 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -17,7 +17,6 @@ from math import sin, cos, atan2, degrees, radians, tan, sqrt - class Hiveplot: """ Base class for a Hive plot. @@ -62,16 +61,18 @@ class Hiveplot: h.save() """ - def __init__( self, filename): - self.dwg = svgwrite.Drawing(filename=filename, debug=True) - self.axes = [] + + def __init__(self, filename): + self.dwg = svgwrite.Drawing(filename=filename, debug=True) + self.axes = [] def draw_axes(self): for axis in self.axes: self.dwg.add(axis.getDwg()) - - def connect(self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, **kwargs): + def connect( + self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, **kwargs + ): """Draw edges as Bézier curves. Start and end points map to the coordinates of the given nodes @@ -95,43 +96,38 @@ def connect(self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, kwargs : extra SVG attributes for path element, optional Set or change attributes using key=value - """ - n0 = axis0.nodes[n0_index] - n1 = axis1.nodes[n1_index] + """ + n0 = axis0.nodes[n0_index] + n1 = axis1.nodes[n1_index] - pth = self.dwg.path(d="M %s %s" % (n0.x, n0.y), fill='none', **kwargs) # source + pth = self.dwg.path(d="M %s %s" % (n0.x, n0.y), fill="none", **kwargs) # source # compute source control point alfa = axis0.angle() + radians(source_angle) - length = sqrt( ((n0.x - axis0.start[0])**2) + ((n0.y-axis0.start[1])**2)) - x = axis0.start[0] + length * cos(alfa); - y = axis0.start[1] + length * sin(alfa); + length = sqrt(((n0.x - axis0.start[0]) ** 2) + ((n0.y - axis0.start[1]) ** 2)) + x = axis0.start[0] + length * cos(alfa) + y = axis0.start[1] + length * sin(alfa) - pth.push("C %s %s" % (x, y)) # first control point in path + pth.push("C %s %s" % (x, y)) # first control point in path # compute target control point alfa = axis1.angle() + radians(target_angle) - length = sqrt( ((n1.x - axis1.start[0])**2) + ((n1.y-axis1.start[1])**2)) - x = axis1.start[0] + length * cos(alfa); - y = axis1.start[1] + length * sin(alfa); - - pth.push("%s %s" % (x, y)) # second control point in path + length = sqrt(((n1.x - axis1.start[0]) ** 2) + ((n1.y - axis1.start[1]) ** 2)) + x = axis1.start[0] + length * cos(alfa) + y = axis1.start[1] + length * sin(alfa) - pth.push("%s %s" % (n1.x, n1.y)) # target + pth.push("%s %s" % (x, y)) # second control point in path + + pth.push("%s %s" % (n1.x, n1.y)) # target self.dwg.add(pth) - def save(self): self.draw_axes() self.dwg.save() - - - class Axis: - - def __init__( self, start=(0,0), end=(0,0), **kwargs): + def __init__(self, start=(0, 0), end=(0, 0), **kwargs): """Initialize Axis object with start, end positions and optional SVG attributes Parameters @@ -149,12 +145,11 @@ def __init__( self, start=(0,0), end=(0,0), **kwargs): """ self.start = start - self.end = end + self.end = end self.nodes = {} - self.dwg = svgwrite.Drawing() + self.dwg = svgwrite.Drawing() self.attrs = kwargs - def add_node(self, node, offset): """Add a Node object to nodes dictionary, calculating its coordinates using offset @@ -167,33 +162,29 @@ def add_node(self, node, offset): """ # calculate x,y from offset considering axis start and end points - width = self.end[0] - self.start[0] - height = self.end[1] - self.start[1] + width = self.end[0] - self.start[0] + height = self.end[1] - self.start[1] node.x = self.start[0] + (width * offset) node.y = self.start[1] + (height * offset) self.nodes[node.ID] = node - def draw(self): # draw axis - self.dwg.add( self.dwg.line( start = self.start, - end = self.end, - **self.attrs )) + self.dwg.add(self.dwg.line(start=self.start, end=self.end, **self.attrs)) # draw my nodes for node in self.nodes.values(): - self.dwg.add( node.getDwg() ) + self.dwg.add(node.getDwg()) def getDwg(self): self.draw() return self.dwg - def angle (self): + def angle(self): xDiff = self.end[0] - self.start[0] yDiff = self.end[1] - self.start[1] return atan2(yDiff, xDiff) - class Node: """Base class for Node objects. @@ -201,6 +192,7 @@ class Node: object in the *dwg* attribute. """ + def __init__(self, ID): """ Parameters @@ -211,11 +203,7 @@ def __init__(self, ID): self.x = 0 self.y = 0 # self.r = 1.5 - self.dwg = svgwrite.Drawing() - + self.dwg = svgwrite.Drawing() + def getDwg(self): return self.dwg - - - - From 3360b79dafbb520658fbff268f64c5e14bf45f54 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 13:57:04 -0400 Subject: [PATCH 02/18] remove unused imports --- pyveplot/__init__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index f127deb..4ce5327 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -13,8 +13,7 @@ to connect them. """ import svgwrite -from svgwrite import cm, mm -from math import sin, cos, atan2, degrees, radians, tan, sqrt +from math import sin, cos, atan2, radians, sqrt class Hiveplot: @@ -62,8 +61,9 @@ class Hiveplot: """ - def __init__(self, filename): - self.dwg = svgwrite.Drawing(filename=filename, debug=True) + def __init__( self, filename, **kwargs): + kwargs = {"debug": True, **kwargs} + self.dwg = svgwrite.Drawing(filename=filename, **kwargs) self.axes = [] def draw_axes(self): @@ -121,9 +121,14 @@ def connect( pth.push("%s %s" % (n1.x, n1.y)) # target self.dwg.add(pth) - def save(self): + def save(self, fpath=None, pretty=False): self.draw_axes() - self.dwg.save() + if fpath is None: + self.dwg.save(pretty) + elif hasattr(fpath, "write"): + self.dwg.write(fpath, pretty) + else: + self.dwg.saveas(fpath, pretty) class Axis: From fda8b3049c9c62c6fbe2d87f9d651ac1ac25240b Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 13:58:18 -0400 Subject: [PATCH 03/18] reformat --- pyveplot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index 4ce5327..b3e193c 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -61,7 +61,7 @@ class Hiveplot: """ - def __init__( self, filename, **kwargs): + def __init__(self, filename, **kwargs): kwargs = {"debug": True, **kwargs} self.dwg = svgwrite.Drawing(filename=filename, **kwargs) self.axes = [] From 4d32caa0463a4bd468d8ec8c4d2dacfbf6c4b89a Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 14:04:04 -0400 Subject: [PATCH 04/18] refactor classes out of __init__ --- pyveplot/__init__.py | 201 +------------------------------------------ pyveplot/classes.py | 201 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 199 deletions(-) create mode 100644 pyveplot/classes.py diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index b3e193c..83cfa78 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -12,203 +12,6 @@ turn contain an arbitrary number of *Node* objects, and a method to connect them. """ -import svgwrite -from math import sin, cos, atan2, radians, sqrt +from pyveplot.classes import Hiveplot, Axis, Node - -class Hiveplot: - """ - Base class for a Hive plot. - - Example - ------- - :: - from pyveplot import * - import networkx, random - - # a network - g = networkx.barabasi_albert_graph(50, 2) - - # our hiveplot object - h = Hiveplot( 'short_example.svg') - # start end - h.axes = [Axis( (200,200), (200,100), stroke="grey"), - Axis( (200,200), (300,300), stroke="blue"), - Axis( (200,200), (10,310), stroke="black")] - - # randomly distribute nodes in axes - for n in g.nodes(): - random.choice( h.axes ).add_node( Node(n), random.random() ) - - for e in g.edges(): - if (e[0] in h.axes[0].nodes) and (e[1] in h.axes[1].nodes): - # edges from axis0 to axis1 - h.connect(h.axes[0], e[0], 45, - h.axes[1], e[1], -45, - stroke_width='0.34', stroke_opacity='0.4', stroke='purple') - elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): - # edges from axis0 to axis2 - h.connect(h.axes[0], e[0], -45, - h.axes[2], e[1], 45, - stroke_width='0.34', stroke_opacity='0.4', stroke='red') - elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): - # edges from axis1 to axis2 - h.connect(h.axes[1], e[0], 15, - h.axes[2], e[1], -15, - stroke_width='0.34', stroke_opacity='0.4', stroke='magenta') - - h.save() - - """ - - def __init__(self, filename, **kwargs): - kwargs = {"debug": True, **kwargs} - self.dwg = svgwrite.Drawing(filename=filename, **kwargs) - self.axes = [] - - def draw_axes(self): - for axis in self.axes: - self.dwg.add(axis.getDwg()) - - def connect( - self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, **kwargs - ): - """Draw edges as Bézier curves. - - Start and end points map to the coordinates of the given nodes - which in turn are set when adding nodes to an axis with the - Axis.add_node() method, by using the placement information of - the axis and a specified offset from its start point. - - Control points are set at the same distance from the start (or end) - point of an axis as their corresponding nodes, but along an invisible - axis that shares its origin but diverges by a given angle. - - - Parameters - ---------- - axis0 : source Axis object - n0_index : key of source node in nodes dictionary of axis0 - source_angle : angle of departure for invisible axis that diverges from axis0 and holds first control points - axis1 : target Axis object - n1_index : key of target node in nodes dictionary of axis1 - target_angle : angle of departure for invisible axis that diverges from axis1 and holds second control points - kwargs : extra SVG attributes for path element, optional - Set or change attributes using key=value - - """ - n0 = axis0.nodes[n0_index] - n1 = axis1.nodes[n1_index] - - pth = self.dwg.path(d="M %s %s" % (n0.x, n0.y), fill="none", **kwargs) # source - - # compute source control point - alfa = axis0.angle() + radians(source_angle) - length = sqrt(((n0.x - axis0.start[0]) ** 2) + ((n0.y - axis0.start[1]) ** 2)) - x = axis0.start[0] + length * cos(alfa) - y = axis0.start[1] + length * sin(alfa) - - pth.push("C %s %s" % (x, y)) # first control point in path - - # compute target control point - alfa = axis1.angle() + radians(target_angle) - length = sqrt(((n1.x - axis1.start[0]) ** 2) + ((n1.y - axis1.start[1]) ** 2)) - x = axis1.start[0] + length * cos(alfa) - y = axis1.start[1] + length * sin(alfa) - - pth.push("%s %s" % (x, y)) # second control point in path - - pth.push("%s %s" % (n1.x, n1.y)) # target - self.dwg.add(pth) - - def save(self, fpath=None, pretty=False): - self.draw_axes() - if fpath is None: - self.dwg.save(pretty) - elif hasattr(fpath, "write"): - self.dwg.write(fpath, pretty) - else: - self.dwg.saveas(fpath, pretty) - - -class Axis: - def __init__(self, start=(0, 0), end=(0, 0), **kwargs): - """Initialize Axis object with start, end positions and optional SVG attributes - - Parameters - ---------- - start : ( x, y ) start point of the axis - end : (x1, y1) end point of the axis - kwargs : extra SVG attributes for line element, optional - Set or change attributes using key=value - - Example - ------- - >>> axis0 = Axis( (150, 200), # start - (150, 0), # end - stroke="black", stroke_width=1.5) # pass SVG attributes of axes - - """ - self.start = start - self.end = end - self.nodes = {} - self.dwg = svgwrite.Drawing() - self.attrs = kwargs - - def add_node(self, node, offset): - """Add a Node object to nodes dictionary, calculating its coordinates using offset - - Parameters - ---------- - node : a Node object - offset : float - number between 0 and 1 that sets the distance - from the start point at which the node will be placed - - """ - # calculate x,y from offset considering axis start and end points - width = self.end[0] - self.start[0] - height = self.end[1] - self.start[1] - node.x = self.start[0] + (width * offset) - node.y = self.start[1] + (height * offset) - self.nodes[node.ID] = node - - def draw(self): - # draw axis - self.dwg.add(self.dwg.line(start=self.start, end=self.end, **self.attrs)) - # draw my nodes - for node in self.nodes.values(): - self.dwg.add(node.getDwg()) - - def getDwg(self): - self.draw() - return self.dwg - - def angle(self): - xDiff = self.end[0] - self.start[0] - yDiff = self.end[1] - self.start[1] - return atan2(yDiff, xDiff) - - -class Node: - """Base class for Node objects. - - Holds coordinates for node placement and a svgwrite.Drawing() - object in the *dwg* attribute. - - """ - - def __init__(self, ID): - """ - Parameters - ---------- - ID: a unique key for the nodes dict of an axis. - """ - self.ID = ID - self.x = 0 - self.y = 0 - # self.r = 1.5 - self.dwg = svgwrite.Drawing() - - def getDwg(self): - return self.dwg +__all__ = ["Hiveplot", "Axis", "Node"] diff --git a/pyveplot/classes.py b/pyveplot/classes.py new file mode 100644 index 0000000..70d7884 --- /dev/null +++ b/pyveplot/classes.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +import svgwrite +from math import sin, cos, atan2, radians, sqrt + + +class Hiveplot: + """ + Base class for a Hive plot. + + Example + ------- + :: + from pyveplot import * + import networkx, random + + # a network + g = networkx.barabasi_albert_graph(50, 2) + + # our hiveplot object + h = Hiveplot( 'short_example.svg') + # start end + h.axes = [Axis( (200,200), (200,100), stroke="grey"), + Axis( (200,200), (300,300), stroke="blue"), + Axis( (200,200), (10,310), stroke="black")] + + # randomly distribute nodes in axes + for n in g.nodes(): + random.choice( h.axes ).add_node( Node(n), random.random() ) + + for e in g.edges(): + if (e[0] in h.axes[0].nodes) and (e[1] in h.axes[1].nodes): + # edges from axis0 to axis1 + h.connect(h.axes[0], e[0], 45, + h.axes[1], e[1], -45, + stroke_width='0.34', stroke_opacity='0.4', stroke='purple') + elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): + # edges from axis0 to axis2 + h.connect(h.axes[0], e[0], -45, + h.axes[2], e[1], 45, + stroke_width='0.34', stroke_opacity='0.4', stroke='red') + elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): + # edges from axis1 to axis2 + h.connect(h.axes[1], e[0], 15, + h.axes[2], e[1], -15, + stroke_width='0.34', stroke_opacity='0.4', stroke='magenta') + + h.save() + + """ + + def __init__(self, filename, **kwargs): + kwargs = {"debug": True, **kwargs} + self.dwg = svgwrite.Drawing(filename=filename, **kwargs) + self.axes = [] + + def draw_axes(self): + for axis in self.axes: + self.dwg.add(axis.getDwg()) + + def connect( + self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, **kwargs + ): + """Draw edges as Bézier curves. + + Start and end points map to the coordinates of the given nodes + which in turn are set when adding nodes to an axis with the + Axis.add_node() method, by using the placement information of + the axis and a specified offset from its start point. + + Control points are set at the same distance from the start (or end) + point of an axis as their corresponding nodes, but along an invisible + axis that shares its origin but diverges by a given angle. + + + Parameters + ---------- + axis0 : source Axis object + n0_index : key of source node in nodes dictionary of axis0 + source_angle : angle of departure for invisible axis that diverges from axis0 and holds first control points + axis1 : target Axis object + n1_index : key of target node in nodes dictionary of axis1 + target_angle : angle of departure for invisible axis that diverges from axis1 and holds second control points + kwargs : extra SVG attributes for path element, optional + Set or change attributes using key=value + + """ + n0 = axis0.nodes[n0_index] + n1 = axis1.nodes[n1_index] + + pth = self.dwg.path(d="M %s %s" % (n0.x, n0.y), fill="none", **kwargs) # source + + # compute source control point + alfa = axis0.angle() + radians(source_angle) + length = sqrt(((n0.x - axis0.start[0]) ** 2) + ((n0.y - axis0.start[1]) ** 2)) + x = axis0.start[0] + length * cos(alfa) + y = axis0.start[1] + length * sin(alfa) + + pth.push("C %s %s" % (x, y)) # first control point in path + + # compute target control point + alfa = axis1.angle() + radians(target_angle) + length = sqrt(((n1.x - axis1.start[0]) ** 2) + ((n1.y - axis1.start[1]) ** 2)) + x = axis1.start[0] + length * cos(alfa) + y = axis1.start[1] + length * sin(alfa) + + pth.push("%s %s" % (x, y)) # second control point in path + + pth.push("%s %s" % (n1.x, n1.y)) # target + self.dwg.add(pth) + + def save(self, fpath=None, pretty=False): + self.draw_axes() + if fpath is None: + self.dwg.save(pretty) + elif hasattr(fpath, "write"): + self.dwg.write(fpath, pretty) + else: + self.dwg.saveas(fpath, pretty) + + +class Axis: + def __init__(self, start=(0, 0), end=(0, 0), **kwargs): + """Initialize Axis object with start, end positions and optional SVG attributes + + Parameters + ---------- + start : ( x, y ) start point of the axis + end : (x1, y1) end point of the axis + kwargs : extra SVG attributes for line element, optional + Set or change attributes using key=value + + Example + ------- + >>> axis0 = Axis( (150, 200), # start + (150, 0), # end + stroke="black", stroke_width=1.5) # pass SVG attributes of axes + + """ + self.start = start + self.end = end + self.nodes = {} + self.dwg = svgwrite.Drawing() + self.attrs = kwargs + + def add_node(self, node, offset): + """Add a Node object to nodes dictionary, calculating its coordinates using offset + + Parameters + ---------- + node : a Node object + offset : float + number between 0 and 1 that sets the distance + from the start point at which the node will be placed + + """ + # calculate x,y from offset considering axis start and end points + width = self.end[0] - self.start[0] + height = self.end[1] - self.start[1] + node.x = self.start[0] + (width * offset) + node.y = self.start[1] + (height * offset) + self.nodes[node.ID] = node + + def draw(self): + # draw axis + self.dwg.add(self.dwg.line(start=self.start, end=self.end, **self.attrs)) + # draw my nodes + for node in self.nodes.values(): + self.dwg.add(node.getDwg()) + + def getDwg(self): + self.draw() + return self.dwg + + def angle(self): + xDiff = self.end[0] - self.start[0] + yDiff = self.end[1] - self.start[1] + return atan2(yDiff, xDiff) + + +class Node: + """Base class for Node objects. + + Holds coordinates for node placement and a svgwrite.Drawing() + object in the *dwg* attribute. + + """ + + def __init__(self, ID): + """ + Parameters + ---------- + ID: a unique key for the nodes dict of an axis. + """ + self.ID = ID + self.x = 0 + self.y = 0 + # self.r = 1.5 + self.dwg = svgwrite.Drawing() + + def getDwg(self): + return self.dwg From 82f1a6de11a7960758b3c77690a224be31026669 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 14:04:43 -0400 Subject: [PATCH 05/18] improve backwards compatibility --- pyveplot/classes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyveplot/classes.py b/pyveplot/classes.py index 70d7884..f6f57ab 100644 --- a/pyveplot/classes.py +++ b/pyveplot/classes.py @@ -3,7 +3,7 @@ from math import sin, cos, atan2, radians, sqrt -class Hiveplot: +class Hiveplot(object): """ Base class for a Hive plot. @@ -49,7 +49,7 @@ class Hiveplot: """ def __init__(self, filename, **kwargs): - kwargs = {"debug": True, **kwargs} + kwargs = dict(debug=True, **kwargs) self.dwg = svgwrite.Drawing(filename=filename, **kwargs) self.axes = [] @@ -118,7 +118,7 @@ def save(self, fpath=None, pretty=False): self.dwg.saveas(fpath, pretty) -class Axis: +class Axis(object): def __init__(self, start=(0, 0), end=(0, 0), **kwargs): """Initialize Axis object with start, end positions and optional SVG attributes @@ -177,7 +177,7 @@ def angle(self): return atan2(yDiff, xDiff) -class Node: +class Node(object): """Base class for Node objects. Holds coordinates for node placement and a svgwrite.Drawing() From e75730e564d90e106ac588848267c3cf96b65380 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 15:53:30 -0400 Subject: [PATCH 06/18] Add utilities - UnitConverter to convert SVG units (px, pt, cm etc.) - PolarPlotter to convert polar coordinates into x, y --- pyveplot/utils.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 pyveplot/utils.py diff --git a/pyveplot/utils.py b/pyveplot/utils.py new file mode 100644 index 0000000..b9a111f --- /dev/null +++ b/pyveplot/utils.py @@ -0,0 +1,165 @@ +from numbers import Number +from math import sqrt + +try: + from numpy import sin, cos, radians +except ImportError: + from math import sin, cos, radians + + +import svgwrite + + +def or_(orig, default): + """Use ``orig``, unless it is None: then use ``default``""" + return default if orig is None else orig + + +class UnitConverter(object): + """Measurements taken from https://oreillymedia.github.io/Using_SVG/guide/units.html""" + + table = { + "px": 1, + "in": 96, + "cm": 37.795, + "mm": 3.7795, + "pt": 1 / 0.75, + "pc": 16, + None: 1, + } + + def __init__(self, width=None, height=None, font_size=None): + """Class for converting between SVG units""" + self.table = self.table.copy() + self.font_size = font_size # pt + if self.font_size is not None: + self.table["em"] = self.table["pt"] * self.font_size + self.table["en"] = self.table["em"] / 2 + + self.width = None if width is None else self.to_px(width) + self.height = None if height is None else self.to_px(height) + + if self.width is not None: + self.table["vw"] = self.width / 100 + + if self.height is not None: + self.table["vh"] = self.height / 100 + if self.width is not None: + self.table["vmin"], self.table["vmax"] = [ + x / 100 for x in sorted((self.width, self.height)) + ] + self.table["%"] = (1 / 100) * ( + sqrt(self.width ** 2 + self.height ** 2) / sqrt(2) + ) + + def to_px(self, val, unit=None): + """Convert the given value into px. Compatible with numpy. + + If ``val`` is a string with units, these will be used: + otherwise, the unit can be explicitly passed. + + Parameters + ---------- + val + unit + + Returns + ------- + float + """ + detected_unit = None + if isinstance(val, str): + val, detected_unit = svgwrite.utils.split_coordinate(val) + if detected_unit is None: + detected_unit = unit + return val * self.table[detected_unit] + + def from_px(self, val, unit): + """Convert the give px value into the requested unit. + + If the passed ``unit`` is None, the px value will be returned as a float. + + If the passed ``val`` is a number (or unitless string), + a string with units will be returned. + + If the passed ``val`` is a numpy array, + a numpy array will be returned: there will be no unit strings. + + Parameters + ---------- + val: float + px value to convert + unit: str, optional + If none, the px value will be returned + + Returns + ------- + + """ + converted = float(val) / self.table[unit] + if isinstance(val, Number) and unit: + return str(converted) + unit + else: + return converted + + def __call__(self, val, tgt=None, src=None): + return self.from_px(self.to_px(val, src), tgt) + + def _kwargs(self): + return {"width": self.width, "height": self.height, "font_size": self.font_size} + + def copy(self, **kwargs): + """Copy the UnitConverter, changing its init args as given.""" + return type(self)(**dict(self._kwargs(), **kwargs)) + + +class PolarPlotter(object): + def __init__(self, x=0, y=0, use_radians=True, **kwargs): + """Class for converting polar coordinates into Cartesian coordinates. + + Parameters + ---------- + x: float | str + x location of origin, in any SVG units + y: float | str + y location of origin, in any SVG units + use_radians: bool, optional + Whether to use radians for angles by default (default True) + kwargs: + ``width``, ``height``, ``font_size`` kwargs to pass to internal ``UnitConverter`` + """ + self.converter = UnitConverter(**kwargs) + self.x = self.converter.to_px(x) + self.y = self.converter.to_px(y) + self.use_radians = use_radians + + def __call__(self, distance, angle, use_radians=None): + """Convert polar coordinates into Cartesian coordinates in px. + + Compatible with numpy arrays. + + Parameters + ---------- + distance: float | str + radial distance, in any SVG units + angle: float + polar angle from the 12 o'clock position + use_radians: bool, optional + Whether to use radians for angles. By default, use the object's default. + + Returns + ------- + tuple + x, y coordinates in px/user units from the top left + """ + if use_radians is None: + angle = self.use_radians + if not use_radians: + angle = radians(angle) + + distance = self.converter.to_px(distance) + + x = self.x + distance * sin(angle) + # negative because SVG thinks from the top left, not bottom + y = self.y - distance * cos(angle) + return x, y From 544a2ea582b05a85b56a6b65aced69246d721fba Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 15:57:04 -0400 Subject: [PATCH 07/18] Update core classes General ------- - replace camelCase with snake_case - improve docs Axis ---- - add_node() can instantiate its own Nodes, and returns them - Allow unit conversion in constructor Hiveplot -------- - Remove necessity of filename - Allow passing of kwargs to root Drawing - Shared UnitConverter and PolarPlotter - Changed draw_axes() to draw() for consistency - Added get_dwg() for consistency - Allowed saving to any path or file-like object, with pretty flag - add_axis() and add_axis_polar() methods for convenience --- pyveplot/classes.py | 264 +++++++++++++++++++++++++++++++------------- 1 file changed, 185 insertions(+), 79 deletions(-) diff --git a/pyveplot/classes.py b/pyveplot/classes.py index f6f57ab..173e32a 100644 --- a/pyveplot/classes.py +++ b/pyveplot/classes.py @@ -1,9 +1,119 @@ # -*- coding: utf-8 -*- +import warnings + import svgwrite from math import sin, cos, atan2, radians, sqrt +from pyveplot.utils import UnitConverter, PolarPlotter + + +UNITS = UnitConverter() + + +class Node(object): + """Base class for Node objects. + + Holds coordinates for node placement and a svgwrite.Drawing() + object in the *dwg* attribute. + + """ + + def __init__(self, ID): + """ + Parameters + ---------- + ID: a unique key for the nodes dict of an axis. + """ + self.ID = ID + self.x = 0 + self.y = 0 + # self.r = 1.5 + self.dwg = svgwrite.Drawing() + + def get_dwg(self): + return self.dwg + + def getDwg(self): + warnings.warn( + "getDwg is deprecated in favour of the PEP8-compliant get_dwg", + DeprecationWarning, + ) + return self.get_dwg() + + +class Axis(object): + node_cls = Node + + def __init__(self, start=(0, 0), end=(0, 0), **kwargs): + """Initialize Axis object with start, end positions and optional SVG attributes + + Parameters + ---------- + start : ( x, y ) start point of the axis + end : (x1, y1) end point of the axis + kwargs : extra SVG attributes for line element, optional + Set or change attributes using key=value + + Example + ------- + >>> axis0 = Axis( (150, 200), # start + (150, 0), # end + stroke="black", stroke_width=1.5) # pass SVG attributes of axes + + """ + self.start = tuple(UNITS.to_px(i) for i in start) + self.end = tuple(UNITS.to_px(i) for i in end) + self.nodes = {} + self.dwg = svgwrite.Drawing() + self.attrs = kwargs + + def add_node(self, node, offset): + """Add a Node object to nodes dictionary, calculating its coordinates using offset + + Parameters + ---------- + node : a Node object | hashable + offset : float + number between 0 and 1 that sets the distance + from the start point at which the node will be placed + + Returns + ------- + Node + """ + if not isinstance(node, Node): + node = Node(node) + # calculate x,y from offset considering axis start and end points + width = self.end[0] - self.start[0] + height = self.end[1] - self.start[1] + node.x = self.start[0] + (width * offset) + node.y = self.start[1] + (height * offset) + self.nodes[node.ID] = node + return node + + def draw(self): + # draw axis + self.dwg.add(self.dwg.line(start=self.start, end=self.end, **self.attrs)) + # draw my nodes + for node in self.nodes.values(): + self.dwg.add(node.get_dwg()) + + def get_dwg(self): + self.draw() + return self.dwg + + def getDwg(self): + warnings.warn("getDwg is deprecated in favour of the PEP8-compliant get_dwg") + return self.get_dwg() + + def angle(self): + x_diff = self.end[0] - self.start[0] + y_diff = self.end[1] - self.start[1] + return atan2(y_diff, x_diff) + class Hiveplot(object): + axis_cls = Axis """ Base class for a Hive plot. @@ -48,14 +158,35 @@ class Hiveplot(object): """ - def __init__(self, filename, **kwargs): + def __init__(self, filename=None, origin=None, **kwargs): kwargs = dict(debug=True, **kwargs) - self.dwg = svgwrite.Drawing(filename=filename, **kwargs) + if filename: + kwargs["filename"] = filename + self.dwg = svgwrite.Drawing(**kwargs) + self.units = UnitConverter( + **{key: kwargs.get(key) for key in ("width", "height", "font_size")} + ) self.axes = [] + self.origin = None + self.coords = None + + if origin: + self._set_origin(*origin) + + def _set_origin(self, x, y): + self.origin = self.units.to_px(x), self.units.to_px(y) + self.coords = PolarPlotter(*self.origin, **self.units._kwargs()) def draw_axes(self): + warnings.warn( + "draw_axes() is deprecated in favour of the more consistent draw()", + DeprecationWarning, + ) + return self.draw() + + def draw(self): for axis in self.axes: - self.dwg.add(axis.getDwg()) + self.dwg.add(axis.get_dwg()) def connect( self, axis0, n0_index, source_angle, axis1, n1_index, target_angle, **kwargs @@ -108,94 +239,69 @@ def connect( pth.push("%s %s" % (n1.x, n1.y)) # target self.dwg.add(pth) - def save(self, fpath=None, pretty=False): - self.draw_axes() - if fpath is None: - self.dwg.save(pretty) - elif hasattr(fpath, "write"): - self.dwg.write(fpath, pretty) - else: - self.dwg.saveas(fpath, pretty) - + def get_dwg(self): + self.draw() + return self.dwg -class Axis(object): - def __init__(self, start=(0, 0), end=(0, 0), **kwargs): - """Initialize Axis object with start, end positions and optional SVG attributes + def save(self, fpath=None, pretty=False): + """Save the SVG hive plot. Parameters ---------- - start : ( x, y ) start point of the axis - end : (x1, y1) end point of the axis - kwargs : extra SVG attributes for line element, optional - Set or change attributes using key=value - - Example - ------- - >>> axis0 = Axis( (150, 200), # start - (150, 0), # end - stroke="black", stroke_width=1.5) # pass SVG attributes of axes - + fpath: str | Path, optional + Where to save the file, defaulting to the filename given on instantiation. + pretty: bool + Whether to format the SVG file before write. """ - self.start = start - self.end = end - self.nodes = {} - self.dwg = svgwrite.Drawing() - self.attrs = kwargs + dwg = self.get_dwg() + if fpath is None: + dwg.save(pretty) + elif hasattr(fpath, "write"): + dwg.write(fpath, pretty) + else: + dwg.saveas(fpath, pretty) - def add_node(self, node, offset): - """Add a Node object to nodes dictionary, calculating its coordinates using offset + def add_axis(self, start=(0, 0), end=(0, 0), **kwargs): + """Add axis to hive plot Parameters ---------- - node : a Node object - offset : float - number between 0 and 1 that sets the distance - from the start point at which the node will be placed - + start: tuple of (str | float, str | float) + x and y coordinates, in any SVG unit + end: tuple of (str | float, str | float) + see ``start`` + kwargs + Supplied to the Axis constructor + + Returns + ------- + Axis """ - # calculate x,y from offset considering axis start and end points - width = self.end[0] - self.start[0] - height = self.end[1] - self.start[1] - node.x = self.start[0] + (width * offset) - node.y = self.start[1] + (height * offset) - self.nodes[node.ID] = node - - def draw(self): - # draw axis - self.dwg.add(self.dwg.line(start=self.start, end=self.end, **self.attrs)) - # draw my nodes - for node in self.nodes.values(): - self.dwg.add(node.getDwg()) - - def getDwg(self): - self.draw() - return self.dwg - - def angle(self): - xDiff = self.end[0] - self.start[0] - yDiff = self.end[1] - self.start[1] - return atan2(yDiff, xDiff) + start = tuple(self.units.to_px(i) for i in start) + end = tuple(self.units.to_px(i) for i in end) + ax = self.axis_cls(start, end, **kwargs) + self.axes.append(ax) + return ax + def add_axis_polar(self, start=(0, 0), end=(0, 0), use_radians=None, **kwargs): + """Add axis to hive plot, using polar coordinates. -class Node(object): - """Base class for Node objects. - - Holds coordinates for node placement and a svgwrite.Drawing() - object in the *dwg* attribute. - - """ - - def __init__(self, ID): - """ Parameters ---------- - ID: a unique key for the nodes dict of an axis. + start: tuple of (str | float, float) + radius and angle coordinates, with radius in any SVG unit + and angle from the 12 o'clock position + end: tuple of (str | float, float) + see ``start`` + use_radians: bool, optional + Whether the angle is in radians (default True) + kwargs + Supplied to the Axis constructor + + Returns + ------- + Axis """ - self.ID = ID - self.x = 0 - self.y = 0 - # self.r = 1.5 - self.dwg = svgwrite.Drawing() - - def getDwg(self): - return self.dwg + start = self.coords(*start, use_radians=use_radians) + end = self.coords(*end, use_radians=use_radians) + return self.add_axis(start, end, **kwargs) From 2eb044325c9c3f4012191613a38070d26f7649c8 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:38:15 -0400 Subject: [PATCH 08/18] add requirements file --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a732ca1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +svgwrite +numpy +networkx +pytest From 3b46fd3b112bb6d992518477c54f901942c8a553 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:38:34 -0400 Subject: [PATCH 09/18] add regression test for short_example --- test/__init__.py | 0 test/regression_test.py | 61 ++++++++++++ test/short_example.svg | 209 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 test/__init__.py create mode 100644 test/regression_test.py create mode 100644 test/short_example.svg diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/regression_test.py b/test/regression_test.py new file mode 100644 index 0000000..9f82901 --- /dev/null +++ b/test/regression_test.py @@ -0,0 +1,61 @@ +import os +import random + +import networkx + +from pyveplot import Hiveplot, Axis, Node + + +TEST_DIR = os.path.dirname(os.path.realpath(__file__)) + + +def assert_same_contents(fpath1, fpath2): + with open(fpath1) as f1, open(fpath2) as f2: + assert f1.read() == f2.read() + + +def test_short_example(tmpdir): + fname = "short_example.svg" + ref_fpath = os.path.join(TEST_DIR, fname) + test_fpath = str(tmpdir.join(fname)) + + # seed must be the same + random.seed(1) + + # a network + g = networkx.barabasi_albert_graph(50, 2, seed=2) + + # our hiveplot object + h = Hiveplot(test_fpath) + # start end + axis0 = Axis((200, 200), (200, 100), stroke="grey") + axis1 = Axis((200, 200), (300, 300), stroke="blue") + axis2 = Axis((200, 200), (10, 310), stroke="black") + + h.axes = [axis0, axis1, axis2] + + # randomly distribute nodes in axes + for n in g.nodes(): + node = Node(n) + random.choice(h.axes).add_node(node, random.random()) + + for e in g.edges(): + if (e[0] in axis0.nodes) and (e[1] in axis1.nodes): # edges from axis0 to axis1 + h.connect(axis0, e[0], 45, + axis1, e[1], -45, + stroke_width='0.34', stroke_opacity='0.4', + stroke='purple') + elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): # edges from axis0 to axis2 + h.connect(axis0, e[0], -45, + axis2, e[1], 45, + stroke_width='0.34', stroke_opacity='0.4', + stroke='red') + elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): # edges from axis1 to axis2 + h.connect(axis1, e[0], 15, + axis2, e[1], -15, + stroke_width='0.34', stroke_opacity='0.4', + stroke='magenta') + + h.save(pretty=True) + + assert_same_contents(ref_fpath, test_fpath) diff --git a/test/short_example.svg b/test/short_example.svg new file mode 100644 index 0000000..b8f142f --- /dev/null +++ b/test/short_example.svg @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7b40c635a1cc61e018026ddbb9755c42271e27eb Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:43:03 -0400 Subject: [PATCH 10/18] tox and travis --- .travis.yml | 16 ++++++++++++++++ requirements.txt | 1 + tox.ini | 8 ++++++++ 3 files changed, 25 insertions(+) create mode 100644 .travis.yml create mode 100644 tox.ini diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0a33029 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,16 @@ +language: python + +matrix: + include: + - python: 2.7 + - python: 3.5 + - python: 3.6 + - python: 3.7 + dist: xenial + sudo: true + +# command to install dependencies +install: pip install tox-travis + +# command to run tests +script: tox diff --git a/requirements.txt b/requirements.txt index a732ca1..6f0af03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ svgwrite numpy networkx pytest +tox diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3d7fa85 --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py{27,35,36,37} + +[testenv] +deps=-rrequirements.txt +commands= + pip install . + pytest --verbose From 5d2e31d8da3e52358a6a05caed15bd88dc8d6f49 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:46:17 -0400 Subject: [PATCH 11/18] tidy up setup.py --- setup.py | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/setup.py b/setup.py index 8ad32ea..a6565be 100644 --- a/setup.py +++ b/setup.py @@ -1,27 +1,31 @@ from setuptools import setup + def readme(): - with open('README.rst') as f: + with open("README.rst") as f: return f.read() - -setup(name='pyveplot', - version='0.6', - description='SVG Hiveplot Python API', - long_description=readme(), - classifiers=[ - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Programming Language :: Python :: 2.7', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Intended Audience :: Science/Research', - 'Topic :: Scientific/Engineering', - ], - url='http://github.com/CSB-IG/pyveplot', - author='Rodrigo Garcia', - author_email='rgarcia@inmegen.gob.mx', - license='GPLv3', - packages=['pyveplot'], - install_requires=[ 'svgwrite' ], -# scripts=['bin/hiveplot.py'], -# include_package_data=True, - zip_safe=False) + +setup( + name="pyveplot", + version="0.6", + description="SVG Hiveplot Python API", + long_description=readme(), + classifiers=[ + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Topic :: Scientific/Engineering :: Information Analysis", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering", + ], + url="http://github.com/CSB-IG/pyveplot", + author="Rodrigo Garcia", + author_email="rgarcia@inmegen.gob.mx", + license="GPLv3", + packages=["pyveplot"], + install_requires=["svgwrite"], + zip_safe=False, +) From 3dec8d1b61ca6c1311169e5136555a008e8a3e33 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:47:18 -0400 Subject: [PATCH 12/18] add __version__ --- pyveplot/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index 83cfa78..c8a9b44 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -14,4 +14,8 @@ """ from pyveplot.classes import Hiveplot, Axis, Node + +__version__ = "0.6.0" +__version_info__ = tuple(int(i) for i in __version__.split('.')) + __all__ = ["Hiveplot", "Axis", "Node"] From 92fe6d0dc6346d77134dda6ece0803f8418142fd Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 16:51:14 -0400 Subject: [PATCH 13/18] compatible printing --- example_note.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example_note.py b/example_note.py index 540eedc..4bf2190 100644 --- a/example_note.py +++ b/example_note.py @@ -96,7 +96,7 @@ a2_color = Color('#5a004c') a3_color = Color('#336699') # place nodes on axes -print "place nodes on axes" +print("place nodes on axes") for n,d in sorted_dg: nd = Node(n) @@ -222,5 +222,5 @@ fill='none') -print "writing file" +print("writing file") h.save() From 9fe79f508fe1ccf6476ec7e3079c526d635cc75b Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 27 Mar 2019 17:51:55 -0400 Subject: [PATCH 14/18] Fix tests - CPython 3.0 <= ver <= 3.5 SVGs are non-deterministic because of dict ordering - Python 2 produces visibly identical but numerically different path elements: xfailed this for now --- test/.gitignore | 1 + test/conftest.py | 8 ++++++++ test/regression_test.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 test/.gitignore create mode 100644 test/conftest.py diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..284a532 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +*_*short_example.svg diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..906b6f4 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,8 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--dump", action="store_true", default=False, + help="save output of regression test" + ) diff --git a/test/regression_test.py b/test/regression_test.py index 9f82901..6737123 100644 --- a/test/regression_test.py +++ b/test/regression_test.py @@ -1,6 +1,11 @@ import os import random +import shutil +import sys +from datetime import datetime +from collections import Counter +import pytest import networkx from pyveplot import Hiveplot, Axis, Node @@ -14,7 +19,12 @@ def assert_same_contents(fpath1, fpath2): assert f1.read() == f2.read() -def test_short_example(tmpdir): +def assert_same_lines(fpath1, fpath2): + with open(fpath1) as f1, open(fpath2) as f2: + assert Counter(f1.readlines()) == Counter(f2.readlines()) + + +def test_short_example(tmpdir, request): fname = "short_example.svg" ref_fpath = os.path.join(TEST_DIR, fname) test_fpath = str(tmpdir.join(fname)) @@ -58,4 +68,19 @@ def test_short_example(tmpdir): h.save(pretty=True) - assert_same_contents(ref_fpath, test_fpath) + try: + if sys.version_info >= (3, 6): + assert_same_contents(ref_fpath, test_fpath) + # elif (3, 0) < sys.version_info < (3, 6): + else: + # cannot check for exact identity in CPython < 3.6 due to + # non-deterministic dict ordering + assert_same_lines(ref_fpath, test_fpath) + except AssertionError: + if request.config.getoption("--dump"): + ver = '.'.join(str(i) for i in sys.version_info) + dt = datetime.now().isoformat().replace(':', '-') + shutil.copyfile(test_fpath, os.path.join(TEST_DIR, '_'.join([ver, dt, fname]))) + if sys.version_info < (3, 0): + pytest.xfail("Python 2 paths are visibly identical, but numerically different") + raise From 111c89253fe22ed299add4b9e6cac96927109922 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 28 Mar 2019 15:46:12 -0400 Subject: [PATCH 15/18] minor refactors and fixes --- example.png => examples/example.png | Bin example.py => examples/example.py | 0 .../short_example.png | Bin short_example.py => examples/short_example.py | 0 pyveplot/__init__.py | 5 ++-- pyveplot/classes.py | 19 ++++++------ pyveplot/utils.py | 27 ++++++++++++++---- 7 files changed, 35 insertions(+), 16 deletions(-) rename example.png => examples/example.png (100%) rename example.py => examples/example.py (100%) rename short_example.png => examples/short_example.png (100%) rename short_example.py => examples/short_example.py (100%) diff --git a/example.png b/examples/example.png similarity index 100% rename from example.png rename to examples/example.png diff --git a/example.py b/examples/example.py similarity index 100% rename from example.py rename to examples/example.py diff --git a/short_example.png b/examples/short_example.png similarity index 100% rename from short_example.png rename to examples/short_example.png diff --git a/short_example.py b/examples/short_example.py similarity index 100% rename from short_example.py rename to examples/short_example.py diff --git a/pyveplot/__init__.py b/pyveplot/__init__.py index c8a9b44..69cb80c 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -12,10 +12,11 @@ turn contain an arbitrary number of *Node* objects, and a method to connect them. """ -from pyveplot.classes import Hiveplot, Axis, Node +from .classes import Hiveplot, Axis, Node +from .utils import UnitConverter, PolarPlotter __version__ = "0.6.0" __version_info__ = tuple(int(i) for i in __version__.split('.')) -__all__ = ["Hiveplot", "Axis", "Node"] +__all__ = ["Hiveplot", "Axis", "Node", "UnitConverter", "PolarPlotter"] diff --git a/pyveplot/classes.py b/pyveplot/classes.py index 173e32a..2d6001a 100644 --- a/pyveplot/classes.py +++ b/pyveplot/classes.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals, print_function import warnings +from math import sin, cos, atan2, radians, sqrt import svgwrite -from math import sin, cos, atan2, radians, sqrt -from pyveplot.utils import UnitConverter, PolarPlotter +from .utils import UnitConverter, PolarPlotter UNITS = UnitConverter() @@ -158,7 +159,7 @@ class Hiveplot(object): """ - def __init__(self, filename=None, origin=None, **kwargs): + def __init__(self, filename=None, center=None, **kwargs): kwargs = dict(debug=True, **kwargs) if filename: kwargs["filename"] = filename @@ -167,15 +168,15 @@ def __init__(self, filename=None, origin=None, **kwargs): **{key: kwargs.get(key) for key in ("width", "height", "font_size")} ) self.axes = [] - self.origin = None + self.center = None self.coords = None - if origin: - self._set_origin(*origin) + if center: + self._set_center(*center) - def _set_origin(self, x, y): - self.origin = self.units.to_px(x), self.units.to_px(y) - self.coords = PolarPlotter(*self.origin, **self.units._kwargs()) + def _set_center(self, x, y): + self.center = self.units.to_px(x), self.units.to_px(y) + self.coords = PolarPlotter(*self.center, **self.units._kwargs()) def draw_axes(self): warnings.warn( diff --git a/pyveplot/utils.py b/pyveplot/utils.py index b9a111f..9cf13b7 100644 --- a/pyveplot/utils.py +++ b/pyveplot/utils.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import, division, unicode_literals, print_function from numbers import Number from math import sqrt @@ -75,7 +76,7 @@ def to_px(self, val, unit=None): return val * self.table[detected_unit] def from_px(self, val, unit): - """Convert the give px value into the requested unit. + """Convert the given px value into the requested unit. If the passed ``unit`` is None, the px value will be returned as a float. @@ -94,7 +95,7 @@ def from_px(self, val, unit): Returns ------- - + float | str | np.ndarray """ converted = float(val) / self.table[unit] if isinstance(val, Number) and unit: @@ -103,6 +104,22 @@ def from_px(self, val, unit): return converted def __call__(self, val, tgt=None, src=None): + """Convert length from any unit to any other unit. + + Parameters + ---------- + val: float | str + value to convert, as a string (with/without units), a number, + or numpy array of numbers + tgt: str, optional + target unit to convert to, default px (returns number) + src: str, optional + source unit to convert from (ignored if val is a string with units) + + Returns + ------- + float | str | np.ndarray + """ return self.from_px(self.to_px(val, src), tgt) def _kwargs(self): @@ -120,9 +137,9 @@ def __init__(self, x=0, y=0, use_radians=True, **kwargs): Parameters ---------- x: float | str - x location of origin, in any SVG units + x location of pole, in any SVG units y: float | str - y location of origin, in any SVG units + y location of pole, in any SVG units use_radians: bool, optional Whether to use radians for angles by default (default True) kwargs: @@ -153,7 +170,7 @@ def __call__(self, distance, angle, use_radians=None): x, y coordinates in px/user units from the top left """ if use_radians is None: - angle = self.use_radians + use_radians = self.use_radians if not use_radians: angle = radians(angle) From a9669a2be07f9ab2a1222f258b8422d7bdd7d51b Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 28 Mar 2019 15:46:56 -0400 Subject: [PATCH 16/18] require black --- requirements.txt | 1 + test/regression_test.py | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 6f0af03..34dec0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ numpy networkx pytest tox +black; python_version >= "3.6" diff --git a/test/regression_test.py b/test/regression_test.py index 6737123..dd4ce73 100644 --- a/test/regression_test.py +++ b/test/regression_test.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import, division, unicode_literals, print_function import os import random import shutil From 1f7763458ede16a29b6dcfd824cde1b73ccaaaf1 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 28 Mar 2019 15:47:11 -0400 Subject: [PATCH 17/18] example with modern features --- examples/modern_example.py | 67 +++++++++++++++++++++++++++++++++++++ examples/modern_example.svg | 2 ++ 2 files changed, 69 insertions(+) create mode 100644 examples/modern_example.py create mode 100644 examples/modern_example.svg diff --git a/examples/modern_example.py b/examples/modern_example.py new file mode 100644 index 0000000..1fed9f6 --- /dev/null +++ b/examples/modern_example.py @@ -0,0 +1,67 @@ +"""Produces a plot visually the same as short_example, but uses modern features""" +from __future__ import absolute_import, division, unicode_literals, print_function +import random +from math import pi +from pyveplot import Hiveplot +import networkx + +random.seed(1) + +# a network +g = networkx.barabasi_albert_graph(50, 2, seed=2) + +# numbers use px units +center = (200, 200) +# our hiveplot object +h = Hiveplot("modern_example.svg", center=center) + +axis0 = h.add_axis(start=center, end=(200, 100), stroke="grey") +# polar coordinates (radius, angle): defaults to radians +circle = 2 * pi +# str units are interpreted correctly +axis1 = h.add_axis_polar(end=("105pt", circle / 3), stroke="blue") +axis2 = h.add_axis_polar(end=("5.82cm", 240), use_radians=False, stroke="black") + +# randomly distribute nodes in axes +for n in g.nodes(): + random.choice(h.axes).add_node(n, random.random()) + +for e in g.edges(): + if (e[0] in axis0.nodes) and (e[1] in axis1.nodes): # edges from axis0 to axis1 + h.connect( + axis0, + e[0], + 45, + axis1, + e[1], + -45, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="purple", + ) + elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): # edges from axis0 to axis2 + h.connect( + axis0, + e[0], + -45, + axis2, + e[1], + 45, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="red", + ) + elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): # edges from axis1 to axis2 + h.connect( + axis1, + e[0], + 15, + axis2, + e[1], + -15, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="magenta", + ) + +h.save() diff --git a/examples/modern_example.svg b/examples/modern_example.svg new file mode 100644 index 0000000..e00c9c1 --- /dev/null +++ b/examples/modern_example.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file From efd3c03d7fc555be51644e83496429221dd5911b Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 28 Mar 2019 15:53:23 -0400 Subject: [PATCH 18/18] update readme --- README.rst | 113 ++++++++++++++++++++++-------------- examples/modern_example.png | Bin 0 -> 27876 bytes 2 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 examples/modern_example.png diff --git a/README.rst b/README.rst index 15b2caa..ad0bc8b 100644 --- a/README.rst +++ b/README.rst @@ -4,60 +4,89 @@ Pyveplot A nice way of visualizing complex networks are `Hiveplots `_. -This library uses `svgwrite `_ to +This library uses `svgwrite `_ to programmatically create images like this one: -.. image:: https://github.com/CSB-IG/pyveplot/raw/master/example.png +.. image:: examples/example.png +Pyveplot is tested on pythons 2.7 and 3.5+ -A short example ---------------- +A short, modern example +----------------------- -Create a plot from a network, randomly selecting whichever axis to place 50 nodes.:: +Create a plot from a network, randomly selecting whichever axis to place 50 nodes. + +.. code-block:: python + + import random + from math import pi + from pyveplot import Hiveplot + import networkx + + random.seed(1) - from pyveplot import * - import networkx, random - # a network - g = networkx.barabasi_albert_graph(50, 2) - + g = networkx.barabasi_albert_graph(50, 2, seed=2) + + # numbers use px units + center = (200, 200) # our hiveplot object - h = Hiveplot( 'short_example.svg') - # start end - axis0 = Axis( (200,200), (200,100), stroke="grey") - axis1 = Axis( (200,200), (300,300), stroke="blue") - axis2 = Axis( (200,200), (10,310), stroke="black") - - h.axes = [ axis0, axis1, axis2 ] - + h = Hiveplot("modern_example.svg", center=center) + + axis0 = h.add_axis(start=center, end=(200, 100), stroke="grey") + # polar coordinates (radius, angle): defaults to radians + circle = 2 * pi + # str units are interpreted correctly + axis1 = h.add_axis_polar(end=("105pt", circle / 3), stroke="blue") + axis2 = h.add_axis_polar(end=("5.82cm", 240), use_radians=False, stroke="black") + # randomly distribute nodes in axes for n in g.nodes(): - node = Node(n) - random.choice( h.axes ).add_node( node, random.random() ) - + random.choice(h.axes).add_node(n, random.random()) + for e in g.edges(): - if (e[0] in axis0.nodes) and (e[1] in axis1.nodes): # edges from axis0 to axis1 - h.connect(axis0, e[0], 45, - axis1, e[1], -45, - stroke_width='0.34', stroke_opacity='0.4', - stroke='purple') - elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): # edges from axis0 to axis2 - h.connect(axis0, e[0], -45, - axis2, e[1], 45, - stroke_width='0.34', stroke_opacity='0.4', - stroke='red') - elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): # edges from axis1 to axis2 - h.connect(axis1, e[0], 15, - axis2, e[1], -15, - stroke_width='0.34', stroke_opacity='0.4', - stroke='magenta') - + if (e[0] in axis0.nodes) and (e[1] in axis1.nodes): # edges from axis0 to axis1 + h.connect( + axis0, + e[0], + 45, + axis1, + e[1], + -45, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="purple", + ) + elif (e[0] in axis0.nodes) and (e[1] in axis2.nodes): # edges from axis0 to axis2 + h.connect( + axis0, + e[0], + -45, + axis2, + e[1], + 45, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="red", + ) + elif (e[0] in axis1.nodes) and (e[1] in axis2.nodes): # edges from axis1 to axis2 + h.connect( + axis1, + e[0], + 15, + axis2, + e[1], + -15, + stroke_width="0.34", + stroke_opacity="0.4", + stroke="magenta", + ) + h.save() +.. image:: examples/modern_example.png -.. image:: https://github.com/CSB-IG/pyveplot/raw/master/short_example.png - -The more elaborate `example.py `_ +The more elaborate `example.py `_ shows how to use shapes for nodes, placement of the control points and attributes of edges, and the attributes of axes. @@ -67,6 +96,4 @@ Installation Install library, perhaps within a virtualenv:: - $ pip install pyveplot - - + pip install pyveplot diff --git a/examples/modern_example.png b/examples/modern_example.png new file mode 100644 index 0000000000000000000000000000000000000000..5753e1fa5fc7fd7f8a3ef78260f84861381845a6 GIT binary patch literal 27876 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 U70gw&C;$Ke07*qoM6N<$f^v_N)Bpeg literal 0 HcmV?d00001