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