Source code for pypath.visual.drawing

#!/usr/bin/env python
# -*- coding: utf-8 -*-

#
#  This file is part of the `pypath` python module
#
#  Copyright 2014-2023
#  EMBL, EMBL-EBI, Uniklinik RWTH Aachen, Heidelberg University
#
#  Authors: see the file `README.rst`
#  Contact: Dénes Türei (turei.denes@gmail.com)
#
#  Distributed under the GPLv3 License.
#  See accompanying file LICENSE.txt or copy at
#      https://www.gnu.org/licenses/gpl-3.0.html
#
#  Website: https://pypath.omnipathdb.org/
#

from future.utils import iteritems
from past.builtins import xrange, range, reduce

import os
import sys

try:
    import cairo
except:
    sys.stdout.write('Module `cairo` is not available.'
                     '\nSome plotting functionalities won\'t be accessible.\n')

import math
import time

from pypath.share.common import *
import pypath.share.common as common
import pypath_common._constants as _const
import pypath.share.session as session_mod

__all__ = ['Plot', 'InterSet']

try:
    import igraph

except ModuleNotFoundError:
    sys.stdout.write('Module `igraph` is not available.'
                     '\nSome plotting functionalities won\'t be accessible.\n')


[docs] class Plot(session_mod.Logger):
[docs] def __init__(self, graph=None, filename=None, graphix_dir="pdf", graphix_format="pdf", name=None, title_text=None, title_font_family=None, title_font_size=None, title_color='#646567', size=None, layout="fruchterman_reingold", layout_param=None, vertex_label=None, vertex_size=None, vertex_label_size='degree_label_size', edge_width=None, vertex_color='#6EA945', vertex_label_color='#007B7F', vertex_alpha='AA', vertex_frame_color='#FFFFFF00', vertex_frame_width=0, edge_label=None, edge_label_size=None, edge_label_color='#007B7F', edge_curved=None, edge_color='#818284', edge_alpha='AA', autocurve=None, vertex_label_font="sans-serif", edge_label_font="sans-serif", edge_arrow_size=1.0, edge_arrow_width=1.0, palettes={}, bbox=None, margin=10, small=None, dimensions=(1280, 1280), grouping=None, **kwargs): # setting parameters: for key, val in iteritems(locals()): setattr(self, key, val) self.default_alpha = { 'vertex_color': 'AA', 'edge_color': 'AA', 'vertex_label_color': 'FF', 'vertex_frame_color': '00', 'edge_label_color': 'FF' } self.default_vertex_label_size = 6.0 self.plots = [] self.session = common.random_string() self.name = self.name if self.name is not None else self.session self.label_sizes = { 'small': (15.0, 13.7), 'medium': (13.0, 10.0), 'large': (9.0, 6.0) } self.palettes = { 'vertex': ['#6EA945', '#007B7F', '#FCCC06', '#DA0025', '#000000'], 'edge': ['#007B7F', '#6EA945', '#DA0025'], 'vertex_label': ['#454447'], 'edge_label': ['#454447'] } self.small_param = { 'vertex_size': 21, 'edge_width': 0.051, 'autocurve': True, 'vertex_label_dist': 1.5 } self.medium_param = { 'vertex_size': 7, 'edge_width': 0.051, 'autocurve': True, 'vertex_label_dist': 1.33, 'edge_label_size': 1.0 } self.large_param = { 'vertex_size': 2, 'edge_width': 0.051, 'vertex_label_dist': 1.0, 'edge_label_size': 1.0 } self.layout_defaults = { 'fruchterman_reingold': { 'repulserad': self.graph.vcount()**2.8, 'maxiter': 1000, 'area': self.graph.vcount()**2.3 } } self.update_page() self.update_graph(graph)
def reload(self): modname = self.__class__.__module__ mod = __import__(modname, fromlist=[modname.split('.')[0]]) imp.reload(mod) new = getattr(mod, self.__class__.__name__) setattr(self, '__class__', new) def _has_graph(self): return type(self.graph) is igraph.Graph def update_page(self, size=None, margin=None): self.dimensions = self.dimensions if size is None else size self.margin = self.margin if margin is None else margin if type(self.margin) is int: self.margin = tuple([self.margin] * 4) self.bbox = igraph.drawing.utils.BoundingBox( self.margin[0], self.margin[1], self.dimensions[0] - self.margin[2], self.dimensions[1] - self.margin[3]) def title(self, title, family=None, size=None, color=None): self.title_text = title self.title_font_family = family if family is not None else self.vertex_label_font self.title_font_size = size if size is not None else \ math.ceil(self.bbox.width / 50.0) self.title_color = color if color is not None else self.title_color def update_graph(self, graph, title=None): self.graph = graph if title is not None: self.title_text = title if self._has_graph(): if type(self.vertex_label) is str and \ self.vertex_label in self.graph.vs.attributes(): self.vertex_label = self.graph.vs[self.vertex_label] self.graph.vertex_label_font = self.vertex_label_font self.graph.edge_label_font = self.edge_label_font self.size = 'small' if self.graph.vcount() <= 100 \ else 'medium' if self.graph.vcount() <= 500 \ else 'large' getattr(self, '%s_defaults' % self.size)() self.update_layout() self.colorize('vertex', self.vertex_color, self.vertex_alpha) self.colorize('edge', self.edge_color, self.edge_alpha) self.colorize('vertex', self.vertex_label_color, attr='label') self.colorize('edge', self.edge_label_color, attr='label') self.autocurve = True if self.small else False if self.title_text is not None: self.title(self.title_text, self.title_font_family, self.title_font_size, self.title_color) def update_layout(self, layout=None, param=None): g = self.graph if self.layout_update_needed(layout, param): start_time = time.time() self.layout_param = self.layout_param if self.layout_param is not None \ else self.layout_defaults[self.layout] if self.layout in self.layout_defaults \ else {} msg = """Calculating %s layout... (numof nodes/edges: %u/%u)""" % \ (self.layout, g.vcount(), g.ecount()) sys.stdout.write('\t::%s' % msg) sys.stdout.flush() self._log(msg) if self.grouping is not None: if self.layout in [ "intergroup", "modular_fr", "modular_circle" ]: f = getattr(gr_plot, "layout_%s" % self.layout) f(g, self.grouping, self.layout_param) else: if self.layout not in [ "fruchterman_reingold", "fr", "circle" ]: self.layout = "fr" g['layout_data'] = layout_intergroup(g, self.grouping, **self.layout_param) g['layout_type'] = "layout_intergroup" if self.vertex_color == "groups": self.colorize(what='vertex', coldef=self.grouping) self.layout = self.layout if layout is None else layout self.layout_param = self.layout_param if param is None else param self.layout_data = g.layout(self.layout, **self.layout_param) self.layout_data = igraph.Layout(self.layout_data) time_elapsed = time.time() - start_time m, s = divmod(time_elapsed, 60) time_elapsed_str = "%02d min %02d sec" % (m, s) sys.stdout.write(' Done in %s. \n' % time_elapsed_str) sys.stdout.flush() def update_filename(self): if self.name is None: self.name = 'plot' seq = str(len(self.plots) + 1) self.nextfile = self.filename if self.filename is not None \ else 'network-%s-%u.%s' % \ (self.name, len(self.plots) + 1, self.graphix_format) if os.path.sep not in self.nextfile: self.nextfile = os.path.join(self.graphix_dir, self.nextfile) def layout_update_needed(self, layout, param): return not hasattr(self, "layout") or \ (layout is not None and self.layout != layout) or \ not hasattr(self, "layout_data") or \ len(self.layout_data) != self.graph.vcount() or \ not hasattr(self, "layout_param") or \ (param is not None and self.layout_param != param) def colorize(self, what='vertex', coldef=None, alpha=None, attr=None, palette=None): attr = '%scolor' % ('' if attr is None else '%s_' % attr) seq = self.graph.vs if what == 'vertex' else self.graph.es coldef = coldef if coldef is not None else self.palette[0] alpha = alpha if alpha is not None else self.default_alpha['%s_%s' % ( what, attr)] pal = self.palettes[what] if palette is None else palette if type(coldef) in _const.CHAR_TYPES and coldef in seq.attributes(): lev = list(set(seq[coldef])) seq[attr] = [pal[lev.index(i[coldef])] for i in seq] elif type(coldef) in _const.CHAR_TYPES and len(coldef) <= 9: seq[attr] = [coldef for _ in seq] elif type(coldef) is list and len(coldef) == len(seq): seq[attr] = coldef elif type(coldef) is dict and '__attr__' in coldef: seq[attr] = [coldef[i[coldef['__attr__']]] for i in seq] elif hasattr(coldef, '__call__'): seq[attr] = [coldef(i) for i in seq] if min([len(i[attr]) for i in seq]) == 7: self.set_alpha(seq, alpha, attr) def set_param(self, param, value): if not hasattr(self, param) or getattr(self, param) is None: setattr(self, param, value) def small_defaults(self): self.set_defaults('small_param') def medium_defaults(self): self.set_defaults('medium_param') def large_defaults(self): self.set_defaults('large_param') def set_defaults(self, preset): if hasattr(self, preset): for k, v in iteritems(getattr(self, preset)): self.set_param(k, v) def set_alpha(self, seq, alpha, attr): seq[attr] = ['%s%s' % (c[0:7], alpha) for c in seq[attr]] def hex2rgb(self, rgbhex): rgbhex = rgbhex.lstrip('#') lv = len(rgbhex) return tuple(int(rgbhex[i:i + 2], 16) for i in range(0, lv, 2)) def rgb1(self, rgb256): return rgb256 if not any([i > 1 for i in rgb256]) \ else tuple([x / float(255) for x in rgb256]) def make_title(self): ctx = cairo.Context(self.plots[-1].surface) ctx.set_font_size(self.title_font_size) ctx.select_font_face(self.title_font_family, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) ctx.set_source_rgba(*self.rgb1(self.hex2rgb(self.title_color))) title_drawer = igraph.drawing.text.TextDrawer( ctx, self.title_text, halign=igraph.drawing.text.TextDrawer.CENTER) title_drawer.draw_at(0, 40, width=self.bbox.width) def draw(self, return_data=False, **kwargs): if not self._has_graph(): return None self.update_filename() g = self.graph msg = """Plotting %s to file %s...""" % \ (self.graphix_format, self.nextfile) sys.stdout.write('\t::%s' % msg) sys.stdout.flush() self._log(msg) if self.graphix_format == "pdf": sf = cairo.PDFSurface(self.nextfile, self.dimensions[0], self.dimensions[1]) else: # currently doing only pdf sf = self.nextfile if self.vertex_label_size == "degree_label_size": # TODO dgr = g.vs.degree() maxDgr = float(max(dgr)) g.vs["label_size"] = [None] g.vs["label_size"] = [ math.log(float(v.degree()) / maxDgr + 1.0) * self.label_sizes[self.size][0] + self.label_sizes[self.size][1] for v in g.vs ] self.vertex_label_size = g.vs["label_size"] elif type(self.vertex_label_size) is not int: self.vertex_label_size = self.default_vertex_label_size if type(self.edge_curved) is float: self.kwargs['edge_curved'] = self.edge_curved else: self.kwargs['autocurve'] = self.autocurve if igraph.__version__ < '0.9.4': import pypath.visual.igraph_drawing as ig_drawing self.kwargs['drawer_factory'] = ( ig_drawing.DefaultGraphDrawerFFsupport ) self.plots.append( igraph.plot( g, layout=self.layout_data, target=sf, bbox=self.bbox, vertex_size=self.vertex_size, vertex_frame_width=self.vertex_frame_width, vertex_label=self.vertex_label, vertex_label_size=self.vertex_label_size, edge_label=self.edge_label, edge_width=self.edge_width, edge_arrow_size=self.edge_arrow_size, edge_arrow_width=self.edge_arrow_width, vertex_label_dist=self.vertex_label_dist, **self.kwargs)) self.plots[-1].redraw() if self.title_text is not None: self.make_title() self.plots[-1].save() sys.stdout.write('Ready.\n') sys.stdout.flush() self._log("Plot saved to %s" % self.nextfile) if return_data: return (self.plots[-1], g, self.layout_data, sf, self.bbox, self.vertex_size, self.vertex_frame_width, self.vertex_label, self.vertex_label_size, self.edge_width, self.edge_curved, self.edge_arrow_size, self.edge_arrow_width, self.kwargs)
#TODO this class may be dropped?
[docs] class InterSet(object):
[docs] def __init__(self, xsizes, intersects, outf='cairotest.pdf', width=1024, height=1024, bgcol='embl_gray125', cols=None, interscols=None, ysizes=None, ycols=None, skip=3.5, margin=24, mincircle=5, cellpadding=4): for key, val in iteritems(locals()): setattr(self, key, val) self.colors = { 'embl_green': (115, 179, 96, 255), 'embl_blue': (0, 102, 102, 255), 'embl_yellow': (250, 183, 0, 255), 'embl_red': (227, 62, 62, 255), 'embl_black': (0, 0, 0, 255), 'embl_gray875': (32, 32, 32, 255), 'embl_gray75': (64, 64, 64, 255), 'embl_gray625': (96, 96, 96, 255), 'embl_gray50': (128, 128, 128, 255), 'embl_gray25': (192, 192, 192, 255), 'embl_gray125': (224, 224, 224, 255), 'white': (255, 255, 255, 255) } # positions of circle labels: self.clabels = [(0.5, math.sqrt(3) / -2.0), (math.sqrt(3) / 2.0, -0.5), (math.sqrt(2) / 2.0, math.sqrt(2) / -2.0)] self.palette = [ self.colors[x] for x in ['embl_green', 'embl_blue', 'embl_yellow'] ] self.fontpal = [ self.colors[x] for x in ['embl_gray875', 'white', 'embl_gray875'] ] self.font = 'HelveticaNeueLT Std Lt' self.bgcol = self.bgcol if type(self.bgcol) is tuple else self.colors[ self.bgcol] # set parameters for x: # list of column labels (first elements of tuples in x list) self.xlabs = [x[0] for x in self.xsizes] # colors of sets in column headers self.xcols = self.get_colors(self.xsizes) # set sizes: self.xsizes = self.get_sizes(self.xsizes) # same for y: self.ylabs = [y[0] for y in self.ysizes ] if self.ysizes is not None else self.xlabs self.ycols = self.get_colors(self.ysizes) \ if self.ysizes is not None else self.xcols self.ysizes = self.get_sizes(self.ysizes) \ if self.ysizes is not None else self.xsizes # margin: # margin is either a single integer, or a tuple of 4 integers: self.margin = self.margin if type(self.margin) is tuple else ( self.margin, ) * 4 # table: # proportions of cell sizes: self.xcellsize = [3, 1] + [3] * len(self.xsizes) self.ycellsize = [3, 1] + [3] * len(self.ysizes) # sizes of table cells: self.xcoo = self.cells(self.xcellsize, self.margin[0], self.width - self.margin[1]) self.ycoo = self.cells(self.ycellsize, self.margin[2], self.height - self.margin[3]) # width and height of diagram cells: self.cellw = self.xcoo[0] self.cellh = self.ycoo[0] # largest circle fit in the cells: self.maxcircle = min(self.cellw, self.cellh) / 2 - 2 * self.cellpadding self.maxarea = pow(self.maxcircle, 2) * math.pi self.minarea = pow(self.mincircle, 2) * math.pi # scaling circle sizes between min and max circle size self.xcircsize = self.scale_sizes(self.xsizes) self.ycircsize = self.scale_sizes(self.ysizes) ssize = self.scale_sizes([x['size'] for x in self.intersects.values()]) for i, k in enumerate(self.intersects.keys()): self.intersects[k]['ssize'] = ssize[i] if 'color' not in self.intersects[k]: self.intersects[k]['color'] = self.palette[0:len(ssize[i])]
[docs] def draw(self): ''' main function of this class ''' self.srf = cairo.PDFSurface(self.outf, self.width, self.height) self.ctx = cairo.Context(self.srf) self.draw_table() self.colnames() self.rownames() self.draw_circles() self.srf.finish() self.srf.flush()
def max_text(self, labels, width): pts = [] for lab in labels: pts.append(self.fit_text(lab, width)) return min(pts) def fit_text(self, txt, width, pt=24, padding=2): overf = 1 while overf > 0: self.ctx.select_font_face(self.font, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) self.ctx.set_font_size(pt) overf = self.ctx.text_extents(txt)[2] - width + padding pt *= 0.95 return pt def get_colors(self, sizes, palette=None): palette = palette if palette is not None else self.palette return [[ xx[1] if xx[1] is not None else palette[i] for i, xx in enumerate(x[1:]) ] for x in sizes] def get_sizes(self, sizes): return [[xx[0] for i, xx in enumerate(x[1:])] for x in sizes] def scale_sizes(self, sizes): scaled = [ math.sqrt(x / math.pi) for x in self.scale([i for ii in sizes for i in ii], self.minarea, self.maxarea) ] lens = [len(s) for s in sizes] return [ scaled[sum(lens[:i]):sum(lens[:i]) + l] for i, l in enumerate(lens) ] def rgb1(self, rgb256): return rgb256 if not any([i > 1 for i in rgb256]) \ else tuple([x / float(255) for x in rgb256]) def set_alpha(self, col, alpha): return tuple(list(col[0:3]) + [alpha]) def draw_circles(self): self.clabpt = self.fit_text('00000', self.cellw / 7, padding=0) y = self.margin[2] + self.cellh / float(2) for xi in range(2, len(self.xcoo)): x = self.margin[0] + sum(self.xcoo[:xi]) + self.cellw / float(2) for i, sz in enumerate(self.xcircsize[xi - 2]): self.circle(x, y, sz, self.rgb1(self.xcols[xi - 2][i])) self.label( str(self.xsizes[xi - 2][i]), *tuple([ a + b * sz for a, b in zip([x, y], list(self.clabels[i])) ]), pt=self.clabpt, c=self.colors['embl_gray875'], center=True, vcenter=True, rot=-45) x = self.margin[0] + self.cellw / float(2) for yi in range(2, len(self.ycoo)): y = self.margin[2] + sum(self.ycoo[:yi]) + self.cellh / float(2) for i, sz in enumerate(self.ycircsize[yi - 2]): self.circle(x, y, sz, self.rgb1(self.ycols[yi - 2][i])) self.label( str(self.ysizes[yi - 2][i]), *tuple([ a + b * sz for a, b in zip([x, y], list(self.clabels[i])) ]), pt=self.clabpt, c=self.colors['embl_gray875'], center=True, vcenter=True, rot=-45) # intersections for yi in range(2, len(self.ycoo)): y = self.margin[2] + sum(self.ycoo[:yi]) + self.cellh / float(2) for xi in range(2, len(self.xcoo)): x = self.margin[0] + \ sum(self.xcoo[:xi]) + self.cellw / float(2) for i, sz in enumerate(self.intersects[(self.xlabs[ xi - 2], self.ylabs[yi - 2])]['ssize']): self.circle(x, y, sz, self.rgb1(self.intersects[(self.xlabs[ xi - 2], self.ylabs[yi - 2])]['color'][i])) self.label( str(self.intersects[(self.xlabs[xi - 2], self.ylabs[ yi - 2])]['size'][i]), *tuple([ a + b * sz for a, b in zip([x, y], list(self.clabels[i])) ]), pt=self.clabpt, c=self.colors['embl_gray875'], center=True, vcenter=True, rot=-45) ''' self.label(str(self.intersects[(self.xlabs[xi-2], self.ylabs[yi-2])]['size']), x, y, self.intersects[(self.xlabs[xi-2], self.ylabs[yi-2])]['ssize'][i] * 0.5 + 3, self.colors['white']) ''' def circle(self, x, y, r, c): c = self.set_alpha(c, 0.5) self.ctx.set_source_rgba(*c) self.ctx.arc(x, y, r, 0, 2 * math.pi) self.ctx.fill() def label(self, txt, x, y, pt, c, center=True, rot=0.0, vcenter=False): c = self.rgb1(c) self.ctx.select_font_face(self.font, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) self.ctx.set_font_size(pt) self.ctx.save() self.ctx.translate(x, y) self.ctx.rotate(math.radians(rot)) if center: ext = self.ctx.text_extents(txt) self.ctx.translate(-ext[2] / 2.0, 0.0) if vcenter: ext = self.ctx.text_extents(txt) self.ctx.translate(0.0, -ext[3] / 2.0) self.ctx.move_to(0, 0) self.ctx.set_source_rgba(*c) self.ctx.show_text(txt) self.ctx.restore() def colnames(self): # font size for labels: self.xlabpt = self.max_text(self.xlabs, self.cellw) y = self.margin[2] + self.ycoo[0] + self.ycoo[1] / 2.0 for xi in range(2, len(self.xcoo)): x = self.margin[0] + sum(self.xcoo[:xi]) + self.cellw / 2.0 lab = self.xlabs[xi - 2] self.label(lab, x, y, self.xlabpt, self.colors['embl_gray875']) def rownames(self): # font size for labels: self.xlabpt = self.max_text(self.xlabs, self.cellw) x = self.margin[0] + self.xcoo[0] + self.xcoo[1] / 2.0 for yi in range(2, len(self.ycoo)): y = self.margin[2] + sum(self.ycoo[:yi]) + self.cellh / 2.0 lab = self.ylabs[yi - 2] self.label( lab, x, y, self.xlabpt, self.colors['embl_gray875'], rot=-90) def draw_table(self): bg = self.rgb1(self.bgcol) self.ctx.set_source_rgba(*bg) for xi in range(0, len(self.xcoo)): for yi in range(0, len(self.ycoo)): ulx = self.margin[0] + sum(self.xcoo[:xi]) uly = self.margin[2] + sum(self.ycoo[:yi]) # print 'Drawing rectangle at (%f, %f), size (%f, %f)' % \ # (ulx + self.skip, uly + self.skip, self.xcoo[xi] - self.skip, # self.ycoo[yi] - self.skip) self.ctx.rectangle(ulx + self.skip, uly + self.skip, self.xcoo[xi] - self.skip, self.ycoo[yi] - self.skip) self.ctx.stroke_preserve() self.ctx.fill() def cells(self, props, mi, ma): rng = ma - mi uni = rng / float(sum(props)) return [x * uni for x in props] def scale(self, lst, lower, upper): return [((x - min(set(lst) - set([0])) ) / float(max(lst) - min(set(lst) - set([0]))) * (upper - lower) + lower) if x != 0 else 0 for x in lst]