Source code for aquaduct.apps.chord

#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-

# Aqua-Duct, a tool facilitating analysis of the flow of solvent molecules in molecular dynamic simulations
# Copyright (C) 2018-2019  Michał Banas
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import colorsys
import itertools

import matplotlib.patches as mpatches
import matplotlib.path as mpath
import matplotlib.pyplot as plt
import numpy as np


def color_gen():
    colors = ["#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4", "#46f0f0",
              "#f032e6", "#bcf60c", "#fabebe", "#008080", "#e6beff", "#9a6324",
              "#800000", "#aaffc3", "#808000", "#ffd8b1", "#000075", "#808080"]
    for color in itertools.cycle(colors):
        yield color


[docs]def hex2rgb(color): """ Convert HEX color to RGB format. :param color: String hex color. :return: Tuple with RGB values. """ # https://stackoverflow.com/questions/29643352/converting-hex-to-rgb-value-in-python return tuple(int(color[i:i + 2], 16) for i in (0, 2, 4))
[docs]def polar2point(angle, r): """ Transform polar coordinates to cartesian coordinates. :param angle: Angle. :param r: Radius. """ rad = np.deg2rad return np.array([r * np.sin(rad(angle)), r * np.cos(rad(angle))])
[docs]def generate_arc(r, sa, ea, max_angle=5, reversed_=False): """ Generate arc vertrices with control points for quadratic Bezier curve. :param r: Radius :param sa: Start angle. :param ea: End angle :param max_angle: Max. angle for which control point will be calculated. If > 90 curves will be significantly distorted. :param reversed_: If True vertices will start from ea. If `ea - sa > max_angle`, then `ea - sa` will be divided by `max_angle` into parts and control point will be calculated for each part. """ vertices = [] angles = [] n = ea - sa while True: if n - max_angle >= 0: angles.append(max_angle) if n - max_angle == 0: break n -= max_angle else: angles.append(n % max_angle) break if reversed_: sa = -ea angles = reversed(angles) vertices.append(polar2point(np.abs(sa), r)) for angle in angles: cp_angle = np.abs(sa + angle / 2.) # https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves cp_length = r + r * np.pi * 0.1 * pow(angle / 90., 2) vertices.append(polar2point(cp_angle, cp_length)) vertices.append(polar2point(np.abs(sa + angle), r)) sa += angle return vertices
[docs]class Node(mpatches.PathPatch): def __init__(self, r, sa, ea, color): """ Represent data on Chord circle. :param r: Radius. :param sa: Start angle. :param ea: End angle. :param color: Node color in HEX or matplotlib tuple format. """ self.sa = sa self.ea = ea self.color = color # Remembers when last link was added self._link_angle = sa vertices = [] codes = [] # # Outer arc outer_arc = generate_arc(r, sa, ea) vertices.extend(outer_arc) codes.append(mpath.Path.MOVETO) codes.extend([mpath.Path.CURVE3] * (len(outer_arc) - 1)) # Inner arc inner_arc = generate_arc(0.8 * r, sa, ea, reversed_=True) vertices.extend(inner_arc) codes.append(mpath.Path.LINETO) codes.extend([mpath.Path.CURVE3] * (len(inner_arc) - 1)) # Closing poly vertices.append((0, 0)) codes.append(mpath.Path.CLOSEPOLY) path = mpath.Path(vertices, codes) super(Node, self).__init__(path, facecolor=color, linewidth=0) def reserve_arc(self, angle): # Only if scaling is an optional # if self._link_angle + angle - self.sa > self.ea - self.sa: # raise RuntimeError("Too much data for node. Use scaling option.") self._link_angle += angle def get_arc_offset(self): return self._link_angle
[docs]class Arrow(mpatches.PathPatch): def __init__(self, r, sa, ea, color, max_angle=45): """ Arrow patch for links. :param r: Radius. :param sa: Start angle. :param ea: End angle. :param color: Arrow color in HEX or matplotlib tuple format. :param max_angle: """ vertices = [] codes = [] c = sa + (ea - sa) / 2 if ea - sa < max_angle: max_angle = ea - sa arc = generate_arc(r, c - max_angle / 2, c + max_angle / 2) vertices.extend(arc) codes.append(mpath.Path.MOVETO) codes.extend([mpath.Path.CURVE3] * (len(arc) - 1)) vertices.append(polar2point(sa + (ea - sa) / 2, 0.9 * r - 2)) codes.append(mpath.Path.LINETO) vertices.append((0, 0)) codes.append(mpath.Path.CLOSEPOLY) path = mpath.Path(vertices, codes) super(Arrow, self).__init__(path, facecolor=color, linewidth=0)
class Chord(object): def __init__(self, ax, r, nodes_sizes, links, labels, colors=[]): """ :param ax: :param r: :param nodes_sizes: :param links: :param labels: :param colors: """ self.nodes = [] if colors: self.colors = colors else: cg = color_gen() self.colors = [next(cg) for _ in range(0, len(nodes_sizes))] sizes_sum = sum(nodes_sizes) # Nodes legend_node_index = [] # Keeps indexes of nodes which will be in legend sa = 0 for i, size in enumerate(nodes_sizes): ea = sa + size * 360. / sizes_sum if ea - sa >= 10: angle = sa + (ea - sa) / 2 pos = polar2point(angle, 1.05 * r) ax.text(pos[0], pos[1], "{} ({:.2f}%)".format(labels[i], 100.0 * size / sizes_sum), verticalalignment="center", horizontalalignment="center", fontsize=10, rotation=360 - angle) else: legend_node_index.append(i) node = Node(r, sa, ea, self.colors[i]) ax.add_patch(node) self.nodes.append(node) sa = ea # Create legend ax.legend([self.nodes[i] for i in legend_node_index], ["{} ({:.2f}%)".format(labels[i], 100.0 * nodes_sizes[i] / sizes_sum) for i in legend_node_index], bbox_to_anchor=(1.02, 1), loc="upper left") # Scales links_sum = [0.] * len(nodes_sizes) for link in links: links_sum[link["source"]] += link["value"] links_sum[link["dest"]] += link["value"] scales = [] for i, (size, link_sum) in enumerate(zip(nodes_sizes, links_sum)): if size < link_sum: scales.append(0.9999 * size / link_sum) # Scale is reduced to 99% due to float precision else: scales.append(1.) # Links for link in links: source_node = self.nodes[link["source"]] dest_node = self.nodes[link["dest"]] value = link["value"] sa0 = source_node.get_arc_offset() slink_arc = scales[link["source"]] * value * 360. / sizes_sum sa1 = sa0 + slink_arc source_node.reserve_arc(slink_arc) ea0 = dest_node.get_arc_offset() elink_arc = scales[link["dest"]] * value * 360. / sizes_sum ea1 = ea0 + elink_arc dest_node.reserve_arc(elink_arc) l = Link(0.8 * r, sa0, sa1, ea0, ea1, source_node.color) a = Arrow(0.81 * r, sa0, sa1, source_node.color) ax.add_patch(l) ax.add_patch(a) if sa1 - sa0 > 6: # Complimentary color rgb_color = hex2rgb(source_node.color.lstrip("#")) if isinstance(source_node.color, str) else source_node.color hsl_color = list(colorsys.rgb_to_hls(*rgb_color)) if not hsl_color[0]: if 138 >= hsl_color[1] >= 118: # For gray color automatically set white hsl_color[1] = 255 else: hsl_color[1] = (hsl_color[1] - 255) * -1.0 else: hsl_color[0] += 0.5 complimentary_color = [c / 255 for c in colorsys.hls_to_rgb(*hsl_color)] # Arrow text angle = sa0 + (sa1 - sa0) / 2 pos = polar2point(sa0 + (sa1 - sa0) / 2, 0.86 * r) ax.text(pos[0], pos[1], link["value"], verticalalignment="center", horizontalalignment="center", fontsize=9, rotation=180 - angle, color="white") if __name__ == "__main__": fig, ax = plt.subplots(subplot_kw={"aspect": 1}) ax.set_axis_off() ax.set_xlim(-110, 110) ax.set_ylim(-110, 110) labels = ["Data #1", "Data #2", "Data #3", "Data #4"] sizes = [5000, 6000, 7000] links = [dict(source=1, dest=0, value=2000), dict(source=1, dest=0, value=2000), dict(source=2, dest=0, value=4600), dict(source=0, dest=0, value=1000)] Chord(ax, 100, sizes, links, labels) fig.savefig("chord.png", format="png", dpi=2 ** 7) plt.show()