Commit 7ea3821e authored by Tiago Peixoto's avatar Tiago Peixoto
Browse files

Slightly refactor draw module to only optionally depend on GTK+

This implements a graceful fallback in case GTK cannot be loaded, which
can (currently) happen simply by not having a environment DISPLAY
variable.
parent fbf26825
......@@ -44,6 +44,7 @@ graph_tool_communitydir = $(MOD_DIR)/community
graph_tool_draw_PYTHON = \
draw/__init__.py \
draw/cairo_draw.py \
draw/gtk_draw.py \
draw/graphviz_draw.py
graph_tool_draw_DATA = \
draw/graph-tool-logo.svg
......
......@@ -71,7 +71,6 @@ from .. stats import label_parallel_edges
import numpy.random
from numpy import sqrt
import sys
import warnings
from .. dl_import import dl_import
dl_import("import libgraph_tool_layout")
......@@ -79,8 +78,7 @@ dl_import("import libgraph_tool_layout")
__all__ = ["graph_draw", "graphviz_draw", "fruchterman_reingold_layout",
"arf_layout", "sfdp_layout", "random_layout",
"interactive_window", "cairo_draw", "GraphWidget",
"GraphWindow"]
"cairo_draw"]
def random_layout(g, shape=None, pos=None, dim=2):
......@@ -586,7 +584,13 @@ def sfdp_layout(g, vweight=None, eweight=None, pin=None, C=0.2, K=None, p=2.,
verbose)
return pos
from cairo_draw import graph_draw, GraphWidget, GraphWindow, \
interactive_window, cairo_draw
from cairo_draw import graph_draw, cairo_draw
try:
from cairo_draw import GraphWidget, GraphWindow, \
interactive_window
__all__ += ["interactive_window", "GraphWidget", "GraphWindow"]
except ImportError:
pass
from graphviz_draw import graphviz_draw
......@@ -24,15 +24,17 @@ import warnings
try:
import cairo
except ImportError:
warnings.warn("Error importing cairo. Graph drawing will not work.",
ImportWarning)
msg = "Error importing cairo. Graph drawing will not work."
warnings.filterwarnings("always", msg, ImportWarning)
warnings.warn(msg, ImportWarning)
try:
import matplotlib.cm
import matplotlib.colors
except ImportError:
warnings.warn("error importing matplotlib module. " + \
"Graph drawing will not work..", ImportWarning)
msg = "Error importing matplotlib module. Graph drawing will not work."
warnings.filterwarnings("always", msg, ImportWarning)
warnings.warn(msg, ImportWarning)
import numpy as np
import gzip
......@@ -51,16 +53,10 @@ try:
from libgraph_tool_draw import vertex_attrs, edge_attrs, vertex_shape,\
edge_marker
except ImportError:
warnings.warn("error importing cairo-based drawing library. " +
"Was graph-tool compiled with cairomm support?",
ImportWarning)
try:
from gi.repository import Gtk, Gdk, GdkPixbuf
import gobject
except ImportError:
warnings.warn("Error importing Gtk module. Gtk drawing will " +
"not work.", ImportWarning)
msg = "Error importing cairo-based drawing library. " + \
"Was graph-tool compiled with cairomm support?"
warnings.filterwarnings("always", msg, ImportWarning)
warnings.warn(msg, ImportWarning)
from .. draw import sfdp_layout, random_layout, _avg_edge_distance, \
coarse_graphs
......@@ -327,1064 +323,6 @@ def cairo_draw(g, pos, cr, vprops=None, eprops=None, vorder=None, eorder=None,
nodesfirst, vattrs, eattrs, vdefs, edefs, cr)
def get_bb(g, pos, size, pen_width, size_scale=1, text=None, font_family=None,
font_size=None, cr=None):
size = size.fa[:g.num_vertices()] if isinstance(size, PropertyMap) else size
pen_width = pen_width.fa if isinstance(pen_width, PropertyMap) else pen_width
pos_x, pos_y = ungroup_vector_property(pos, [0, 1])
if text is not None and text != "":
if not isinstance(size, PropertyMap):
uniform = (not isinstance(font_size, PropertyMap) and
not isinstance(font_family, PropertyMap))
size = np.ones(len(pos_x.fa)) * size
else:
uniform = False
for i, v in enumerate(g.vertices()):
ff = font_family[v] if isinstance(font_family, PropertyMap) \
else font_family
cr.select_font_face(ff)
fs = font_size[v] if isinstance(font_family, PropertyMap) \
else font_size
cr.set_font_size(fs)
t = text[v] if isinstance(text, PropertyMap) else text
if not isinstance(t, str):
t = str(t)
extents = cr.text_extents(t)
s = max(extents[2], extents[3]) * 1.4
size[i] = max(size[i] * size_scale, s) / size_scale
if uniform:
size[:] = size[i]
break
delta = (size * size_scale) / 2 + pen_width
x_range = [pos_x.fa.min(), pos_x.fa.max()]
y_range = [pos_y.fa.min(), pos_y.fa.max()]
x_delta = [x_range[0] - (pos_x.fa - delta).min(),
(pos_x.fa + delta).max() - x_range[1]]
y_delta = [y_range[0] - (pos_y.fa - delta).min(),
(pos_y.fa + delta).max() - y_range[1]]
return x_range, y_range, x_delta, y_delta
def transform_scale(M, scale):
p = M.transform_distance(scale / np.sqrt(2),
scale / np.sqrt(2))
return np.sqrt(p[0] ** 2 + p[1] ** 2)
def fit_to_view(g, pos, geometry, size, pen_width, M=None, text=None,
font_family=None, font_size=None, cr=None):
if M is not None:
pos_x, pos_y = ungroup_vector_property(pos, [0, 1])
P = np.zeros((2, len(pos_x.fa)))
P[0, :] = pos_x.fa
P[1, :] = pos_y.fa
T = np.zeros((2, 2))
O = np.zeros(2)
T[0, 0], T[1, 0], T[0, 1], T[1, 1], O[0], O[1] = M
P = np.dot(T, P)
P[0] += O[0]
P[1] += O[1]
pos_x.fa = P[0, :]
pos_y.fa = P[1, :]
pos = group_vector_property([pos_x, pos_y])
x_range, y_range, x_delta, y_delta = get_bb(g, pos, size, pen_width,
1, text, font_family,
font_size, cr)
zoom_x = (geometry[0] - sum(x_delta)) / (x_range[1] - x_range[0])
zoom_y = (geometry[1] - sum(y_delta)) / (y_range[1] - y_range[0])
if np.isnan(zoom_x) or np.isinf(zoom_x) or zoom_x == 0:
zoom_x = 1
if np.isnan(zoom_y) or np.isinf(zoom_y) or zoom_y == 0:
zoom_y = 1
pad = 0.95
zoom = min(zoom_x, zoom_y) * pad
empty_x = (geometry[0] - sum(x_delta)) - (x_range[1] - x_range[0]) * zoom
empty_y = (geometry[1] - sum(y_delta)) - (y_range[1] - y_range[0]) * zoom
offset = [-x_range[0] * zoom + empty_x / 2 + x_delta[0],
-y_range[0] * zoom + empty_y / 2 + y_delta[0]]
return offset, zoom
def adjust_default_sizes(g, geometry, vprops, eprops, force=False):
if "size" not in vprops or force:
A = geometry[0] * geometry[1]
vprops["size"] = np.sqrt(A / g.num_vertices()) / 3.5
if "pen_width" not in vprops or force:
size = vprops["size"]
if isinstance(vprops["size"], PropertyMap):
size = vprops["size"].fa.mean()
vprops["pen_width"] = size / 10
if "pen_width" not in eprops or force:
eprops["pen_width"] = size / 10
if "marker_size" not in eprops or force:
eprops["marker_size"] = size * 0.8
def scale_ink(scale, vprops, eprops):
if "size" not in vprops:
vprops["size"] = _vdefaults["size"]
if "pen_width" not in vprops:
vprops["pen_width"] = _vdefaults["pen_width"]
if "font_size" not in vprops:
vprops["font_size"] = _vdefaults["font_size"]
if "pen_width" not in eprops:
eprops["pen_width"] = _edefaults["pen_width"]
if "marker_size" not in eprops:
eprops["marker_size"] = _edefaults["marker_size"]
for props in [vprops, eprops]:
if isinstance(props["pen_width"], PropertyMap):
props["pen_width"].fa *= scale
else:
props["pen_width"] *= scale
if isinstance(vprops["size"], PropertyMap):
vprops["size"].fa *= scale
else:
vprops["size"] *= scale
if isinstance(vprops["font_size"], PropertyMap):
vprops["font_size"].fa *= scale
else:
vprops["font_size"] *= scale
if isinstance(eprops["marker_size"], PropertyMap):
eprops["marker_size"].fa *= scale
else:
eprops["marker_size"] *= scale
def point_in_poly(p, poly):
i, c = 0, False
j = len(poly) - 1
while i < len(poly):
if (((poly[i][1] > p[1]) != (poly[j][1] > p[1])) and
(p[0] < (poly[j][0] - poly[i][0]) * (p[1] - poly[i][1]) /
(poly[j][1] - poly[i][1]) + poly[i][0])):
c = not c
j = i
i += 1
return c
class VertexMatrix(object):
def __init__(self, g, pos):
self.g = g
self.pos = pos
self.m = None
self.m_res = None
self.update()
def get_box(self, p, size=None):
if size is None:
return (int(round(p[0] / self.m_res)),
int(round(p[1] / self.m_res)))
else:
n = int(np.ceil(size / self.m_res))
b = self.get_box(p)
boxes = []
for i in xrange(-n, n):
for j in xrange(-n, n):
boxes.append((b[0] + i, b[1] + j))
return boxes
def update(self):
pos_x, pos_y = ungroup_vector_property(self.pos, [0, 1])
x_range = [pos_x.fa.min(), pos_x.fa.max()]
y_range = [pos_y.fa.min(), pos_y.fa.max()]
self.m_res = min(x_range[1] - x_range[0],
y_range[1] - y_range[0]) / np.sqrt(self.g.num_vertices())
self.m_res *= np.sqrt(10)
self.m = defaultdict(set)
for v in self.g.vertices():
i, j = self.get_box(self.pos[v])
self.m[(i, j)].add(v)
def update_vertex(self, v, new_pos):
b = self.get_box(self.pos[v])
self.m[b].remove(v)
self.pos[v] = new_pos
b = self.get_box(self.pos[v])
self.m[b].add(v)
def remove_vertex(self, v):
b = self.get_box(self.pos[v])
self.m[b].remove(v)
def add_vertex(self, v):
b = self.get_box(self.pos[v])
self.m[b].add(v)
def get_closest(self, pos):
pos = np.array(pos)
box = self.get_box(pos)
dist = float("inf")
clst = None
for i in xrange(-1, 2):
for j in xrange(-1, 2):
b = (box[0] + i, box[1] + j)
for v in self.m[b]:
ndist = ((pos - self.pos[v].a[:2]) ** 2).sum()
if ndist < dist:
dist = ndist
clst = v
return clst
def mark_polygon(self, points, selected):
rect = [min([x[0] for x in points]), min([x[1] for x in points]),
max([x[0] for x in points]), max([x[1] for x in points])]
p1 = self.get_box(rect[:2])
p2 = self.get_box(rect[2:])
for i in xrange(p1[0], p2[0] + 1):
for j in xrange(p1[1], p2[1] + 1):
for v in self.m[(i, j)]:
p = self.pos[v]
if not point_in_poly(p, points):
continue
selected[v] = True
def apply_transforms(g, pos, m):
m = tuple(m)
g = GraphView(g, directed=True)
libgraph_tool_draw.apply_transforms(g._Graph__graph, _prop("v", g, pos),
m[0], m[1], m[2], m[3], m[4], m[5])
class GraphWidget(Gtk.DrawingArea):
r"""Interactive GTK+ widget displaying a given graph.
Parameters
----------
g : :class:`~graph_tool.Graph`
Graph to be drawn.
pos : :class:`~graph_tool.PropertyMap` (optional, default: ``None``)
Vector-valued vertex property map containing the x and y coordinates of
the vertices. If not given, it will be computed using :func:`sfdp_layout`.
vprops : dict (optional, default: ``None``)
Dictionary with the vertex properties. Individual properties may also be
given via the ``vertex_<prop-name>`` parameters, where ``<prop-name>`` is
the name of the property.
eprops : dict (optional, default: ``None``)
Dictionary with the vertex properties. Individual properties may also be
given via the ``edge_<prop-name>`` parameters, where ``<prop-name>`` is
the name of the property.
vorder : :class:`~graph_tool.PropertyMap` (optional, default: ``None``)
If provided, defines the relative order in which the vertices are drawn.
eorder : :class:`~graph_tool.PropertyMap` (optional, default: ``None``)
If provided, defines the relative order in which the edges are drawn.
nodesfirst : bool (optional, default: ``False``)
If ``True``, the vertices are drawn first, otherwise the edges are.
update_layout : bool (optional, default: ``True``)
If ``True``, the layout will be updated dynamically.
layout_K : float (optional, default: ``1.0``)
Parameter ``K`` passed to :func:`~graph_tool.draw.sfdp_layout`.
multilevel : bool (optional, default: ``False``)
Parameter ``multilevel`` passed to :func:`~graph_tool.draw.sfdp_layout`.
display_props : list of :class:`~graph_tool.PropertyMap` instances (optional, default: ``None``)
List of properties to be displayed when the mouse passes over a vertex.
display_props_size : float (optional, default: ``11``)
Font size used to display the vertex properties.
bg_color : str or sequence (optional, default: ``None``)
Background color. The default is white.
vertex_* : :class:`~graph_tool.PropertyMap` or arbitrary types (optional, default: ``None``)
Parameters following the pattern ``vertex_<prop-name>`` specify the
vertex property with name ``<prop-name>``, as an alternative to the
``vprops`` parameter.
edge_* : :class:`~graph_tool.PropertyMap` or arbitrary types (optional, default: ``None``)
Parameters following the pattern ``edge_<prop-name>`` specify the edge
property with name ``<prop-name>``, as an alternative to the ``eprops``
parameter.
**kwargs
Any extra parameters are passed to :func:`~graph_tool.draw.cairo_draw`.
Notes
-----
The graph drawing can be panned by dragging with the middle mouse button
pressed. The graph may be zoomed by scrolling with the mouse wheel, or
equivalent (if the "shift" key is held, the vertex/edge sizes are scaled
accordingly). The layout may be rotated by dragging while holding the
"control" key. Pressing the "r" key centers and zooms the layout around the
graph. By pressing the "a" key, the current translation, scaling and
rotation transformations are applied to the vertex positions themselves, and
the transformation matrix is reset (if this is never done, the given
position properties are never modified).
Individual vertices may be selected by pressing the left mouse button. The
currently selected vertex follows the mouse pointer. To stop the selection,
the right mouse button must be pressed. Alternatively, a group of vertices
may be selected by holding the "shift" button while the pointer is dragged
while pressing the left button. The selected vertices may be moved by
dragging the pointer with the left button pressed. They may be rotated by
holding the "control" key and scrolling with the mouse. If the key "z" is
pressed, the layout is zoomed to fit the selected vertices only.
If the key "s" is pressed, the dynamic spring-block layout is
activated. Vertices which are currently selected do are not updated.
"""
def __init__(self, g, pos, vprops=None, eprops=None, vorder=None,
eorder=None, nodesfirst=False, update_layout=False,
layout_K=1., multilevel=False, display_props=None,
display_props_size=11, bg_color=None, **kwargs):
Gtk.DrawingArea.__init__(self)
vprops = {} if vprops is None else vprops
eprops = {} if eprops is None else eprops
props, kwargs = parse_props("vertex", kwargs)
vprops.update(props)
props, kwargs = parse_props("edge", kwargs)
eprops.update(props)
self.kwargs = kwargs
self.g = g
self.pos = pos
self.vprops = vprops
self.eprops = eprops
self.vorder = vorder
self.eorder = eorder
self.nodesfirst = nodesfirst
self.panning = None
self.tmatrix = cairo.Matrix() # position to surface
self.smatrix = cairo.Matrix() # surface to screen
self.pointer = [0, 0]
self.picked = False
self.selected = g.new_vertex_property("bool")
self.srect = None
self.drag_begin = None
self.moved_picked = False
self.vertex_matrix = None
self.display_prop = g.vertex_index if display_props is None \
else display_props
self.display_prop_size = display_props_size
self.geometry = None
self.base = None
self.background = None
self.bg_color = bg_color if bg_color is not None else [1, 1, 1, 1]
self.surface_callback = None
self.layout_callback_id = None
self.layout_K = layout_K
self.layout_init_step = self.layout_K
self.epsilon = 0.01 * self.layout_K
self.multilevel_layout = multilevel
if multilevel:
self.cgs = coarse_graphs(g)
u = self.cgs.next()
self.cg, self.cpos, self.layout_K, self.cvcount, self.cecount = u
self.ag = self.g
self.apos = self.pos
self.g = self.cg
self.pos = self.cpos
self.layout_step = self.layout_K
else:
self.cg = None
if update_layout:
self.reset_layout()
# Event signals
self.connect("motion_notify_event", self.motion_notify_event)
self.connect("button_press_event", self.button_press_event)
self.connect("button_release_event", self.button_release_event)
self.connect("scroll_event", self.scroll_event)
self.connect("key_press_event", self.key_press_event)
self.connect("key_release_event", self.key_release_event)
self.connect("destroy_event", self.cleanup)
self.set_events(Gdk.EventMask.EXPOSURE_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.BUTTON_MOTION_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.POINTER_MOTION_HINT_MASK
| Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.KEY_PRESS_MASK
| Gdk.EventMask.KEY_RELEASE_MASK)
self.set_property("can-focus", True)
self.connect("draw", self.draw)
def cleanup(self):
"""Cleanup callbacks."""
if self.layout_callback_id is not None:
ret = gobject.source_remove(self.layout_callback_id)
if not ret:
warnings.warn("error removing idle callback...")
self.layout_callback_id = None
def __del__(self):
self.cleanup()
# Layout update
def reset_layout(self):
"""Reset the layout algorithm."""
if self.layout_callback_id is not None:
gobject.source_remove(self.layout_callback_id)
self.layout_callback_id = None
self.layout_step = self.layout_init_step
self.layout_callback_id = gobject.idle_add(self.layout_callback)
def layout_callback(self):
"""Perform one step of the layout algorithm."""
if self.layout_callback_id is None:
return False
pos_temp = ungroup_vector_property(self.pos, [0, 1])
sfdp_layout(self.g, K=self.layout_K,
max_iter=5, pos=self.pos,
pin=self.selected,
init_step=self.layout_step,
multilevel=False)
self.layout_step *= 0.9
if self.vertex_matrix is not None:
self.vertex_matrix.update()
self.regenerate_surface(lazy=False)
self.queue_draw()
ps = ungroup_vector_property(self.pos, [0, 1])
delta = np.sqrt((pos_temp[0].fa - ps[0].fa) ** 2 +
(pos_temp[1].fa - ps[1].fa) ** 2).mean()
if delta > self.epsilon:
return True
else:
if self.multilevel_layout:
try:
u = self.cgs.next()
self.cg, self.cpos, K, self.cvcount, self.cecount = u
self.layout_K *= 0.75
self.g = self.cg
self.pos = self.cpos
self.layout_step = max(self.layout_K,
_avg_edge_distance(self.g,
self.pos) / 10)
if self.vertex_matrix is not None:
self.vertex_matrix = VertexMatrix(self.g, self.pos)
self.epsilon = 0.05 * self.layout_K * self.g.num_edges()
geometry = [self.get_allocated_width(),
self.get_allocated_height()]
adjust_default_sizes(self.g, geometry, self.vprops,
self.eprops, force=True)
self.fit_to_window(ink=False)
self.regenerate_surface(lazy=False)
except StopIteration:
self.g = self.ag
self.pos = self.apos
self.g.copy_property(self.cpos, self.pos)
if self.vertex_matrix is not None:
self.vertex_matrix = VertexMatrix(self.g, self.pos)
self.multilevel_layout = False
self.layout_init_step = max(self.layout_K,
_avg_edge_distance(self.g,
self.pos) /
10)
self.epsilon = 0.01 * self.layout_K
return True
self.layout_callback_id = None
return False
# Actual drawing
def regenerate_surface(self, lazy=True, timeout=350):
r"""Redraw the graph surface. If lazy is True, the actual redrawing will
be performed after the specified timeout."""
if lazy:
if self.surface_callback is not None:
gobject.source_remove(self.surface_callback)
f = lambda: self.regenerate_surface(lazy=False)
self.surface_callback = gobject.timeout_add(timeout, f)
else:
geometry = [self.get_allocated_width() * 3,
self.get_allocated_height() * 3]
m = cairo.Matrix()
m.translate(self.get_allocated_width(),
self.get_allocated_height())
self.smatrix = self.smatrix * m
self.tmatrix = self.tmatrix * self.smatrix
if (self.base is None or self.base.get_width() != geometry[0] or
self.base.get_height() != geometry[1]):
# self.base = cairo.ImageSurface(cairo.FORMAT_ARGB32,
# *geometry)
w = self.get_window()
if w is None:
return False
self.base = w.create_similar_surface(cairo.CONTENT_COLOR_ALPHA,
*geometry)
cr = cairo.Context(self.base)
cr.set_source_rgba(*self.bg_color)
cr.paint()
cr.set_matrix(self.tmatrix)
cairo_draw(self.g, self.pos, cr, self.vprops, self.eprops,
self.vorder, self.eorder, self.nodesfirst, **self.kwargs)
self.smatrix = cairo.Matrix()
self.smatrix.translate(-self.get_allocated_width(),
-self.get_allocated_height())