diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..2103ebe --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,12 @@ +Version 0.2 +----------- + + - Changed parameterization of venn3 and venn3_circles (now expects 7-element vectors as arguments rather than 8-element). + - 2-set venn diagrams (functions venn2 and venn2_circles) + - Added support for non-intersecting sets ("Euler diagrams") + - Minor fixes here and there. + +Version 0.1 +----------- + + - Initial version, three-circle area-weighted venn diagrams. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index c4bf456..5947fd7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include README.rst \ No newline at end of file +include README.rst CHANGELOG.txt \ No newline at end of file diff --git a/README.rst b/README.rst index af2330d..6660879 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ Venn diagram plotting routines for Python/Matplotlib About ----- -This package contains a rountine for plotting area-weighted three-circle venn diagrams. +This package contains routines for plotting area-weighted two- and three-circle venn diagrams. Copyright 2012, Konstantin Tretyakov. http://kt.era.ee/ @@ -17,28 +17,41 @@ Installable as any other Python package, either through :code:`easy_install`, or Usage ----- -There are two main functions in the package: :code:`venn3_circles` and :code:`venn3` +There are four main functions here: :code:`venn2`, :code:`venn2_circles`, :code:`venn3`, :code:`venn3_circles`. -Both accept as their only required argument an 8-element list of set sizes, +The :code:`venn2` and :code:`venn2_circles` accept as their only required argument a 3-element list of subset sizes: -:code:`sets = (abc, Abc, aBc, ABc, abC, AbC, aBC, ABC)` + subsets = (Ab, aB, AB) -That is, for example, :code:`sets[1]` contains the size of the set (A and not B and not C), -:code:`sets[3]` contains the size of the set (A and B and not C), etc. Note that the value in :code:`sets[0]` is not used. +That is, for example, subsets[0] contains the size of the subset (A and not B), and +subsets[2] contains the size of the set (A and B), etc. -The function :code:`venn3_circles` simply draws three circles such that their intersection areas would correspond -to the desired set intersection sizes. Note that it is not impossible to achieve exact correspondence, but in +Similarly, the functions :code:`venn3` and :code:`venn3_circles` require a 7-element list: + + subsets = (Abc, aBc, ABc, abC, AbC, aBC, ABC) + +The functions :code:`venn2_circles` and :code:`venn3_circles` simply draw two or three circles respectively, +such that their intersection areas correspond to the desired set intersection sizes. +Note that for a three-circle venn diagram it is not possible to achieve exact correspondence, although in most cases the picture will still provide a decent indication. -The function :code:`venn3` draws the venn diagram as a collection of 8 separate colored patches with text labels. +The functions :code:`venn2` and :code:`venn3` draw diagram as a collection of separate colored patches with text labels. + +The functions :code:`venn2_circles` and :code:`venn3_circles` return the list of Circle patches that may be tuned further +to your liking. -The function :code:`venn3_circles` returns the list of Circle patches that may be tuned further. -The function :code:`venn3` returns an object of class :code:`Venn3`, which also gives access to diagram patches and text elements. +The functions :code:`venn2` and :code:`venn3` return an object of class :code:`Venn2` or :code:`Venn3` respectively, +which give access to constituent patches and text elements. Basic Example:: + from matplotlib.venn improt venn2 + venn2(subsets = (3, 2, 1)) + +For the three-circle case:: + from matplotlib.venn import venn3 - venn3(sets = (0, 1, 1, 1, 2, 1, 2, 2), set_labels = ('Set1', 'Set2', 'Set3')) + venn3(subsets = (1, 1, 1, 2, 1, 2, 2), set_labels = ('Set1', 'Set2', 'Set3')) More elaborate example:: @@ -46,16 +59,15 @@ More elaborate example:: import numpy as np from matplotlib.venn import venn3, venn3_circles plt.figure(figsize=(4,4)) - v = venn3(sets=(0, 1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) + v = venn3(subsets=(1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) v.get_patch_by_id('100').set_alpha(1.0) v.get_patch_by_id('100').set_color('white') - v.get_text_by_id('100').set_text('Unknown') - v.labels[0].set_text('Set "A"') - c = venn3_circles(sets=(0, 1, 1, 1, 1, 1, 1, 1), linestyle='dashed') + v.get_label_by_id('100').set_text('Unknown') + v.get_label_by_id('A').set_text('Set "A"') + c = venn3_circles(subsets=(1, 1, 1, 1, 1, 1, 1), linestyle='dashed') c[0].set_lw(1.0) c[0].set_ls('dotted') plt.title("Sample Venn diagram") - plt.annotate('Unknown set', xy=v.get_text_by_id('100').get_position() - np.array([0, 0.05]), xytext=(-70,-70), + plt.annotate('Unknown set', xy=v.get_label_by_id('100').get_position() - np.array([0, 0.05]), xytext=(-70,-70), ha='center', textcoords='offset points', bbox=dict(boxstyle='round,pad=0.5', fc='gray', alpha=0.1), arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.5',color='gray')) - diff --git a/matplotlib/venn/__init__.py b/matplotlib/venn/__init__.py index d2755eb..2c2851f 100644 --- a/matplotlib/venn/__init__.py +++ b/matplotlib/venn/__init__.py @@ -6,35 +6,45 @@ Licensed under MIT license. -This package contains a rountine for plotting area-weighted three-circle venn diagrams. -There are two main functions here: venn3_circles and venn3 +This package contains routines for plotting area-weighted two- and three-circle venn diagrams. +There are four main functions here: :code:`venn2`, :code:`venn2_circles`, :code:`venn3`, :code:`venn3_circles`. -Both accept as their only required argument an 8-element list of set sizes, -sets = (abc, Abc, aBc, ABc, abC, AbC, aBC, ABC) +The :code:`venn2` and :code:`venn2_circles` accept as their only required argument a 3-element list of subset sizes: -That is, for example, sets[1] contains the size of the set (A and not B and not C), -sets[3] contains the size of the set (A and B and not C), etc. Note that the value in sets[0] is not used. + subsets = (Ab, aB, AB) -The function venn3_circles simply draws three circles such that their intersection areas would correspond -to the desired set intersection sizes. Note that it is not impossible to achieve exact correspondence, but in +That is, for example, subsets[0] contains the size of the subset (A and not B), and +subsets[2] contains the size of the set (A and B), etc. + +Similarly, the functions :code:`venn3` and :code:`venn3_circles` require a 7-element list: + + subsets = (Abc, aBc, ABc, abC, AbC, aBC, ABC) + +The functions :code:`venn2_circles` and :code:`venn3_circles` simply draw two or three circles respectively, +such that their intersection areas correspond to the desired set intersection sizes. +Note that for a three-circle venn diagram it is not possible to achieve exact correspondence, although in most cases the picture will still provide a decent indication. -The function venn3 draws the venn diagram as a collection of 8 separate colored patches with text labels. +The functions :code:`venn2` and :code:`venn3` draw diagram as a collection of separate colored patches with text labels. + +The functions :code:`venn2_circles` and :code:`venn3_circles` return the list of Circle patches that may be tuned further +to your liking. + +The functions :code:`venn2` and :code:`venn3` return an object of class :code:`Venn2` or :code:`Venn3` respectively, +which give access to constituent patches and text elements. -The function venn3_circles returns the list of Circle patches that may be tuned further. -The function venn3 returns an object of class Venn3, which also gives access to diagram patches and text elements. +Example:: -Example: from matplotlib import pyplot as plt import numpy as np from matplotlib.venn import venn3, venn3_circles plt.figure(figsize=(4,4)) - v = venn3(sets=(0, 1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) + v = venn3(subsets=(1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) v.get_patch_by_id('100').set_alpha(1.0) v.get_patch_by_id('100').set_color('white') - v.get_text_by_id('100').set_text('Unknown') - v.labels[0].set_text('Set "A"') - c = venn3_circles(sets=(0, 1, 1, 1, 1, 1, 1, 1), linestyle='dashed') + v.get_label_by_id('100').set_text('Unknown') + v.get_label_by_id('A').set_text('Set "A"') + c = venn3_circles(subsets=(1, 1, 1, 1, 1, 1, 1), linestyle='dashed') c[0].set_lw(1.0) c[0].set_ls('dotted') plt.title("Sample Venn diagram") @@ -42,5 +52,6 @@ ha='center', textcoords='offset points', bbox=dict(boxstyle='round,pad=0.5', fc='gray', alpha=0.1), arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.5',color='gray')) ''' +#from _venn2 import venn2, venn2_circles from _venn3 import venn3, venn3_circles -___all___ = ['venn3', 'venn3_circles'] +___all___ = ['venn2', 'venn2_circles', 'venn3', 'venn3_circles'] diff --git a/matplotlib/venn/_math.py b/matplotlib/venn/_math.py index f0fa887..9099361 100644 --- a/matplotlib/venn/_math.py +++ b/matplotlib/venn/_math.py @@ -68,27 +68,32 @@ def circle_line_intersection(center, r, a, b): t2 = (-B - np.sqrt(disc))/2.0/A return np.array([a + t1*s, a+t2*s]) -def find_distance_by_area(r, R, a): +def find_distance_by_area(r, R, a, numeric_correction = 0.0001): ''' Solves circle_intersection_area(r, R, d) == a for d numerically (analytical solution seems to be too ugly to pursue). Assumes that a < pi * min(r, R)**2, will fail otherwise. - >>> find_distance_by_area(1, 1, 0) + The numeric correction parameter is used whenever the computed distance is exactly (R - r) (i.e. one circle must be inside another). + In this case the result returned is (R-r+correction). This helps later when we position the circles and need to ensure they intersect. + + >>> find_distance_by_area(1, 1, 0, 0.0) 2.0 - >>> round(find_distance_by_area(1, 1, 3.1415), 4) + >>> round(find_distance_by_area(1, 1, 3.1415, 0.0), 4) 0.0 - >>> d = find_distance_by_area(2, 3, 4) + >>> d = find_distance_by_area(2, 3, 4, 0.0) >>> d 3.37... >>> round(circle_intersection_area(2, 3, d), 10) 4.0 + >>> find_distance_by_area(1, 2, np.pi) + 1.0001 ''' if r > R: r, R = R, r if np.abs(a) < tol: return float(r + R) if np.abs(min([r, R])**2*np.pi - a) < tol: - return np.abs(R - r) + return np.abs(R - r + numeric_correction) return brentq(lambda x: circle_intersection_area(r, R, x) - a, R-r, R+r) def circle_circle_intersection(C_a, r_a, C_b, r_b): @@ -154,3 +159,26 @@ def vector_angle_in_degrees(v): -45.0 ''' return np.arctan2(v[1], v[0])*180/np.pi + +def normalize_by_center_of_mass(coords, radii): + ''' + Given coordinates of circle centers and radii, as two arrays, + returns new coordinates array, computed such that the center of mass of the + three circles is (0, 0). + + >>> normalize_by_center_of_mass(np.array([[0.0, 0.0], [2.0, 0.0], [1.0, 3.0]]), np.array([1.0, 1.0, 1.0])) + array([[-1., -1.], + [ 1., -1.], + [ 0., 2.]]) + >>> normalize_by_center_of_mass(np.array([[0.0, 0.0], [2.0, 0.0], [1.0, 2.0]]), np.array([1.0, 1.0, np.sqrt(2.0)])) + array([[-1., -1.], + [ 1., -1.], + [ 0., 1.]]) + ''' + # Now find the center of mass. + radii = radii**2 + sum_r = np.sum(radii) + if sum_r < tol: + return coords + else: + return coords - np.dot(radii, coords)/np.sum(radii) \ No newline at end of file diff --git a/matplotlib/venn/_venn2.py b/matplotlib/venn/_venn2.py new file mode 100644 index 0000000..87d729b --- /dev/null +++ b/matplotlib/venn/_venn2.py @@ -0,0 +1,233 @@ +''' +Venn diagram plotting routines. +Two-circle venn plotter. + +Copyright 2012, Konstantin Tretyakov. +http://kt.era.ee/ + +Licensed under MIT license. +''' +import numpy as np +import warnings + +from matplotlib.patches import Circle, PathPatch +from matplotlib.path import Path +from matplotlib.colors import ColorConverter +from matplotlib.pyplot import gca + +from _math import * + +from _venn3 import make_venn3_region_patch, prepare_venn3_axes +make_venn2_region_patch = make_venn3_region_patch +prepare_venn2_axes = prepare_venn3_axes + + + +def compute_venn2_areas(diagram_areas, normalize_to=1.0): + ''' + The list of venn areas is given as 3 values, corresponding to venn diagram areas in the following order: + (Ab, aB, AB) (i.e. last element corresponds to the size of intersection A&B&C). + The return value is a list of areas (A, B, AB), such that the total area is normalized + to normalize_to. If total area was 0, returns + (1.0, 1.0, 0.0)/2.0 + + Assumes all input values are nonnegative (to be more precise, all areas are passed through and abs() function) + >>> compute_venn2_areas((1, 1, 0)) + (0.5, 0.5, 0.0) + >>> compute_venn2_areas((0, 0, 0)) + (0.5, 0.5, 0.0) + >>> compute_venn2_areas((1, 1, 1), normalize_to=3) + (2.0, 2.0, 1.0) + >>> compute_venn2_areas((1, 2, 3), normalize_to=6) + (4.0, 5.0, 3.0) + ''' + # Normalize input values to sum to 1 + areas = np.array(np.abs(diagram_areas), float) + total_area = np.sum(areas) + if np.abs(total_area) < tol: + return (0.5, 0.5, 0.0) + else: + areas = areas/total_area*normalize_to + return (areas[0] + areas[2], areas[1] + areas[2], areas[2]) + +def solve_venn2_circles(venn_areas): + ''' + Given the list of "venn areas" (as output from compute_venn2_areas, i.e. [A, B, AB]), + finds the positions and radii of the two circles. + The return value is a tuple (coords, radii), where coords is a 2x2 array of coordinates and + radii is a 2x1 array of circle radii. + + Assumes the input values to be nonnegative and not all zero. + In particular, the first two values must be positive. + + >>> c, r = solve_venn2_circles((1, 1, 0)) + >>> np.round(r, 3) + array([ 0.564, 0.564]) + >>> c, r = solve_venn2_circles(compute_venn2_areas((1, 2, 3))) + >>> np.round(r, 3) + array([ 0.461, 0.515]) + ''' + (A_a, A_b, A_ab) = map(float, venn_areas) + r_a, r_b = np.sqrt(A_a/np.pi), np.sqrt(A_b/np.pi) + radii = np.array([r_a, r_b]) + if A_ab > tol: + # Nonzero intersection + coords = np.zeros((2,2)) + coords[1][0] = find_distance_by_area(radii[0], radii[1], A_ab) + else: + # Zero intersection + coords = np.zeros((2,2)) + coords[1][0] = radii[0] + radii[1] + np.mean(radii)*1.1 + coords = normalize_by_center_of_mass(coords, radii) + return (coords, radii) + +def compute_venn2_regions(centers, radii): + ''' + See compute_venn3_regions for explanations. + >>> centers, radii = solve_venn2_circles((1, 1, 0.5)) + >>> regions = compute_venn2_regions(centers, radii) + ''' + intersection = circle_circle_intersection(centers[0], radii[0], centers[1], radii[1]) + if intersection is None: + # Two circular regions + regions = [("CIRCLE", (centers[a], radii[a], True), centers[a]) for a in [0, 1]] + [None] + else: + # Three curved regions + regions = [] + for (a, b) in [(0, 1), (1, 0)]: + # Make region a¬ b: [(AB, A-), (BA, B+)] + points = np.array([intersection[a], intersection[b]]) + arcs = [(centers[a], radii[a], False), (centers[b], radii[b], True)] + if centers[a][0] < centers[b][0]: + # We are to the left + label_pos_x = (centers[a][0] - radii[a] + centers[b][0] - radii[b])/2.0 + else: + # We are to the right + label_pos_x = (centers[a][0] + radii[a] + centers[b][0] + radii[b])/2.0 + label_pos = np.array([label_pos_x, centers[a][1]]) + regions.append((points, arcs, label_pos)) + + # Make region a&b: [(AB, A+), (BA, B+)] + (a, b) = (0, 1) + points = np.array([intersection[a], intersection[b]]) + arcs = [(centers[a], radii[a], True), (centers[b], radii[b], True)] + label_pos_x = (centers[a][0] + radii[a] + centers[b][0] - radii[b])/2.0 + label_pos = np.array([label_pos_x, centers[a][1]]) + regions.append((points, arcs, label_pos)) + return regions + +def compute_venn2_colors(set_colors): + ''' + Given two base colors, computes combinations of colors corresponding to all regions of the venn diagram. + returns a list of 3 elements, providing colors for regions (10, 01, 11). + + >>> compute_venn2_colors(('r', 'g')) + (array([ 1., 0., 0.]), array([ 0. , 0.5, 0. ]), array([ 0.7 , 0.35, 0. ])) + ''' + ccv = ColorConverter() + base_colors = [np.array(ccv.to_rgb(c)) for c in set_colors] + return (base_colors[0], base_colors[1], 0.7*(base_colors[0] + base_colors[1])) + +def venn2_circles(subsets, normalize_to=1.0, alpha=1.0, color='black', linestyle='solid', linewidth=2.0, **kwargs): + ''' + Plots only the two circles for the corresponding Venn diagram. + Useful for debugging or enhancing the basic venn diagram. + parameters sets and normalize_to are the same as in venn2() + kwargs are passed as-is to matplotlib.patches.Circle. + returns a list of three Circle patches. + + >>> c = venn2_circles((1, 2, 3)) + ''' + if isinstance(subsets, dict): + subsets = [s.get(t, 0) for t in ['10', '01', '11']] + areas = compute_venn2_areas(subsets, normalize_to) + centers, radii = solve_venn2_circles(areas) + ax = gca() + prepare_venn2_axes(ax, centers, radii) + result = [] + for (c, r) in zip(centers, radii): + circle = Circle(c, r, alpha=alpha, edgecolor=color, facecolor='none', ls=linestyle, lw=linewidth, **kwargs) + ax.add_patch(circle) + result.append(circle) + return result + + +class Venn2: + ''' + A container for a set of patches and patch labels and set labels, which make up the rendered venn diagram. + ''' + id2idx = {'10':0,'01':1,'11':2,'A':0, 'B':1} + def __init__(self, patches, subset_labels, set_labels): + self.patches = patches + self.subset_labels = subset_labels + self.set_labels = set_labels + def get_patch_by_id(self, id): + '''Returns a patch by a "region id". A region id is a string '10', '01' or '11'.''' + return self.patches[self.id2idx[id]] + def get_label_by_id(self, id): + ''' + Returns a subset label by a "region id". A region id is a string '10', '01' or '11'. + Alternatively, if the string 'A' or 'B' is given, the label of the + corresponding set is returned (or None).''' + if len(id) == 1: + return self.set_labels[self.id2idx[id]] if self.set_labels is not None else None + else: + return self.subset_labels[self.id2idx[id]] + +def venn2(subsets, set_labels = ('A', 'B'), set_colors=('r', 'g'), alpha=0.4, normalize_to=1.0): + '''Plots a 2-set area-weighted Venn diagram. + The subsets parameter is either a dict or a list. + - If it is a dict, it must map regions to their sizes. + The regions are identified via two-letter binary codes ('10', '01', and '11'), hence a valid set could look like: + {'01': 10, '01': 20, '11': 40}. Unmentioned codes are considered to map to 0. + - If it is a list, it must have 3 elements, denoting the sizes of the regions in the following order: + (10, 10, 11) + + Set labels parameter is a list of two strings - set labels. Set it to None to disable set labels. + The set_colors parameter should be a list of two elements, specifying the "base colors" of the two circles. + The color of circle intersection will be computed based on those. + + The normalize_to parameter specifies the total (on-axes) area of the circles to be drawn. Sometimes tuning it (together + with the overall fiture size) may be useful to fit the text labels better. + The return value is a Venn2 object, that keeps references to the Text and Patch objects used on the plot. + + >>> from matplotlib.venn import * + >>> v = venn2(subsets=(1, 1, 1), set_labels = ('A', 'B')) + >>> c = venn2_circles(subsets=(1, 1, 1), linestyle='dashed') + >>> v.get_patch_by_id('10').set_alpha(1.0) + >>> v.get_patch_by_id('10').set_color('white') + >>> v.get_label_by_id('10').set_text('Unknown') + >>> v.get_label_by_id('A').set_text('Set A') + ''' + if isinstance(subsets, dict): + subsets = [s.get(t, 0) for t in ['10', '01', '11']] + areas = compute_venn2_areas(subsets, normalize_to) + centers, radii = solve_venn2_circles(areas) + if (areas[0] < tol or areas[1] < tol): + raise Exception("Both circles in the diagram must have positive areas.") + centers, radii = solve_venn2_circles(areas) + regions = compute_venn2_regions(centers, radii) + colors = compute_venn2_colors(set_colors) + + ax = gca() + prepare_venn2_axes(ax, centers, radii) + # Create and add patches and text + patches = [make_venn2_region_patch(r) for r in regions] + for (p, c) in zip(patches, colors): + if p is not None: + p.set_facecolor(c) + p.set_edgecolor('none') + p.set_alpha(alpha) + ax.add_patch(p) + texts = [ax.text(r[2][0], r[2][1], str(s), va='center', ha='center') if r is not None else None for (r, s) in zip(regions, subsets)] + + # Position labels + if set_labels is not None: + padding = np.mean([r * 0.1 for r in radii]) + label_positions = [centers[0] + np.array([0.0, - radii[0] - padding]), + centers[1] + np.array([0.0, - radii[1] - padding])] + labels = [ax.text(pos[0], pos[1], txt, size='large', ha='right', va='top') for (pos, txt) in zip(label_positions, set_labels)] + labels[1].set_ha('left') + else: + labels = None + return Venn2(patches, texts, labels) \ No newline at end of file diff --git a/matplotlib/venn/_venn3.py b/matplotlib/venn/_venn3.py index 2aa63f4..53cf306 100644 --- a/matplotlib/venn/_venn3.py +++ b/matplotlib/venn/_venn3.py @@ -9,104 +9,177 @@ ''' import numpy as np import warnings + +from matplotlib.patches import Circle, PathPatch +from matplotlib.path import Path +from matplotlib.colors import ColorConverter +from matplotlib.pyplot import gca + from _math import * -tol = 1e-10 - -def solve_venn3_circles(areas, normalize_to=1.0): +def compute_venn3_areas(diagram_areas, normalize_to=1.0): ''' - The list of venn areas is given as 8 values, corresponding to venn diagram areas in the following order: - (abc, Abc, aBc, ABc, abC, AbC, aBC, ABC) + The list of venn areas is given as 7 values, corresponding to venn diagram areas in the following order: + (Abc, aBc, ABc, abC, AbC, aBC, ABC) (i.e. last element corresponds to the size of intersection A&B&C). - The return value is a list (r_a, r_b, r_c, d_ab, d_ac, d_bc), denoting the radii of the three circles - and the distances between their centers. - Assumes all input values are nonnegative. - Returns circles, such that their total area is normalized to . - Yes, the first value in the provided list is not used at all in this method. - Yes, the overall match is only approximate (to be precise, what is matched are the areas of the circles and the - three pairwise intersections). + The return value is a list of areas (A_a, A_b, A_c, A_ab, A_bc, A_ac, A_abc), + such that the total area of all circles is normalized to normalize_to. If total area was 0, returns + (1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0)/3.0 - >>> solve_venn3_circles((0, 1, 1, 0, 1, 0, 0, 0)) - (0.3257..., 0.3257..., 0.3257..., 0.6514..., 0.6514..., 0.6514...) - >>> solve_venn3_circles((0, 1, 2, 40, 30, 4, 40, 4)) - (0.359..., 0.475..., 0.452..., 0.198..., 0.435..., 0.345...) + Assumes all input values are nonnegative (to be more precise, all areas are passed through and abs() function) + >>> compute_venn3_areas((1, 1, 0, 1, 0, 0, 0)) + (0.33..., 0.33..., 0.33..., 0.0, 0.0, 0.0, 0.0) + >>> compute_venn3_areas((0, 0, 0, 0, 0, 0, 0)) + (0.33..., 0.33..., 0.33..., 0.0, 0.0, 0.0, 0.0) + >>> compute_venn3_areas((1, 1, 1, 1, 1, 1, 1), normalize_to=7) + (4.0, 4.0, 4.0, 2.0, 2.0, 2.0, 1.0) + >>> compute_venn3_areas((1, 2, 3, 4, 5, 6, 7), normalize_to=56/2) + (16.0, 18.0, 22.0, 10.0, 13.0, 12.0, 7.0) ''' # Normalize input values to sum to 1 - areas = np.array(areas[1:], float) + areas = np.array(np.abs(diagram_areas), float) total_area = np.sum(areas) if np.abs(total_area) < tol: - return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) - areas = areas/total_area*normalize_to + return (1.0/3.0, 1.0/3.0, 1.0/3.0, 0.0, 0.0, 0.0, 0.0) + else: + areas = areas/total_area*normalize_to + A_a = areas[0] + areas[2] + areas[4] + areas[6] + A_b = areas[1] + areas[2] + areas[5] + areas[6] + A_c = areas[3] + areas[4] + areas[5] + areas[6] + + # Areas of the three intersections (ab, ac, bc) + A_ab, A_ac, A_bc = areas[2] + areas[6], areas[4] + areas[6], areas[5] + areas[6] + + return (A_a, A_b, A_c, A_ab, A_bc, A_ac, areas[6]) + + +def solve_venn3_circles(venn_areas): + ''' + Given the list of "venn areas" (as output from compute_venn3_areas, i.e. [A, B, C, AB, BC, AC, ABC]), + finds the positions and radii of the three circles. + The return value is a tuple (coords, radii), where coords is a 3x2 array of coordinates and + radii is a 3x1 array of circle radii. + + Assumes the input values to be nonnegative and not all zero. + In particular, the first three values must all be positive. - # Compute areas of the three circles - A_a = areas[0] + areas[2] + areas[4] + areas[6] - A_b = areas[1] + areas[2] + areas[5] + areas[6] - A_c = areas[3] + areas[4] + areas[5] + areas[6] - r_a, r_b, r_c = np.sqrt(A_a/np.pi), np.sqrt(A_b/np.pi), np.sqrt(A_c/np.pi) + The overall match is only approximate (to be precise, what is matched are the areas of the circles and the + three pairwise intersections). - # Compute areas of the three intersections (ab, ac, bc) - A_ab, A_ac, A_bc = areas[2] + areas[6], areas[4] + areas[6], areas[5] + areas[6] - d_ab = find_distance_by_area(r_a, r_b, A_ab) - d_ac = find_distance_by_area(r_a, r_c, A_ac) - d_bc = find_distance_by_area(r_b, r_c, A_bc) + >>> c, r = solve_venn3_circles((1, 1, 1, 0, 0, 0, 0)) + >>> np.round(r, 3) + array([ 0.564, 0.564, 0.564]) + >>> c, r = solve_venn3_circles(compute_venn3_areas((1, 2, 40, 30, 4, 40, 4))) + >>> np.round(r, 3) + array([ 0.359, 0.476, 0.453]) + ''' + (A_a, A_b, A_c, A_ab, A_bc, A_ac, A_abc) = map(float, venn_areas) + r_a, r_b, r_c = np.sqrt(A_a/np.pi), np.sqrt(A_b/np.pi), np.sqrt(A_c/np.pi) + intersection_areas = [A_ab, A_bc, A_ac] + radii = np.array([r_a, r_b, r_c]) - # Ad-hoc fix to ensure that resulting circles can be at all positioned - if d_ab > d_bc + d_ac: - d_ab = 0.8*(d_ac + d_bc) - warnings.warn("Bad circle positioning") - if d_bc > d_ab + d_ac: - d_bc = 0.8*(d_ab + d_ac) - warnings.warn("Bad circle positioning") - if d_ac > d_ab + d_bc: - d_ac = 0.8*(d_ab + d_bc) - warnings.warn("Bad circle positioning") - return (r_a, r_b, r_c, d_ab, d_ac, d_bc) + # Hypothetical distances between circle centers that assure + # that their pairwise intersection areas match the requirements. + dists = [find_distance_by_area(radii[i], radii[j], intersection_areas[i]) for (i, j) in [(0, 1), (1,2), (2,0)]] -def position_venn3_circles(r_a, r_b, r_c, d_ab, d_ac, d_bc): + # How many intersections have nonzero area? + num_nonzero = sum(np.array([A_ab, A_bc, A_ac]) > tol) + + # Handle four separate cases: + # 1. All pairwise areas nonzero + # 2. Two pairwise areas nonzero + # 3. One pairwise area nonzero + # 4. All pairwise areas zero. + + if num_nonzero == 3: + # The "generic" case, simply use dists to position circles at the vertices of a triangle. + # Before we need to ensure that resulting circles can be at all positioned on a triangle, + # use an ad-hoc fix. + for i in range(3): + i, j, k = (i, (i+1)%3, (i+2)%3) + if dists[i] > dists[j] + dists[k]: + dists[i] = 0.8*(dists[j] + dists[k]) + warnings.warn("Bad circle positioning") + coords = position_venn3_circles_generic(radii, dists) + elif num_nonzero == 2: + # One pair of circles is not intersecting. + # In this case we can position all three circles in a line + # The two circles that have no intersection will be on either sides. + for i in range(3): + if intersection_areas[i] < tol: + (left, right, middle) = (i, (i+1)%3, (i+2)%3) + coords = np.zeros((3,2)) + coords[middle][0] = dists[middle] + coords[right][0] = dists[middle] + dists[right] + # We want to avoid the situation where left & right still intersect + if coords[left][0] + radii[left] > coords[right][0] - radii[right]: + mid = (coords[left][0] + radii[left] + coords[right][0] - radii[right])/2.0 + coords[left][0] = mid - radii[left] - 1e-5 + coords[right][0] = mid + radii[right] + 1e-5 + break + elif num_nonzero == 1: + # Only one pair of circles is intersecting, and one circle is independent. + # Position all on a line first two intersecting, then the free one. + for i in range(3): + if intersection_areas[i] > tol: + (left, right, side) = (i, (i+1)%3, (i+2)%3) + coords = np.zeros((3,2)) + coords[right][0] = dists[left] + coords[side][0] = dists[left] + radii[right] + radii[side]*1.1 # Pad by 10% + break + else: + # All circles are non-touching. Put them all in a sequence + coords = np.zeros((3,2)) + coords[1][0] = radii[0] + radii[1]*1.1 + coords[2][0] = radii[0] + radii[1]*1.1 + radii[1] + radii[2]*1.1 + + coords = normalize_by_center_of_mass(coords, radii) + return (coords, radii) + +def position_venn3_circles_generic(radii, dists): ''' - Given radii and distances between the circles (the output from solve_venn3_circles), - finds the coordinates of the centers for the three circles. Returns a 3x2 array with circle center coordinates in rows. - Circles are positioned so that the center of mass is at (0, 0), the centers of A and B are on a horizontal line, and C is just below. + Given radii = (r_a, r_b, r_c) and distances between the circles = (d_ab, d_bc, d_ac), + finds the coordinates of the centers for the three circles so that they form a proper triangle. + The current positioning method puts the center of A and B on a horizontal line y==0, + and C just below. - >>> position_venn3_circles(1, 1, 1, 0, 0, 0) + Returns a 3x2 array with circle center coordinates in rows. + + >>> position_venn3_circles_generic((1, 1, 1), (0, 0, 0)) array([[ 0., 0.], [ 0., 0.], [ 0., -0.]]) - >>> position_venn3_circles(1, 1, 1, 2, 2, 2) - array([[-1. , 0.577...], - [ 1. , 0.577...], - [ 0. , -1.154...]]) + >>> position_venn3_circles_generic((1, 1, 1), (2, 2, 2)) + array([[ 0. , 0. ], + [ 2. , 0. ], + [ 1. , -1.73205081]]) ''' + (d_ab, d_bc, d_ac) = dists + (r_a, r_b, r_c) = radii coords = np.array([[0, 0], [d_ab, 0], [0, 0]], float) C_x = (d_ac**2 - d_bc**2 + d_ab**2)/2.0/d_ab if np.abs(d_ab) > tol else 0.0 C_y = -np.sqrt(d_ac**2 - C_x**2) coords[2,:] = C_x, C_y - - # Now find the center of mass. - r_a2, r_b2, r_c2 = r_a**2, r_b**2, r_c**2 - if np.abs(r_a2 + r_b2 + r_c2) < tol: - cmass = array([0.0, 0.0]) - else: - cmass = (r_a2 * coords[0] + r_b2 * coords[1] + r_c2 * coords[2])/(r_a2 + r_b2 + r_c2) - for i in range(3): - coords[i] = coords[i] - cmass return coords def compute_venn3_regions(centers, radii): ''' - Given the 3x2 matrix with circle center coordinates and a 3-element list (or array) with circle radii, + Given the 3x2 matrix with circle center coordinates, and a 3-element list (or array) with circle radii [as returned from solve_venn3_circles], returns the 7 regions, comprising the venn diagram. Each region is given as [array([pt_1, pt_2, pt_3]), (arc_1, arc_2, arc_3), label_pos] where each pt_i gives the coordinates of a point, and each arc_i is in turn a triple (circle_center, circle_radius, direction), and label_pos is the recommended center point for positioning region label. + The region is the poly-curve constructed by moving from pt_1 to pt_2 along arc_1, then to pt_3 along arc_2 and back to pt_1 along arc_3. Arc direction==True denotes positive (CCW) direction. - Regions are returned in order (None, Abc, aBc, ABc, abC, AbC, aBC, ABC) (i.e. first element of the result list is None) + There is also a special case, where the region is given as + ["CIRCLE", (center, radius, True), label_pos], which corresponds to a completely circular region. - >>> circ = solve_venn3_circles((0, 1, 1, 1, 1, 1, 1, 1)) - >>> centers = position_venn3_circles(*circ) - >>> regions = compute_venn3_regions(centers, circ[0:3]) + Regions are returned in order (Abc, aBc, ABc, abC, AbC, aBC, ABC) + + >>> centers, radii = solve_venn3_circles((1, 1, 1, 1, 1, 1, 1)) + >>> regions = compute_venn3_regions(centers, radii) ''' # First compute all pairwise circle intersections intersections = [circle_circle_intersection(centers[i], radii[i], centers[j], radii[j]) for (i, j) in [(0, 1), (1, 2), (2, 0)]] @@ -114,40 +187,79 @@ def compute_venn3_regions(centers, radii): # Regions [Abc, aBc, abC] for i in range(3): (a, b, c) = (i, (i+1)%3, (i+2)%3) - - if np.linalg.norm(intersections[b][0] - centers[a]) < radii[a]: - # In the "normal" situation we use the scheme [(BA, B+), (BC, C+), (AC, A-)] - points = np.array([intersections[a][1], intersections[b][0], intersections[c][1]]) - arcs = [(centers[b], radii[b], True), (centers[c], radii[c], True), (centers[a], radii[a], False)] - - # Ad-hoc label positioning - pt_a = intersections[b][0] - pt_b = intersections[b][1] - pt_c = circle_line_intersection(centers[a], radii[a], pt_a, pt_b) - if pt_c is None: - label_pos = circle_circle_intersection(centers[b], radii[b] + 0.1*radii[a], centers[c], radii[c] + 0.1*radii[c])[0] + if intersections[a] is not None and intersections[c] is not None: + # Current circle intersects both of the other circles. + if intersections[b] is not None: + # .. and the two other circles intersect, this is either the "normal" situation + # or it can also be a case of bad placement + if np.linalg.norm(intersections[b][0] - centers[a]) < radii[a]: + # In the "normal" situation we use the scheme [(BA, B+), (BC, C+), (AC, A-)] + points = np.array([intersections[a][1], intersections[b][0], intersections[c][1]]) + arcs = [(centers[b], radii[b], True), (centers[c], radii[c], True), (centers[a], radii[a], False)] + + # Ad-hoc label positioning + pt_a = intersections[b][0] + pt_b = intersections[b][1] + pt_c = circle_line_intersection(centers[a], radii[a], pt_a, pt_b) + if pt_c is None: + label_pos = circle_circle_intersection(centers[b], radii[b] + 0.1*radii[a], centers[c], radii[c] + 0.1*radii[c])[0] + else: + label_pos = 0.5*(pt_c[1] + pt_a) + else: + # This is the "bad" situation (basically one disc covers two touching disks) + # We use the scheme [(BA, B+), (AB, A-)] if (AC is inside B) and + # [(CA, C+), (AC, A-)] otherwise + if np.linalg.norm(intersections[c][0] - centers[b]) < radii[b]: + points = np.array([intersections[a][1], intersections[a][0]]) + arcs = [(centers[b], radii[b], True), (centers[a], radii[a], False)] + else: + points = np.array([intersections[c][0], intersections[c][1]]) + arcs = [(centers[c], radii[c], True), (centers[a], radii[a], False)] + label_pos = centers[a] else: - label_pos = 0.5*(pt_c[1] + pt_a) + # .. and the two other circles do not intersect. This means we are in the "middle" of a OoO placement. + # The patch is then a [(AB, B-), (BA, A+), (AC, C-), (CA, A+)] + points = np.array([intersections[a][0], intersections[a][1], intersections[c][1], intersections[c][0]]) + arcs = [(centers[b], radii[b], False), (centers[a], radii[a], True), (centers[c], radii[c], False), (centers[a], radii[a], True)] + # Label will be between the b and c circles + leftc, rightc = (b, c) if centers[b][0] < centers[c][0] else (c, b) + label_x = ((centers[leftc][0] + radii[leftc]) + (centers[rightc][0] - radii[rightc]))/2.0 + label_y = centers[a][1] + radii[a]/2.0 + label_pos = np.array([label_x, label_y]) + elif intersections[a] is None and intersections[c] is None: + # Current circle is completely separate from others + points = "CIRCLE" + arcs = (centers[a], radii[a], True) + label_pos = centers[a] else: - # This is the "bad" situation (basically one disc covers two touching disks) - # We use the scheme [(BA, B+), (AB, A-)] if (AC is inside B) and - # [(CA, C+), (AC, A-)] otherwise - if np.linalg.norm(intersections[c][0] - centers[b]) < radii[b]: - points = np.array([intersections[a][1], intersections[a][0]]) - arcs = [(centers[b], radii[b], True), (centers[a], radii[a], False)] + # Current circle intersects one of the other circles + other_circle = b if intersections[a] is not None else c + other_circle_intersection = a if intersections[a] is not None else c + i1, i2 = (0, 1) if intersections[a] is not None else (1, 0) + # The patch is a [(AX, A-), (XA, X+)] + points = np.array([intersections[other_circle_intersection][i1], intersections[other_circle_intersection][i2]]) + arcs = [(centers[a], radii[a], False), (centers[other_circle], radii[other_circle], True)] + if centers[a][0] < centers[other_circle][0]: + # We are to the left + label_pos_x = (centers[a][0] - radii[a] + centers[other_circle][0] - radii[other_circle])/2.0 else: - points = np.array([intersections[c][0], intersections[c][1]]) - arcs = [(centers[c], radii[c], True), (centers[a], radii[a], False)] - label_pos = centers[a] + # We are to the right + label_pos_x = (centers[a][0] + radii[a] + centers[other_circle][0] + radii[other_circle])/2.0 + label_pos = np.array([label_pos_x, centers[a][1]]) regions.append((points, arcs, label_pos)) (a, b, c) = (0, 1, 2) - has_middle_region = np.linalg.norm(intersections[b][0] - centers[a]) < radii[a] # Regions [aBC, AbC, ABc] for i in range(3): (a, b, c) = (i, (i+1)%3, (i+2)%3) + if intersections[b] is None: # No region there + regions.append(None) + continue + + has_middle_region = np.linalg.norm(intersections[b][0] - centers[a]) < radii[a] + if has_middle_region: # This is the "normal" situation (i.e. all three circles have a common area) # We then use the scheme [(CB, C+), (CA, A-), (AB, B+)] @@ -160,7 +272,7 @@ def compute_venn3_regions(centers, radii): pt_b = centers[a] + dir_to_a*radii[a] label_pos = 0.5*(pt_a + pt_b) else: - # This is the "bad" situation, where there is no common area + # This is the situation, where there is no common area # Then the corresponding area is made by scheme [(CB, C+), (BC, B+), None] points = np.array([intersections[b][1], intersections[b][0]]) arcs = [(centers[c], radii[c], True), (centers[b], radii[b], True)] @@ -170,35 +282,35 @@ def compute_venn3_regions(centers, radii): # Central region made by scheme [(BC, B+), (AB, A+), (CA, C+)] (a, b, c) = (0, 1, 2) - points = np.array([intersections[b][0], intersections[a][0], intersections[c][0]]) - label_pos = np.mean(points, 0) # Middle of the central region - arcs = [(centers[b], radii[b], True), (centers[a], radii[a], True), (centers[c], radii[c], True)] - if has_middle_region: - regions.append((points, arcs, label_pos)) + if intersections[a] is None or intersections[b] is None or intersections[c] is None: + # No middle region + regions.append(None) else: - regions.append(([], [], label_pos)) + points = np.array([intersections[b][0], intersections[a][0], intersections[c][0]]) + label_pos = np.mean(points, 0) # Middle of the central region + arcs = [(centers[b], radii[b], True), (centers[a], radii[a], True), (centers[c], radii[c], True)] + has_middle_region = np.linalg.norm(intersections[b][0] - centers[a]) < radii[a] + if has_middle_region: + regions.append((points, arcs, label_pos)) + else: + regions.append(([], [], label_pos)) - # (None, Abc, aBc, ABc, abC, AbC, aBC, ABC) - return (None, regions[0], regions[1], regions[5], regions[2], regions[4], regions[3], regions[6]) - -from matplotlib.path import Path -from matplotlib.patches import PathPatch, Circle -from matplotlib.text import Text -from matplotlib.pyplot import gca -from matplotlib.colors import ColorConverter + # (Abc, aBc, ABc, abC, AbC, aBC, ABC) + return (regions[0], regions[1], regions[5], regions[2], regions[4], regions[3], regions[6]) def make_venn3_region_patch(region): ''' Given a venn3 region (as returned from compute_venn3_regions) produces a Patch object, depicting the region as a curve. - >>> circ = solve_venn3_circles((0, 1, 1, 1, 1, 1, 1, 1)) - >>> centers = position_venn3_circles(*circ) - >>> regions = compute_venn3_regions(centers, circ[0:3]) + >>> centers, radii = solve_venn3_circles((1, 1, 1, 1, 1, 1, 1)) + >>> regions = compute_venn3_regions(centers, radii) >>> patches = [make_venn3_region_patch(r) for r in regions] ''' if region is None or len(region[0]) == 0: return None + if region[0] == "CIRCLE": + return Circle(region[1][0], region[1][1]) pts, arcs, label_pos = region path = [pts[0]] for i in range(len(pts)): @@ -219,11 +331,14 @@ def make_venn3_region_patch(region): def compute_venn3_colors(set_colors): ''' Given three base colors, computes combinations of colors corresponding to all regions of the venn diagram. - returns a list of 8 elements, providing colors for regions (000, 100, 010, 110, 001, 101, 011, 111). + returns a list of 7 elements, providing colors for regions (100, 010, 110, 001, 101, 011, 111). + + >>> compute_venn3_colors(['r', 'g', 'b']) + (array([ 1., 0., 0.]),..., array([ 0.4, 0.2, 0.4])) ''' ccv = ColorConverter() base_colors = [np.array(ccv.to_rgb(c)) for c in set_colors] - return ((1.0, 1.0, 1.0), base_colors[0], base_colors[1], 0.7*(base_colors[0] + base_colors[1]), base_colors[2], + return (base_colors[0], base_colors[1], 0.7*(base_colors[0] + base_colors[1]), base_colors[2], 0.7*(base_colors[0] + base_colors[2]), 0.7*(base_colors[1] + base_colors[2]), 0.4*(base_colors[0] + base_colors[1] + base_colors[2])) def prepare_venn3_axes(ax, centers, radii): @@ -233,27 +348,33 @@ def prepare_venn3_axes(ax, centers, radii): ax.set_aspect('equal') ax.set_xticks([]) ax.set_yticks([]) - min_x = min([centers[i][0] - radii[i] for i in range(3)]) - max_x = max([centers[i][0] + radii[i] for i in range(3)]) - min_y = min([centers[i][1] - radii[i] for i in range(3)]) - max_y = max([centers[i][1] + radii[i] for i in range(3)]) + min_x = min([centers[i][0] - radii[i] for i in range(len(radii))]) + max_x = max([centers[i][0] + radii[i] for i in range(len(radii))]) + min_y = min([centers[i][1] - radii[i] for i in range(len(radii))]) + max_y = max([centers[i][1] + radii[i] for i in range(len(radii))]) ax.set_xlim([min_x - 0.1, max_x + 0.1]) ax.set_ylim([min_y - 0.1, max_y + 0.1]) ax.set_axis_off() -def venn3_circles(sets, normalize_to=1.0, alpha=1.0, color='black', linestyle='solid', linewidth=2.0, **kwargs): +def venn3_circles(subsets, normalize_to=1.0, alpha=1.0, color='black', linestyle='solid', linewidth=2.0, **kwargs): ''' - Plots only the three circles for the corresponding Venn diagram. Useful for debugging or enhancing the basic venn diagram. - normalize_to is the same as in venn3() + Plots only the three circles for the corresponding Venn diagram. + Useful for debugging or enhancing the basic venn diagram. + parameters sets and normalize_to are the same as in venn3() kwargs are passed as-is to matplotlib.patches.Circle. returns a list of three Circle patches. + + >>> plot = venn3_circles({'001': 10, '100': 20, '010': 21, '110': 13, '011': 14}) ''' - circ = solve_venn3_circles(sets, normalize_to) - centers = position_venn3_circles(*circ) + # Prepare parameters + if isinstance(subsets, dict): + subsets = [subsets.get(t, 0) for t in ['100', '010', '110', '001', '101', '011', '111']] + areas = compute_venn3_areas(subsets, normalize_to) + centers, radii = solve_venn3_circles(areas) ax = gca() - prepare_venn3_axes(ax, centers, circ[0:3]) + prepare_venn3_axes(ax, centers, radii) result = [] - for (c, r) in zip(centers, circ[0:3]): + for (c, r) in zip(centers, radii): circle = Circle(c, r, alpha=alpha, edgecolor=color, facecolor='none', ls=linestyle, lw=linewidth, **kwargs) ax.add_patch(circle) result.append(circle) @@ -263,73 +384,92 @@ class Venn3: ''' A container for a set of patches and patch labels and set labels, which make up the rendered venn diagram. ''' - id2idx = {'100':0,'010':1,'110':2,'001':3,'101':4,'011':5,'111':6} - def __init__(self, patches, texts, labels): + id2idx = {'100':0,'010':1,'110':2,'001':3,'101':4,'011':5,'111':6, 'A':0, 'B':1, 'C':2} + def __init__(self, patches, subset_labels, set_labels): self.patches = patches - self.texts = texts - self.labels = labels + self.subset_labels = subset_labels + self.set_labels = set_labels def get_patch_by_id(self, id): '''Returns a patch by a "region id". A region id is a string like 001, 011, 010, etc.''' return self.patches[self.id2idx[id]] - def get_text_by_id(self, id): - '''Returns a text by a "region id". A region id is a string like 001, 011, 010, etc.''' - return self.texts[self.id2idx[id]] + def get_label_by_id(self, id): + ''' + Returns a subset label by a "region id". A region id is a string like 001, 011, 010, etc. + Alternatively, if you provide either of 'A', 'B' or 'C', you will obtain the label of the + corresponding set (or None).''' + if len(id) == 1: + return self.set_labels[self.id2idx[id]] if self.set_labels is not None else None + else: + return self.subset_labels[self.id2idx[id]] -def venn3(sets, set_labels = ('A', 'B', 'C'), set_colors=('r', 'g', 'b'), alpha=0.4, normalize_to=1.0): - '''Plots a 3-set Venn diagram. - The sets parameter is either a dict or a list. +def venn3(subsets, set_labels = ('A', 'B', 'C'), set_colors=('r', 'g', 'b'), alpha=0.4, normalize_to=1.0): + '''Plots a 3-set area-weighted Venn diagram. + The subsets parameter is either a dict or a list. - If it is a dict, it must map regions to their sizes. - The regions are identified via three-letter binary codes ('000', '010', etc), hence a valid set could look like: - {'001': 10, '010': 20, '110', ...} - - If it is a list, it must have 8 elements, denoting the sizes of the regions in the following order: - (000, 100, 010, 110, 001, 101, 011, 111). Note that the first element is not used. + The regions are identified via three-letter binary codes ('100', '010', etc), hence a valid set could look like: + {'001': 10, '010': 20, '110':30, ...}. Unmentioned codes are considered to map to 0. + - If it is a list, it must have 7 elements, denoting the sizes of the regions in the following order: + (100, 010, 110, 001, 101, 011, 111). Set labels parameter is a list of three strings - set labels. Set it to None to disable set labels. The set_colors parameter should be a list of three elements, specifying the "base colors" of the three circles. The colors of circle intersections will be computed based on those. - The normalize_to parameter specifies the total (on-screen) area of the circles to be drawn. Make it larger if your text does not fit. + The normalize_to parameter specifies the total (on-axes) area of the circles to be drawn. Sometimes tuning it (together + with the overall fiture size) may be useful to fit the text labels better. The return value is a Venn3 object, that keeps references to the Text and Patch objects used on the plot. - >> from matplotlib.venn import * - >> v = venn3(sets=(0, 1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) - >> venn3_circles(sets=(0, 1, 1, 1, 1, 1, 1, 1), linestyle='dashed') - >> v.get_patch_by_id('100').set_alpha(1.0) - >> v.get_patch_by_id('100').set_color('white') - >> v.get_text_by_id('100').set_text('Unknown') + >>> from matplotlib.venn import * + >>> v = venn3(subsets=(1, 1, 1, 1, 1, 1, 1), set_labels = ('A', 'B', 'C')) + >>> c = venn3_circles(subsets=(1, 1, 1, 1, 1, 1, 1), linestyle='dashed') + >>> v.get_patch_by_id('100').set_alpha(1.0) + >>> v.get_patch_by_id('100').set_color('white') + >>> v.get_label_by_id('100').set_text('Unknown') + >>> v.get_label_by_id('C').set_text('Set C') ''' # Prepare parameters - if isinstance(sets, dict): - sets = [s[t] for t in ['000', '100', '010', '110', '001', '101', '011', '111']] + if isinstance(subsets, dict): + subsets = [subsets.get(t, 0) for t in ['100', '010', '110', '001', '101', '011', '111']] - # Solve Venn diagram - ax = gca() - circ = solve_venn3_circles(sets, normalize_to) - radii = circ[0:3] - centers = position_venn3_circles(*circ) + areas = compute_venn3_areas(subsets, normalize_to) + if (areas[0] < tol or areas[1] < tol or areas[2] < tol): + raise Exception("All three circles in the diagram must have positive areas. Use venn2 or just a circle to draw diagrams with two or one circle.") + centers, radii = solve_venn3_circles(areas) regions = compute_venn3_regions(centers, radii) - colors = compute_venn3_colors(set_colors)[1:] + colors = compute_venn3_colors(set_colors) + ax = gca() + prepare_venn3_axes(ax, centers, radii) # Create and add patches and text - prepare_venn3_axes(ax, centers, circ[0:3]) - patches = [make_venn3_region_patch(r) for r in regions[1:]] + patches = [make_venn3_region_patch(r) for r in regions] for (p, c) in zip(patches, colors): if p is not None: p.set_facecolor(c) p.set_edgecolor('none') p.set_alpha(alpha) ax.add_patch(p) - texts = [ax.text(r[2][0], r[2][1], str(s), va='center', ha='center') for (r, s) in zip(regions[1:], sets[1:])] - + subset_labels = [ax.text(r[2][0], r[2][1], str(s), va='center', ha='center') if r is not None else None for (r, s) in zip(regions, subsets)] + + # Position labels if set_labels is not None: - label_positions = [centers[0] + np.array([-radii[0]/2, radii[0]]), - centers[1] + np.array([radii[1]/2, radii[1]]), - centers[2] + np.array([0.0, -radii[2]*1.1])] - labels = [ax.text(pos[0], pos[1], txt, size='large') for (pos, txt) in zip(label_positions, set_labels)] - labels[0].set_horizontalalignment('right') - labels[1].set_horizontalalignment('left') - labels[2].set_verticalalignment('top') - labels[2].set_horizontalalignment('center') + # There are two situations, when set C is not on the same line with sets A and B, and when the three are on the same line. + if abs(centers[2][1] - centers[0][1]) > tol: + # Three circles NOT on the same line + label_positions = [centers[0] + np.array([-radii[0]/2, radii[0]]), + centers[1] + np.array([radii[1]/2, radii[1]]), + centers[2] + np.array([0.0, -radii[2]*1.1])] + labels = [ax.text(pos[0], pos[1], txt, size='large') for (pos, txt) in zip(label_positions, set_labels)] + labels[0].set_horizontalalignment('right') + labels[1].set_horizontalalignment('left') + labels[2].set_verticalalignment('top') + labels[2].set_horizontalalignment('center') + else: + padding = np.mean([r * 0.1 for r in radii]) + # Three circles on the same line + label_positions = [centers[0] + np.array([0.0, - radii[0] - padding]), + centers[1] + np.array([0.0, - radii[1] - padding]), + centers[2] + np.array([0.0, - radii[2] - padding])] + labels = [ax.text(pos[0], pos[1], txt, size='large', ha='center', va='top') for (pos, txt) in zip(label_positions, set_labels)] else: labels = None - return Venn3(patches, texts, labels) \ No newline at end of file + return Venn3(patches, subset_labels, labels) \ No newline at end of file diff --git a/matplotlib/venn/venn3_test.py b/matplotlib/venn/venn3_test.py index 69921a2..7a42de9 100644 --- a/matplotlib/venn/venn3_test.py +++ b/matplotlib/venn/venn3_test.py @@ -24,49 +24,47 @@ def test_circle_intersection(): def test_find_distances_by_area(): tests = [(0.0, 0.0, 0.0, 0.0), (1.2, 1.3, 0.0, 2.5), (1.0, 1.0, pi, 0.0), (sqrt(1.0/pi), sqrt(1.0/pi), 1.0, 0.0)] for (r, R, a, d) in tests: - assert abs(find_distance_by_area(r, R, a) - d) < tol + assert abs(find_distance_by_area(r, R, a, 0.0) - d) < tol tests = [(1, 2, 2), (1, 2, 1.1), (2, 3, 1.5), (2, 3, 1.0), (10, 20, 10), (20, 10, 10), (20, 10, 11), (0.9, 0.9, 0.0)] for (r, R, d) in tests: a = circle_intersection_area(r, R, d) - assert abs(find_distance_by_area(r, R, a) - d) < tol + assert abs(find_distance_by_area(r, R, a, 0.0) - d) < tol -def test_solve_venn3_circles(): +def test_compute_venn3_areas(): tests = [] - for i in range(8): - t = [0]*8 + for i in range(7): + t = [0]*7 t[i] = 1 tests.append(tuple(t)) - t = [1]*8 + t = [1]*7 t[i] = 0 tests.append(tuple(t)) - tests.append(tuple(range(8))) + tests.append(tuple(range(7))) for t in tests: - (R_a, R_b, R_c, d_ab, d_ac, d_bc) = solve_venn3_circles(t) + (A, B, C, AB, BC, AC, ABC) = compute_venn3_areas(t) t = np.array(t, float) - if np.sum(t[1:]) > 0: - t = t/np.sum(t[1:]) - (abc, Abc, aBc, ABc, abC, AbC, aBC, ABC) = t - assert abs(pi*R_a**2 - (Abc + ABc + AbC + ABC)) < tol - assert abs(pi*R_b**2 - (aBc + ABc + aBC + ABC)) < tol - assert abs(pi*R_c**2 - (abC + AbC + aBC + ABC)) < tol - assert abs(circle_intersection_area(R_a, R_b, d_ab) - (ABc + ABC)) < tol - assert abs(circle_intersection_area(R_a, R_c, d_ac) - (AbC + ABC)) < tol - assert abs(circle_intersection_area(R_c, R_b, d_bc) - (aBC + ABC)) < tol + t = t/np.sum(t) + (Abc, aBc, ABc, abC, AbC, aBC, ABC) = t + assert abs(A - (Abc + ABc + AbC + ABC)) < tol + assert abs(B - (aBc + ABc + aBC + ABC)) < tol + assert abs(C - (abC + AbC + aBC + ABC)) < tol + assert abs(AB - (ABc + ABC)) < tol + assert abs(AC - (AbC + ABC)) < tol + assert abs(BC - (aBC + ABC)) < tol -def test_position_venn3_circles(): +def test_solve_venn3_circles(): from numpy.linalg import norm - tests = [(1, 1, 1, 2, 2, 2), (1, 2, 3, 0, 2, 2), (1, 1, 1, 0, 0, 0), (1, 2, 1, 1, 0.5, 0.6)] + tests = [(2,2,2,1,1,1,0), (10, 20, 30, 0, 19, 9, 0), (1, 1, 1, 0, 0, 0, 0), (1.2, 2, 1, 1, 0.5, 0.6, 0)] for t in tests: - (R_a, R_b, R_c, d_ab, d_ac, d_bc) = t - coords = position_venn3_circles(*t) - print coords - assert abs(norm(coords[0] - coords[1]) - d_ab) < tol - assert abs(norm(coords[0] - coords[2]) - d_ac) < tol - assert abs(norm(coords[2] - coords[1]) - d_bc) < tol - assert abs(norm(R_a**2 * coords[0] + R_b**2 * coords[1] + R_c**2 * coords[2] - array([0.0, 0.0]))) < tol + (A,B,C,AB,BC,AC,ABC) = t + coords, radii = solve_venn3_circles(t) + assert abs(circle_intersection_area(radii[0], radii[1], norm(coords[0] - coords[1])) - AB) < tol + assert abs(circle_intersection_area(radii[0], radii[1], norm(coords[0] - coords[1])) - AB) < tol + assert abs(circle_intersection_area(radii[0], radii[1], norm(coords[0] - coords[1])) - AB) < tol + assert abs(norm(radii[0]**2 * coords[0] + radii[1]**2 * coords[1] + radii[2]**2 * coords[2] - array([0.0, 0.0]))) < tol def test_circle_circle_intersection(): @@ -115,12 +113,11 @@ def test_compute_venn3_regions(): coords = array([[-1, 0], [1, 0], [0, -1]], float) radii = [2, 2, 2] regions = compute_venn3_regions(coords, radii) - assert regions[0] is None - region_signatures = [(False, False, False), (True, False, False), (False, True, False), (True, True, False), + region_signatures = [(True, False, False), (False, True, False), (True, True, False), (False, False, True), (True, False, True), (False, True, True), (True, True, True)] - for i in range(1, len(regions)): + for i in range(len(regions)): pts, arcs, lbl = regions[i] assert len(pts) == 3 assert len(arcs) == 3 diff --git a/setup.cfg b/setup.cfg index afc6ff4..57387b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ tag_build = tag_svn_revision = false [pytest] -addopts = --ignore=setup.py --doctest-modules \ No newline at end of file +addopts = --ignore=setup.py --ignore=build --doctest-modules \ No newline at end of file diff --git a/setup.py b/setup.py index 3a749b8..c63b73b 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ def run_tests(self): import pytest #import here, cause outside the eggs aren't loaded pytest.main(self.test_args) -version = '0.1' +version = '0.2' setup(name='matplotlib-venn', version=version,