0

Take the following matplotlib graph/figure, where the x-axis is time.

import numpy as np
import matplotlib.pyplot as plt
time = np.linspace(1500, 2000)
plt.plot(time, np.exp(time*0.01))

plot: time series

Say I have a list of labels, such as

myLabels = {1500:'Awful times', 1800:'Somewhat better times', 1930:'Bad again', 1990:'We are alright'}

where the labels are supposed to point at intervals, not points; Awful times is the label for [1500, 1800].

I want to somehow add the information from these labels to the figure. My actual figure contains several plots / time series, so the solution needs to be "series independent". I have no idea what looks good, and how to do it. I have some ideas

  • Text under x-axis, additional ticks corresponding to start and end
  • Text under x-axis or in graph, dashed lines throughout the figure

What is usually done to add this kind of information? And how would I implement that using matplotlib?

FooBar
  • 15,724
  • 19
  • 82
  • 171
  • possible duplicate of [Matplotlib: Add strings as custom x-ticks but also keep existing (numeric) tick labels? Alternatives to matplotlib.pyplot.annotate?](http://stackoverflow.com/questions/10615960/matplotlib-add-strings-as-custom-x-ticks-but-also-keep-existing-numeric-tick) –  Aug 01 '15 at 17:31

2 Answers2

5

I might be tempted to use plt.annotate to draw labelled arrows:

import numpy as np
import matplotlib.pyplot as plt

time = np.linspace(1500, 2000)
yvals = np.exp(time * 0.01)
myLabels = {1500:'Awful times', 1800:'Somewhat better times',
            1930:'Bad again', 1990:'We are alright'}

fig, ax = plt.subplots(1,  1)
ax.plot(time, yvals)

for x, label in myLabels.iteritems():

    ax.annotate(label, xy=(x, np.exp(x * 0.01)), xytext=(-40, 40),
                xycoords='data', textcoords='offset points',
                ha='center', va='bottom', fontsize='large',
                arrowprops=dict(arrowstyle='->', lw=2))

ax.set_xlim(1300, 2100)
ax.set_ylim(0, yvals.max() * 1.2)

enter image description here


From the comments it seems you want to represent ranges of values on the time axis rather than single timepoints, and you want to plot multiple series on the same set of axes (so you don't want any aspect of the annotation to vary with the y-values of the timeseries).

There are really lots of ways you could do this, and I'm still not quite sure what you're looking for. One fairly straightforward option would be to plot colored shaded regions using plt.axvspan (similar to chepyle's answer, except without varying the height) and use a legend to display the labels:

edges, labels = zip(*sorted(myLabels.iteritems()))
edges = edges + (2000,)
colors = ['r', 'b', 'g', 'c']

for ii, ll in enumerate(labels):
    ax.axvspan(edges[ii], edges[ii + 1], facecolor=colors[ii],
               label=labels[ii], alpha=0.3)

ax.legend(loc='upper left')

enter image description here

Using a legend has the advantage that you don't need to worry about cramming in a text label for the last range, which is quite narrow.

You could also use vertical lines and squeeze the labels in above (optionally with double-ended arrows to represent the ranges):

from matplotlib.transforms import blended_transform_factory

# x-position specified in data coordinates, y-position specified in [0, 1]
# relative axis coordinates
tform = blended_transform_factory(ax.transData, ax.transAxes)

edges, labels = zip(*sorted(myLabels.iteritems()))
edges = np.r_[edges, 2000]
centers = (edges[:-1] + edges[1:]) / 2.

# mark edges with dashed lines
for ee in edges:
    ax.axvline(ee, ymax=0.75, ls='--', c='k')

# plot labels
for cc, ll in zip(centers, labels):
    ax.annotate(ll, xy=(cc, 0.75), xytext=(0, 10),
                xycoords=tform, textcoords='offset points',
                ha='left', va='bottom', rotation=60)

# plot double-ended arrows
for start, stop in zip(edges[:-1], edges[1:]):
    ax.annotate('', xy=(start, 0.75), xytext=(stop, 0.75),
                xycoords=tform, textcoords=tform,
                arrowprops=dict(arrowstyle='<->', lw=2, shrinkA=0, shrinkB=0))

# turn off spines and ticks on the top and right, so that they don't overlap
# with the labels
for sp in ('top', 'right'):
    ax.spines[sp].set_visible(False)
ax.tick_params(top=False, right=False)

# rescale the y-axis so that the labels and arrows are positioned nicely relative
# to the line
ax.set_ylim(0, yvals.max() * 1.4)

enter image description here

This method requires a lot more tweaking in order to fit the labels in without overlapping one another or the axis spines.

ali_m
  • 71,714
  • 23
  • 223
  • 298
  • I should've clarified this, but I didn't know this solution exists. I am plotting several time series on that figure, therefore I would like to have a solution that is series-independent. – FooBar Aug 01 '15 at 18:11
  • Do you want to annotate particular points in time, or ranges of times? – ali_m Aug 01 '15 at 19:34
  • Do either of the solutions in my updated answer address what you're looking for? – ali_m Aug 04 '15 at 18:34
1

To highlight the ranges, you may use the axhspan or axvspan to draw boxes around the ranges, along with annotate for the text:

import numpy as np
import matplotlib.pyplot as plt
time = np.linspace(1500, 2000)
y=lambda t: np.exp(t*0.01)
plt.plot(time, y(time))
#use a list of tuples instead of a dictionary - it must be sorted!
myLabels = [(1500,'Awful times'), (1800,'Somewhat better times'), (1930,'Bad again'), (1990,'We are alright')]
colorLabels=['black', 'blue', 'red', 'green'] # set list of colors
for ix ,((xloc,txt),colr) in enumerate(zip(myLabels,colorLabels)):
    # look ahead
    if ix+1==len(myLabels):
        xloc2=np.max(time)
    else:
        xloc2=myLabels[ix+1][0]

    # draw a polygon between the lower and upper xrange, using our y function:
    plt.axhspan(np.min(y(time)), y(xloc2),
                xmin=(xloc-np.min(time))/(np.max(time)-np.min(time)),
                xmax=(xloc2-np.min(time))/(np.max(time)-np.min(time)),
                facecolor=colr, alpha=0.5)
    # add a text arrow pointing to the center of the region of interest
    plt.annotate(txt,xy=(np.mean([xloc,xloc2]),np.mean([y(xloc),y(xloc2)])),
                 xytext=(xloc*0.75+0.25*np.min(time),
                         y(xloc)*0.75+0.25*np.max(y(time))),
                 xycoords='data',
                 textcoords='data',
                 arrowprops=dict(arrowstyle="->"))

plt.show()

I used the function to set the tops of the rectangles, but if there are several y's then you could precalculate them all and take the max or just use axvspan with the default y's to use the full y range.

enter image description here

chepyle
  • 976
  • 1
  • 9
  • 16