Skip to content

Commit 9bd9763

Browse files
committed
Fixed vertical position of legend in plot
Fixed issues: - The AnchoredOffsetbox was originally set to at add loc=6 which vertically centers the legend in the plot, creating legends that were too high or too low compared to the upper plot edge. - legends with many items would often overlap due to the spacing between separate legends being constant
1 parent 29ec5ab commit 9bd9763

File tree

1 file changed

+136
-113
lines changed

1 file changed

+136
-113
lines changed

ggplot/components/legend.py

Lines changed: 136 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22
unicode_literals)
33

44
from matplotlib.patches import Rectangle
5-
import matplotlib.pyplot as plt
65
from matplotlib.offsetbox import AnchoredOffsetbox, TextArea, DrawingArea, HPacker, VPacker
6+
from collections import defaultdict
77
import matplotlib.lines as mlines
8-
import operator
98
import numpy as np
109

11-
import six
1210

1311
"""
1412
A legend is a dict of type
@@ -30,75 +28,6 @@
3028
- value for discrete mappings.
3129
"""
3230

33-
def make_title(title):
34-
title = title.title()
35-
return TextArea(" %s " % title, textprops=dict(color="k", fontweight="bold"))
36-
37-
def make_shape_key(label, shape):
38-
idx = len(label)
39-
pad = 20 - idx
40-
lab = label[:max(idx, 20)]
41-
pad = " "*pad
42-
label = TextArea(" %s" % lab, textprops=dict(color="k"))
43-
viz = DrawingArea(15, 20, 0, 0)
44-
fontsize = 10
45-
key = mlines.Line2D([0.5*fontsize], [0.75*fontsize], marker=shape,
46-
markersize=(0.5*fontsize), c="k")
47-
viz.add_artist(key)
48-
return HPacker(children=[viz, label], align="center", pad=5, sep=0)
49-
50-
def make_size_key(label, size):
51-
if not isinstance(label, six.string_types):
52-
label = round(label, 2)
53-
label = str(label)
54-
idx = len(label)
55-
pad = 20 - idx
56-
lab = label[:max(idx, 20)]
57-
pad = " "*pad
58-
label = TextArea(" %s" % lab, textprops=dict(color="k"))
59-
viz = DrawingArea(15, 20, 0, 0)
60-
fontsize = 10
61-
key = mlines.Line2D([0.5*fontsize], [0.75*fontsize], marker="o",
62-
markersize=size / 20., c="k")
63-
viz.add_artist(key)
64-
return HPacker(children=[viz, label], align="center", pad=5, sep=0)
65-
66-
# TODO: Modify to correctly handle both, color and fill
67-
# to include an alpha
68-
def make_line_key(label, color):
69-
label = str(label)
70-
idx = len(label)
71-
pad = 20 - idx
72-
lab = label[:max(idx, 20)]
73-
pad = " "*pad
74-
label = TextArea(" %s" % lab, textprops=dict(color="k"))
75-
viz = DrawingArea(20, 20, 0, 0)
76-
viz.add_artist(Rectangle((0, 5), width=16, height=5, fc=color))
77-
return HPacker(children=[viz, label], height=25, align="center", pad=5, sep=0)
78-
79-
def make_linetype_key(label, linetype):
80-
idx = len(label)
81-
pad = 20 - idx
82-
lab = label[:max(idx, 20)]
83-
pad = " "*pad
84-
label = TextArea(" %s" % lab, textprops=dict(color="k"))
85-
viz = DrawingArea(30, 20, 0, 0)
86-
fontsize = 10
87-
x = np.arange(0.5, 2.25, 0.25) * fontsize
88-
y = np.repeat(0.75, 7) * fontsize
89-
90-
key = mlines.Line2D(x, y, linestyle=linetype, c="k")
91-
viz.add_artist(key)
92-
return HPacker(children=[viz, label], align="center", pad=5, sep=0)
93-
94-
legend_viz = {
95-
"color": make_line_key,
96-
"fill": make_line_key,
97-
"linetype": make_linetype_key,
98-
"shape": make_shape_key,
99-
"size": make_size_key,
100-
}
101-
10231
def add_legend(legend, ax):
10332
"""
10433
Add a legend to the axes
@@ -109,50 +38,144 @@ def add_legend(legend, ax):
10938
Specification in components.legend.py
11039
ax: axes
11140
"""
112-
# TODO: Implement alpha
113-
# It should be coupled with fill, if fill is not
114-
# part of the aesthetics, then with color
115-
remove_alpha = 'alpha' in legend
116-
if remove_alpha:
117-
_alpha_entry = legend.pop('alpha')
118-
41+
# Group legends by column name and invert color/label mapping
42+
groups = {}
43+
for aesthetic in legend:
44+
legend_entry = legend[aesthetic]
45+
column_name = legend_entry["column_name"]
46+
g = groups.get(column_name, {})
47+
legend_dict = { l:c for c,l in legend_entry['dict'].items() }
48+
g[aesthetic] = defaultdict(lambda : None, legend_dict)
49+
groups[column_name] = g
50+
51+
nb_rows = 0
52+
nb_cols = 0
53+
max_rows = 18
11954
# py3 and py2 have different sorting order in dics,
12055
# so make that consistent
121-
for i, aesthetic in enumerate(sorted(legend.keys())):
122-
legend_entry = legend[aesthetic]
123-
new_legend = draw_entry(ax, legend_entry, aesthetic, i)
124-
ax.add_artist(new_legend)
125-
126-
if remove_alpha:
127-
legend['alpha'] = _alpha_entry
128-
129-
def draw_entry(ax, legend_entry, aesthetic, ith_entry):
130-
children = []
131-
children.append(make_title(legend_entry['column_name']))
132-
viz_handler = legend_viz[aesthetic]
133-
legend_items = sorted(legend_entry['dict'].items(), key=operator.itemgetter(1))
134-
children += [viz_handler(str(lab), col) for col, lab in legend_items]
135-
box = VPacker(children=children, align="left", pad=0, sep=5)
136-
137-
# TODO: The vertical spacing between the legends isn't consistent. Should be
138-
# padded consistently
139-
anchored_box = AnchoredOffsetbox(loc=6,
140-
child=box, pad=0.,
141-
frameon=False,
142-
#bbox_to_anchor=(0., 1.02),
143-
# Spacing goes here
144-
bbox_to_anchor=(1, 0.8 - 0.35 * ith_entry),
145-
bbox_transform=ax.transAxes,
146-
borderpad=1.,
147-
)
56+
for i, column_name in enumerate(sorted(groups.keys())):
57+
legend_group = groups[column_name]
58+
legend_box, rows = draw_legend_group(legend_group, column_name, i)
59+
cur_nb_rows = nb_rows
60+
nb_rows += rows + 1
61+
if nb_rows > max(max_rows, rows + 1) :
62+
nb_cols += 1
63+
nb_rows = 0
64+
cur_nb_rows = 0
65+
anchor_legend(ax, legend_box, cur_nb_rows, nb_cols)
66+
67+
68+
def draw_legend_group(legends, column_name, ith_group):
69+
labels = get_labels(legends)
70+
colors, has_color = get_colors(legends)
71+
legend_title = make_title(column_name)
72+
legend_labels = [make_label(l) for l in labels]
73+
none_dict = defaultdict(lambda : None)
74+
legend_cols = []
75+
76+
# TODO: Implement alpha
77+
if "shape" in legends or "size" in legends :
78+
shapes = legends["shape"] if "shape" in legends else none_dict
79+
#sizes = map(int,labels) if "size" in legends else none_dict
80+
sizes = legends["size"] if "size" in legends else none_dict
81+
line = lambda l : make_shape(colors[l], shapes[l], sizes[l])
82+
legend_shapes = [line(label) for label in labels]
83+
legend_cols.append(legend_shapes)
84+
if "linetype" in legends :
85+
linetypes = legends["linetype"]
86+
legend_lines = [make_line(colors[l], linetypes[l]) for l in labels]
87+
legend_cols.append(legend_lines)
88+
# If we don't have lines, indicate color with a rectangle
89+
if "linetype" not in legends and has_color :
90+
legend_rects = [make_rect(colors[l]) for l in labels]
91+
legend_cols.append(legend_rects)
92+
# Concatenate columns and compile rows
93+
legend_cols.append(legend_labels)
94+
row = lambda l : HPacker(children=l, height=25, align='center', pad=5, sep=0)
95+
legend_rows = [row(legend_items) for legend_items in zip(*legend_cols)]
96+
# Vertically align items and anchor in plot
97+
rows = [legend_title] + legend_rows
98+
box = VPacker(children=rows, align="left", pad=0, sep=-10)
99+
return box, len(rows)
100+
101+
102+
103+
def anchor_legend(ax, box, row, col):
104+
anchored = AnchoredOffsetbox(loc=2,
105+
child=box,
106+
pad=0.,
107+
frameon=False,
108+
#bbox_to_anchor=(0., 1.02),
109+
# Spacing goes here
110+
#bbox_to_anchor=(1, 0.8 - 0.35 * ith_group),
111+
#bbox_to_anchor=(1.01 + 0.1*col, 0.86 - 0.06*row),
112+
bbox_to_anchor=(1 + 0.25*col, 1 - 0.054*row),
113+
bbox_transform=ax.transAxes,
114+
#borderpad=1.,
115+
)
148116
# Workaround for a bug in matplotlib up to 1.3.1
149117
# https://github.com/matplotlib/matplotlib/issues/2530
150-
anchored_box.set_clip_on(False)
151-
return anchored_box
118+
anchored.set_clip_on(False)
119+
ax.add_artist(anchored)
120+
121+
122+
123+
def make_title(title):
124+
title = title.title()
125+
area = TextArea(" %s " % title, textprops=dict(color="k", fontweight="bold"))
126+
viz = DrawingArea(20, 10, 0, 0)
127+
packed = VPacker(children=[area, viz], align="center", pad=0, sep=0)
128+
return packed
129+
130+
131+
132+
def make_shape(color, shape, size, y_offset = 10, height = 20):
133+
color = color if color != None else "#222222" # Default value if None
134+
shape = shape if shape != None else "o"
135+
size = size if size != None else 75
136+
viz = DrawingArea(30, height, 8, 1)
137+
key = mlines.Line2D([0], [y_offset], marker=shape,
138+
markersize=size/12.0, c=color)
139+
viz.add_artist(key)
140+
return viz
141+
142+
143+
144+
def make_line(color, style, width = 20, y_offset = 10, height = 20, linewidth = 3):
145+
color = color if color != None else "k" # Default value if None
146+
style = style if style != None else "-"
147+
viz = DrawingArea(30, 10, 0, -5)
148+
x = np.arange(0.0, width, width/7.0)
149+
y = np.repeat(y_offset, x.size)
150+
key = mlines.Line2D(x, y, linestyle=style, linewidth=linewidth, c=color)
151+
viz.add_artist(key)
152+
return viz
153+
154+
155+
156+
def make_rect(color, size = (20,6), height = 20):
157+
color = color if color != None else "k" # Default value if None
158+
viz = DrawingArea(30, height, 0, 1)
159+
viz.add_artist(Rectangle((0, 6), width=size[0], height=size[1], fc=color))
160+
return viz
161+
162+
163+
164+
def make_label(label, max_length = 20, capitalize = True):
165+
label_text = str(label).title()[:max_length]
166+
label_area = TextArea(label_text, textprops=dict(color="k"))
167+
return label_area
168+
169+
170+
def get_labels(legends) :
171+
# All the legends are for the same column, so the labels of any will do
172+
return sorted(legends.values()[0].keys())
152173

153-
if __name__=="__main__":
154-
fig = plt.figure()
155-
ax = fig.add_axes([0.1, 0.1, 0.4, 0.7])
156174

157-
ax.add_artist(draw_legend(ax,{1: "blah", 2: "blah2", 15: "blah4"}, "size", 1))
158-
plt.show(block=True)
175+
def get_colors(legends) :
176+
if "color" in legends :
177+
return legends["color"], True
178+
elif "fill" in legends :
179+
return legends["fill"], True
180+
else :
181+
return defaultdict(lambda : None), False

0 commit comments

Comments
 (0)