Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Matplotlib: Cursor snap to plotted data with datetime axis

I have a plot of 3 data sets that have datetime objetcs on the x axis. I want to have a cursor that snaps to the data and shows the precise x and y value.

I already have a "snap to cursor", but that only works for scalar x axes. Can anyone help me to modify the snap to cursor so that it works for datetime x axes as well?

Here are my data plots: enter image description here

import numpy as np
import matplotlib.pyplot as plot
import matplotlib.ticker as mticker
import matplotlib.dates as dates
import datetime
import Helpers

fig = plot.figure(1)
DAU = (  2,  20,  25,  60, 190, 210,  18, 196, 212)
WAU = ( 50, 160, 412, 403, 308, 379, 345, 299, 258)
MAU = (760, 620, 487, 751, 612, 601, 546, 409, 457)

firstDay = datetime.datetime(2012,1,15)

#create an array with len(DAU) entries from given starting day
dayArray = [firstDay + datetime.timedelta(days = i) for i in xrange(len(DAU))]

line1 = plot.plot(dayArray, DAU, 'o-', color = '#336699')
line2 = plot.plot(dayArray, WAU, 'o-', color = '#993333')
line3 = plot.plot(dayArray, MAU, 'o-', color = '#89a54e')

ax = plot.subplot(111)
dateLocator   = mticker.MultipleLocator(2)
dateFormatter = dates.DateFormatter('%d.%m.%Y')
ax.xaxis.set_major_locator(dateLocator)
ax.xaxis.set_major_formatter(dateFormatter)
fig.autofmt_xdate(rotation = 90, ha = 'center')

yMax = max(np.max(DAU), np.max(WAU), np.max(MAU))
yLimit = 100 - (yMax % 100) + yMax
plot.yticks(np.arange(0, yLimit + 1, 100))

plot.title('Active users', weight = 'bold')
plot.grid(True, axis = 'both')
plot.subplots_adjust(bottom = 0.2)
plot.subplots_adjust(right = 0.82)

legend = plot.legend((line1[0], line2[0], line3[0]),
                 ('DAU',
                 'WAU',
                 'MAU'),
                 'upper left',
                 bbox_to_anchor = [1, 1],
                 shadow = True)

frame = legend.get_frame()
frame.set_facecolor('0.80')
for t in legend.get_texts():
    t.set_fontsize('small')

#THIS DOES NOT WORK
cursor = Helpers.SnaptoCursor(ax, dayArray, DAU, 'euro daily')
plot.connect('motion_notify_event', cursor.mouse_move)

plot.show()

And this is my module "Helper" that contains the "SnaptoCursor" class: (I got the basic SnaptoCursor class from somewhere else and modified it a little bit)

from __future__ import print_function
import numpy as np
import matplotlib.pyplot as plot

def minsec(sec, unused):
    """
    Returns a string of the input seconds formatted as mm'ss''.
    """
    minutes = sec // 60
    sec = sec - minutes * 60
    return '{0:02d}\'{1:02d}\'\''.format(int(minutes), int(sec))

class SnaptoCursor():
    """
    A cursor with crosshair snaps to the nearest x point.
    For simplicity, I'm assuming x is sorted.
    """
    def __init__(self, ax, x, y, formatting, z = None):
        """
        ax: plot axis
        x: plot spacing
        y: plot data
        formatting: string flag for desired formatting
        z: optional second plot data
        """
        self.ax = ax
        self.lx = ax.axhline(color = 'k')  #the horiz line
        self.ly = ax.axvline(color = 'k')  #the vert line
        self.x = x
        self.y = y
        self.z = z
        # text location in axes coords
        self.txt = ax.text(0.6, 0.9, '', transform = ax.transAxes)
        self.formatting = formatting

    def format(self, x, y):
        if self.formatting == 'minsec':
            return 'x={0:d}, y='.format(x) + minsec(y, 0)

        elif self.formatting == 'daily euro':
            return u'day {0:d}: {1:.2f}€'.format(x, y)

    def mouse_move(self, event):
        if not event.inaxes: return

        mouseX, mouseY = event.xdata, event.ydata

        #searchsorted: returns an index or indices that suggest where x should be inserted
        #so that the order of the list self.x would be preserved
        indx = np.searchsorted(self.x, [mouseX])[0]

        mouseX = self.x[indx]
        #if z wasn't defined
        if self.z == None:
            mouseY = self.y[indx]
        #if z was defined: compare the distance between mouse and the two plots y and z
        #and use the nearest one
        elif abs(mouseY - self.y[indx]) < abs(mouseY - self.z[indx]):
            mouseY = self.y[indx]
        else:
            mouseY = self.z[indx]

        #update the line positions
        self.lx.set_ydata(mouseY)
        self.ly.set_xdata(mouseX)

        self.txt.set_text(self.format(mouseX, mouseY))
        plot.draw()

Of course this does not work since I am calling the SnaptoCursor with the datetime array "dayArray", which is supposed to be compared to the mouse coordinates later on. And these data types are not comparable.

like image 745
Andrea Keil Avatar asked Jan 15 '13 12:01

Andrea Keil


1 Answers

I got it!!!

The problems where these two lines in the init method of the SnaptoCursor class:

self.lx = ax.axhline(color = 'k')  #the horiz line
self.ly = ax.axvline(color = 'k')  #the vert line

They were somehow messing up the datetime x axis (that has ordinals up to 730,000 e.g.), so you just have to initialize the lines' coordinates:

self.lx = ax.axhline(y = min(y), color = 'k')  #the horiz line
self.ly = ax.axvline(x = min(x), color = 'k')  #the vert line

Then it works just fine!

I'll be posting my complete SnaptoCursor class now that I have modified so it accepts individual formatting strings, and it can take up to 3 input data plots - that get snapped to according to your mouse position.

def percent(x, unused):
    """
    Returns a string of the float number x formatted as %.
    """
    return '{0:1.2f}%'.format(x * 100)

def minsec(sec, unused):
    """
    Returns a string of the input seconds formatted as mm'ss''.
    """
    minutes = sec // 60
    sec = sec - minutes * 60
    return '{0:02d}\'{1:02d}\'\''.format(int(minutes), int(sec))

class SnaptoCursor():
    """
    A cursor with crosshair snaps to the nearest x point.
    For simplicity, I'm assuming x is sorted.
    """
    def __init__(self, ax, x, y, formatting, y2 = None, y3 = None):
        """
        ax: plot axis
        x: plot spacing
        y: plot data
        formatting: string flag for desired formatting
        y2: optional second plot data
        y3: optional third plot data
        """
        self.ax = ax
        self.lx = ax.axhline(y = min(y), color = 'k')  #the horiz line
        self.ly = ax.axvline(x = min(x), color = 'k')  #the vert line
        self.x = x
        self.y = y
        self.y2 = y2
        self.y3 = y3
        # text location in axes coords
        self.txt = ax.text(0.6, 0.9, '', transform = ax.transAxes)
        self.formatting = formatting

    def format(self, x, y):
        if self.formatting == 'minsec':
            return 'x={0:d}, y='.format(x) + minsec(y, 0)

        if self.formatting == 'decimal':
            return 'x={0:d}, y={1:d}'.format(x, int(y))

        elif self.formatting == 'date decimal':
            return 'x={0:%d.%m.%Y}, y={1:d}'.format(x, int(y))

        elif self.formatting == 'decimal percent':
            return 'x={0:d}, y={1:d}%'.format(x, int(y * 100))

        elif self.formatting == 'float':
            return 'x={0:d}, y={1:.2f}'.format(x, y)

        elif self.formatting == 'float percent':
            return 'x={0:d}, y='.format(x) + percent(y, 0)

        elif self.formatting == 'daily euro':
            return u'day {0:d}: {1:.2f}€'.format(x, y)

    def mouse_move(self, event):
        if not event.inaxes:
            return

        mouseX, mouseY = event.xdata, event.ydata
        if type(self.x[0]) == datetime.datetime:
            mouseX = dates.num2date(int(mouseX)).replace(tzinfo = None)

        #searchsorted: returns an index or indices that suggest where mouseX should be inserted
        #so that the order of the list self.x would be preserved
        indx = np.searchsorted(self.x, [mouseX])[0]

        #if indx is out of bounds
        if indx >= len(self.x):
            indx = len(self.x) - 1

        #if y2 wasn't defined
        if self.y2 == None:
            mouseY = self.y[indx]

        #if y2 was defined AND y3 wasn't defined
        elif self.y3 == None: 
            if abs(mouseY - self.y[indx]) < abs(mouseY - self.y2[indx]):
                mouseY = self.y[indx]
            else:
                mouseY = self.y2[indx]

        #if y2 AND y3 were defined
        elif abs(mouseY - self.y2[indx]) < abs(mouseY - self.y[indx]):
            if abs(mouseY - self.y2[indx]) < abs(mouseY - self.y3[indx]):
                mouseY = self.y2[indx]
            else:
                mouseY = self.y3[indx]
        #lastly, compare y with y3
        elif abs(mouseY - self.y[indx]) < abs(mouseY - self.y3[indx]):
            mouseY = self.y[indx]
        else:
            mouseY = self.y3[indx]

        #update the line positions
        self.lx.set_ydata(mouseY)
        self.ly.set_xdata(mouseX)

        self.txt.set_text(self.format(mouseX, mouseY))
        plot.draw()
like image 71
Andrea Keil Avatar answered Nov 15 '22 11:11

Andrea Keil