I don't know if this is something you would be interested in "fixing", but here is a challenging scenario that adjustText doesn't handle quite as well as it could.
As you can see, there is a lot of empty space on the plot but a lot of overlapping text, so there is room for improvement. I've experimented a lot with different settings for adjust_text()
, but this is the best I've been able to get it to do.
Also, as you can see at the top, sometimes it puts text objects outside the plot borders even when it's not necessary. If I re-run the plot, sometimes it's outside the border, sometimes it's not--but when it is, it causes the vertical size of the plot image to increase.
Here is the script that generates the plot. Forgive the unrelated code; I've removed a lot that isn't used, but the returns are diminishing... :)
WINDOW_WIDTH=960
ALPHA=0.15
WINDOW="7d"
DAYS=42
workout_data=[
[
"!",
"Date",
"Movement",
"Reps",
"Type",
"Comments"
],
[
"",
"[2016-11-03 Thu]",
"teetotaler's interview's sympathetically",
25,
"Intervals",
""
],
[
"",
"[2016-11-03 Thu]",
"transmitter famish bathmats",
31,
"Intervals",
""
],
[
"",
"[2016-11-03 Thu]",
"Weller misstatements upstage",
13,
"Intervals",
""
],
[
"",
"[2016-11-03 Thu]",
"predetermine Mars dissoluteness",
36,
"Intervals",
""
],
[
"",
"[2016-11-01 Tue]",
"pervasive sups shortcoming",
34,
"Intervals",
""
],
[
"",
"[2016-11-01 Tue]",
"evaporation dachshund's jives",
36,
"Intervals",
""
],
[
"",
"[2016-11-01 Tue]",
"passports Beadle benevolent",
36,
"Intervals",
""
],
[
"",
"[2016-11-01 Tue]",
"preserves foregoes virtuousness",
32,
"Intervals",
""
],
[
"",
"[2016-10-28 Fri]",
"peripatetic craziest hawking",
36,
"Intervals",
""
],
[
"",
"[2016-10-28 Fri]",
"bowsprit's reopens steeliest",
14,
"Intervals",
""
],
[
"",
"[2016-10-28 Fri]",
"sweetbriars effluent complication's",
20,
"Intervals",
""
],
[
"",
"[2016-10-28 Fri]",
"tonnage's solidness's drifting",
36,
"Intervals",
"YAYOG W3D1"
],
[
"",
"[2016-10-27 Thu]",
"pasty's coincidence roughens",
93,
"Ladders",
""
],
[
"",
"[2016-10-27 Thu]",
"evaporation dachshund's jives",
55,
"Ladders",
""
],
[
"",
"[2016-10-27 Thu]",
"pervasive sups shortcoming",
36,
"Ladders",
""
],
[
"",
"[2016-10-27 Thu]",
"passports Beadle benevolent",
40,
"Ladders",
"YAYOG W2D4"
],
[
"",
"[2016-10-26 Wed]",
"Weller misstatements upstage",
40,
"Ladders",
""
],
[
"",
"[2016-10-26 Wed]",
"peripatetic craziest hawking",
48,
"Ladders",
""
],
[
"",
"[2016-10-26 Wed]",
"predetermine Mars dissoluteness",
43,
"Ladders",
""
],
[
"",
"[2016-10-26 Wed]",
"fruitful Bahama's rewind's",
21,
"Ladders",
"YAYOG W2D3"
],
[
"",
"[2016-10-24 Mon]",
"pariahs flaring outstript",
51,
"Ladders",
""
],
[
"",
"[2016-10-24 Mon]",
"rustproofing radishes perpetration",
58,
"Ladders",
""
],
[
"",
"[2016-10-24 Mon]",
"pervasive sups shortcoming",
33,
"Ladders",
""
],
[
"",
"[2016-10-24 Mon]",
"faun's nonmember's accoutrements",
28,
"Ladders",
"YAYOG W2D2"
],
[
"",
"[2016-10-21 Fri]",
"Weller misstatements upstage",
33,
"Ladders",
""
],
[
"",
"[2016-10-21 Fri]",
"peripatetic craziest hawking",
43,
"Ladders",
""
],
[
"",
"[2016-10-21 Fri]",
"predetermine Mars dissoluteness",
43,
"Ladders",
""
],
[
"",
"[2016-10-21 Fri]",
"fruitful Bahama's rewind's",
18,
"Ladders",
"YAYOG W2D1"
],
[
"",
"[2016-10-19 Wed]",
"pasty's coincidence roughens",
85,
"Ladders",
"YAYOG W1D4"
],
[
"",
"[2016-10-19 Wed]",
"evaporation dachshund's jives",
46,
"Ladders",
""
],
[
"",
"[2016-10-19 Wed]",
"pervasive sups shortcoming",
30,
"Ladders",
""
],
[
"",
"[2016-10-19 Wed]",
"passports Beadle benevolent",
36,
"Ladders",
""
],
[
"",
"[2016-10-10 Mon]",
"fruitful Bahama's rewind's",
15,
"Ladders",
"I did not realize how weak my triceps are. I did \"let me downs\" 2x the number of push-up reps."
],
[
"",
"[2016-10-10 Mon]",
"predetermine Mars dissoluteness",
33,
"Ladders",
""
],
[
"",
"[2016-10-10 Mon]",
"peripatetic craziest hawking",
40,
"Ladders",
""
],
[
"",
"[2016-10-10 Mon]",
"Weller misstatements upstage",
25,
"Ladders",
""
],
[
"",
"[2016-10-04 Tue]",
"batteries prerequisite's elderberry's",
25,
"Ladders",
""
],
[
"",
"[2016-10-04 Tue]",
"pervasive sups shortcoming",
28,
"Ladders",
""
],
[
"",
"[2016-10-04 Tue]",
"bilaterally belay slighter",
40,
"Ladders",
""
],
[
"",
"[2016-10-04 Tue]",
"pariahs flaring outstript",
46,
"Ladders",
""
],
[
"",
"[2016-09-28 Wed]",
"fruitful Bahama's rewind's",
13,
"Ladders",
""
],
[
"",
"[2016-09-28 Wed]",
"predetermine Mars dissoluteness",
31,
"Ladders",
""
],
[
"",
"[2016-09-28 Wed]",
"peripatetic craziest hawking",
36,
"Ladders",
""
],
[
"",
"[2016-09-28 Wed]",
"Weller misstatements upstage",
25,
"Ladders",
""
]
]
# * Imports
import itertools, os, re, sys
from datetime import datetime, timedelta
import matplotlib.pyplot as plot
import matplotlib.lines as mlines
from matplotlib.dates import DateFormatter, MonthLocator, WeekdayLocator, num2date, SA, SU, date2num
from matplotlib.font_manager import FontProperties
from matplotlib.colors import ColorConverter
import numpy, pandas, random
# * Constants
PANDAS_DATE_FORMAT = "%Y-%m-%d %a %H:%M"
FOOD_DATE_FORMAT = "[%Y-%m-%d %a]"
ORG_DATE_FORMAT = "[%Y-%m-%d %a]"
DPI = 100
WIDTH = round(float(WINDOW_WIDTH) / 100, 1)
HEIGHT = 5.55
OLDEST_DATE = datetime.strptime(data[1][1], ORG_DATE_FORMAT) \
if DAYS == "all" \
else datetime.now() - timedelta(days=DAYS)
HALF_WINDOW = "%s%s" % (int(float(re.search("[0-9]+", WINDOW).group(0)) / 2),
re.search("[a-z]+", WINDOW).group(0))
# Series type marker types
SERIES_MARKERS = {"Ladders": "D",
"Intervals": "+"}
# Solarized colors
yellow = '#b58900'
green = '#b58900'
orange = '#cb4b16'
red = '#dc322f'
magenta = '#d33682'
violet = '#6c71c4'
blue = '#268bd2'
cyan = '#2aa198'
green = '#859900'
base03 = '#002b36'
base02 = '#073642'
base01 = '#586e75'
base00 = '#657b83'
base0 = '#839496'
base1 = '#93a1a1'
base2 = '#eee8d5'
base3 = '#fdf6e3'
border_color = "#0e2329"
dark_bg = "#0e2329"
class SolarizedColors (object):
yellow = '#b58900'
orange = '#cb4b16'
red = '#dc322f'
magenta = '#d33682'
violet = '#6c71c4'
blue = '#268bd2'
cyan = '#2aa198'
green = '#859900'
base0 = '#839496'
base00 = '#657b83'
base1 = '#93a1a1'
base01 = '#586e75'
base2 = '#eee8d5'
base02 = '#073642'
base3 = '#fdf6e3'
base03 = '#002b36'
yellow_hc = '#DEB542'
yellow_lc = '#7B6000'
orange_hc = '#F2804F'
orange_lc = '#8B2C02'
red_hc = '#FF6E64'
red_lc = '#990A1B'
magenta_hc = '#F771AC'
magenta_lc = '#93115C'
violet_hc = '#9EA0E5'
violet_lc = '#3F4D91'
blue_hc = '#69B7F0'
blue_lc = '#00629D'
cyan_hc = '#69CABF'
cyan_lc = '#00736F'
green_hc = '#B4C342'
green_lc = '#546E00'
c = SolarizedColors
solarized_colors = [c.yellow, c.orange, c.red, c.magenta, c.blue, c.violet, c.cyan, c.green,
c.yellow_hc, c.orange_hc, c.red_hc, c.magenta_hc, c.blue_hc, c.violet_hc, c.cyan_hc, c.green_hc,
c.yellow_lc, c.orange_lc, c.red_lc, c.magenta_lc, c.blue_lc, c.violet_lc, c.cyan_lc, c.green_lc]
# Expand color list
# from grapefruit import Color
# solarized_colors.extend([str(Color.NewFromHtml(c).Desaturate(0.25).html)
# for c in solarized_colors])
def printerr(*args, **kwargs):
sys.stderr.write("\nSTDERR:\n" + ' '.join([str(a) for a in args]) + "\n")
if 'exit' in kwargs and kwargs['exit']:
sys.exit(1)
# * main
# ** Process data
# Load into frames
frames = {'workouts': pandas.DataFrame.from_records(workout_data[1:], columns=workout_data[0])}
# Convert date fields to datetime objects
for frame in ['workouts']:
frames[frame]['Date'] = frames[frame]['Date'].apply(pandas.to_datetime, format=FOOD_DATE_FORMAT)
# Drop old data
# for frame in frames:
# frames[frame] = frames[frame][frames[frame]['Date'] > OLDEST_DATE]
# Set date as index
for frame in frames:
frames[frame] = frames[frame].set_index(['Date'])
# Resample food frame, summing values for each day
#frames['food'] = frames['food'].resample('1d').sum()
# Drop old data
# Not strictly necessary to also drop workouts data, because we reset
# the ticks and axis limits after plotting the combined_frame. But
# might as well do it for consistency and speed.
frames['workouts'] = frames['workouts'][frames['workouts'].index > OLDEST_DATE]
# ** Plot data
# Set params
#plot.rcParams['font.family'] = 'DejaVu Sans' # It already gets DejaVu Sans as my default, but in case I ever want to change it...
# Setup figure (before plotting)
fig, axes = plot.subplots(nrows=2, ncols=1, facecolor=c.base03, dpi=DPI, figsize=(WIDTH, HEIGHT), sharex=True)
workouts_plot = axes[0]
weight_plot = axes[1]
calories_plot = weight_plot.twiny()
calories_plot.get_shared_x_axes().join(calories_plot, workouts_plot)
# *** Plot workouts
# Plot this first, because otherwise the shared x axis is restricted
# to the range of the workouts data, which is smaller at the moment.
# I wish order didn't matter so much...
# Version without merging...which fixed it! No more jagged edges on the food/calorie/weight data!
workouts_frame = frames['workouts']
series_types = workouts_frame.Type.unique().tolist()
movement_types = workouts_frame.Movement.unique().tolist()
workouts_plot.set_color_cycle(solarized_colors)
for st in series_types:
for m in movement_types:
f = workouts_frame.query("Type == \"%s\" and Movement == \"%s\"" % (st, m))
if not f.empty:
f.Reps.plot(sharex=workouts_plot, ax=workouts_plot, label=m, marker=SERIES_MARKERS[st])
# **** Label workout lines
lines = workouts_plot.get_lines()
num_lines = len(lines)
xmin, xmax = plot.xlim()
x_values = numpy.linspace(xmin, xmax, num_lines * 200).tolist()
skip = int(len(x_values) * 0.2)
x_values = x_values[skip:-skip]
random.shuffle(x_values)
ymin, ymax = workouts_plot.get_ylim()
valid_y_min = ymin + ymin * 0.1
valid_y_max = ymax - ymax * 0.1
annotations = []
for line in lines:
label = line.get_label()
label_length = len(label)
x_offset = 0
# Convert dates to x-axis positions
x_data = [date2num(d) for d in line.get_xdata()]
# Choose random spot within line x values
#x = random.choice(linspace(min(x_data), max(x_data), 100).tolist()[10:-10])
# Choose random x point
x = random.choice(x_data)
y_data = line.get_ydata()
# From <http://stackoverflow.com/a/39402483/712624>
# Find corresponding y co-ordinate and angle of the...?
ip = 0
# Not sure if this is needed but keeping it for now
# for i in range(len(x_data)):
# if x < x_data[i]:
# ip = i
# break
if len(x_data) == 1:
x = x_data[0]
y = y_data[0]
x_offset = random.randint(-20, 20)
else:
y = y_data[ip-1] + (y_data[ip] - y_data[ip-1]) * (x - x_data[ip-1]) / (x_data[ip] - x_data[ip-1])
# Compute text y-offset
y_offset = random.randint(-40, 40)
if y > valid_y_max:
y_offset = random.randint(-20, 0)
elif y < valid_y_min:
y_offset = random.randint(0, 20)
x_offset = 0
y_offset = 0
# annotations.append(plot.annotate(label, xy=(x, y), xytext=(x_offset, y_offset), size=8, alpha=0.75,
# textcoords='offset points', ha='right', va='bottom', color=line.get_color(),
# bbox=dict(boxstyle='round,pad=0.5', fc='#002b36', alpha=0.25),
# arrowprops=dict(arrowstyle='->', color=line.get_color(), alpha=0.5, fill=False)))
annotations.append(workouts_plot.text(x, y, line.get_label(), size=8, alpha=0.75,
color=line.get_color(),
bbox=dict(boxstyle='round,pad=0.25', ec='#002b36', fc='#002b36', alpha=0.5)))
x_points = [date2num(p)
for p in line.get_xdata()
for line in workouts_plot.get_lines()]
y_points = [p
for p in line.get_ydata()
for line in workouts_plot.get_lines()]
# ** Set plot appearance
# Background color of secondary plot (primary plot is transparent on top)
weight_plot.set_axis_bgcolor(c.base02)
workouts_plot.set_axis_bgcolor(c.base02)
calories_plot.set_axis_bgcolor(c.base02)
# Set spine colors
for spine in workouts_plot.spines.values() + weight_plot.spines.values() + calories_plot.spines.values():
spine.set_edgecolor(border_color)
# Put primary plot (weight) above secondary plot
weight_plot.set_zorder(calories_plot.get_zorder() + 1)
weight_plot.patch.set_visible(False)
calories_plot.patch.set_visible(True)
# *** Ticks and grid
workouts_plot.xaxis.set_minor_locator(WeekdayLocator(byweekday=[SA,SU]))
# Set tick appearance
workouts_plot.yaxis.set_tick_params(labelcolor=c.base01, color=c.base03, labelright='on')
workouts_plot.tick_params(which='both', color=c.base03)
# Make major x-axis ticks manually (instead of using the
# major_locator, because it only makes the ticks when the plot is
# shown, and we need the ticks so we can change the labels)
major_ticks = MonthLocator().tick_values(workouts_frame.index[0], workouts_frame.index[-1])
workouts_plot.xaxis.set_ticks(major_ticks)
#weight_plot.xaxis.set_ticklabels([])
calories_plot.set_xlabel('')
# Set grid appearance
calories_plot.grid(which='major', axis='x', linestyle='-', linewidth=1, color=c.base03)
calories_plot.grid(which='minor', linestyle=':', linewidth=1, color=c.base03)
calories_plot.grid(which='major', axis='y', linestyle='-', color=c.base03)
calories_plot.grid(which='minor', axis='y', linestyle=':', color=c.base03)
workouts_plot.grid(which='major', axis='x', linestyle='-', linewidth=1, color=c.base03)
workouts_plot.grid(which='minor', linestyle=':', linewidth=1, color=c.base03)
workouts_plot.grid(which='major', axis='y', linestyle='-', color=c.base03)
workouts_plot.grid(which='minor', axis='y', linestyle=':', color=c.base03)
weight_plot.grid(which='major', axis='x', linestyle='-', linewidth=1, color=c.base03)
weight_plot.grid(which='minor', linestyle=':', linewidth=1, color=c.base03)
weight_plot.grid(which='major', axis='y', linestyle='-', color=c.base03)
weight_plot.grid(which='minor', axis='y', linestyle=':', color=c.base03)
# Draw grid below data (this sort of works...not sure)
weight_plot.set_axisbelow(True)
calories_plot.set_axisbelow(True)
# *** Legend
# Get lines for legend (from each subplot)
legend_lines = calories_plot.get_lines()
# Filter unwanted lines
legend_lines = [l for l in legend_lines
if not l.get_label().startswith('_')]
# Setup legend
legend = weight_plot.legend([l for l in legend_lines],
[l.get_label() for l in legend_lines],
loc='upper left', fontsize='small', frameon=False, labelspacing=0.25)
legend.get_frame().set_facecolor(c.base02)
for text in legend.get_texts():
text.set_color(c.base01)
# Legend for workout types
# Good info: http://matplotlib.org/users/legend_guide.html#plotting-guide-legend
# Make line objects for legend
color_generator = itertools.cycle(solarized_colors)
workout_legend_lines = [mlines.Line2D([], [], marker=m, label=n, color=c.base0 # color_generator.next()
)
for n, m in SERIES_MARKERS.iteritems()]
workouts_legend = workouts_plot.legend(handles=workout_legend_lines, numpoints=1, markerscale=0.75,
loc='upper left', fontsize='small', frameon=False, labelspacing=0.25)
workouts_legend.get_frame().set_facecolor(c.base02)
for text in workouts_legend.get_texts():
text.set_color(c.base01)
# *** Set tight layout
# Do this after changing everything else
fig.tight_layout(h_pad=0)
for ax in [workouts_plot]:
ax.margins(0)
import adjustText
adjustText.adjust_text(annotations, ax=workouts_plot, arrowprops=dict(arrowstyle='->', alpha=0.5, fill=False), force_points=2, force_text=2,
#only_move={'text': 'y', 'points': 'y'},
# precision=-1, expand_text=(50,100), expand_points=(10,40)
expand_points=(2,2)
)
# ** Save plot
# Write file and filename
#filename = os.path.expanduser(filename)
#fig.savefig(filename, facecolor=c.base03, pad_inches=0, bbox_inches='tight', dpi=DPI)
# Display in window
plot.show()