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/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/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() 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/examples/modern_example.png b/examples/modern_example.png new file mode 100644 index 0000000..5753e1f Binary files /dev/null and b/examples/modern_example.png differ 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 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 c717037..69cb80c 100644 --- a/pyveplot/__init__.py +++ b/pyveplot/__init__.py @@ -12,210 +12,11 @@ turn contain an arbitrary number of *Node* objects, and a method to connect them. """ -import svgwrite -from svgwrite import cm, mm -from math import sin, cos, atan2, degrees, radians, tan, sqrt +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('.')) -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): - 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): - """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): - self.draw_axes() - self.dwg.save() - - - - - -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", "UnitConverter", "PolarPlotter"] diff --git a/pyveplot/classes.py b/pyveplot/classes.py new file mode 100644 index 0000000..2d6001a --- /dev/null +++ b/pyveplot/classes.py @@ -0,0 +1,308 @@ +# -*- 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 .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. + + 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=None, center=None, **kwargs): + kwargs = dict(debug=True, **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.center = None + self.coords = None + + if center: + self._set_center(*center) + + 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( + "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.get_dwg()) + + 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 get_dwg(self): + self.draw() + return self.dwg + + def save(self, fpath=None, pretty=False): + """Save the SVG hive plot. + + Parameters + ---------- + 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. + """ + 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_axis(self, start=(0, 0), end=(0, 0), **kwargs): + """Add axis to hive plot + + Parameters + ---------- + 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 + """ + 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. + + Parameters + ---------- + 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 + """ + start = self.coords(*start, use_radians=use_radians) + end = self.coords(*end, use_radians=use_radians) + return self.add_axis(start, end, **kwargs) diff --git a/pyveplot/utils.py b/pyveplot/utils.py new file mode 100644 index 0000000..9cf13b7 --- /dev/null +++ b/pyveplot/utils.py @@ -0,0 +1,182 @@ +from __future__ import absolute_import, division, unicode_literals, print_function +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 given 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 + ------- + float | str | np.ndarray + """ + 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): + """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): + 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 pole, in any SVG units + y: float | str + y location of pole, 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: + use_radians = 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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..34dec0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +svgwrite +numpy +networkx +pytest +tox +black; python_version >= "3.6" 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, +) 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/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..dd4ce73 --- /dev/null +++ b/test/regression_test.py @@ -0,0 +1,87 @@ +from __future__ import absolute_import, division, unicode_literals, print_function +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 + + +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 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)) + + # 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) + + 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 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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