2

When exploring data sets with many points on an xy chart, I can adjust the alpha and/or marker size to give a good quick visual impression of where the points are most densely clustered. However when I zoom in or make the window bigger, the a different alpha and/or marker size is needed to give the same visual impression.

How can I have the alpha value and/or the marker size increase when I make the window bigger or zoom in on the data? I am thinking that if I double the window area I could double the marker size, and/or take the square root of the alpha; and the opposite for zooming.

Note that all points have the same size and alpha. Ideally the solution would work with plot(), but if it can only be done with scatter() that would be helpful also.

adr
  • 1,731
  • 10
  • 18
  • It seems you want to show the markersize in data units, instead of points. How to do this would be shown in [this answer](https://stackoverflow.com/questions/48172928/scale-matplotlib-pyplot-axes-scatter-markersize-by-x-scale). – ImportanceOfBeingErnest Jan 27 '18 at 15:28

1 Answers1

6

You can achieve what you want with matplotlib event handling. You have to catch zoom and resize events separately. It's a bit tricky to account for both at the same time, but not impossible. Below is an example with two subplots, a line plot on the left and a scatter plot on the right. Both zooming (factor) and resizing of the figure (fig_factor) re-scale the points according to the scaling factors in figure size and x- and y- limits. As there are two limits defined -- one for the x and one for the y direction, I used here the respective minima for the two factors. If you'd rather want to scale with the larger factors, change the min to max in both event functions.

from matplotlib import pyplot as plt
import numpy as np

fig, axes = plt.subplots(nrows=1, ncols = 2)
ax1,ax2 = axes
fig_width = fig.get_figwidth()
fig_height = fig.get_figheight()
fig_factor = 1.0

##saving some values
xlim = dict()
ylim = dict()
lines = dict()
line_sizes = dict()
paths = dict()
point_sizes = dict()

## a line plot
x1 = np.linspace(0,np.pi,30)
y1 = np.sin(x1)

lines[ax1] = ax1.plot(x1, y1, 'ro', markersize = 3, alpha = 0.8)
xlim[ax1] = ax1.get_xlim()
ylim[ax1] = ax1.get_ylim()
line_sizes[ax1] = [line.get_markersize() for line in lines[ax1]]


## a scatter plot
x2 = np.random.normal(1,1,30)
y2 = np.random.normal(1,1,30)

paths[ax2] = ax2.scatter(x2,y2, c = 'b', s = 20, alpha = 0.6)
point_sizes[ax2] = paths[ax2].get_sizes()

xlim[ax2] = ax2.get_xlim()
ylim[ax2] = ax2.get_ylim()


def on_resize(event):
    global fig_factor

    w = fig.get_figwidth()
    h = fig.get_figheight()

    fig_factor = min(w/fig_width,h/fig_height)

    for ax in axes:
        lim_change(ax)


def lim_change(ax):
    lx = ax.get_xlim()
    ly = ax.get_ylim()

    factor = min(
        (xlim[ax][1]-xlim[ax][0])/(lx[1]-lx[0]),
        (ylim[ax][1]-ylim[ax][0])/(ly[1]-ly[0])
    )

    try:
        for line,size in zip(lines[ax],line_sizes[ax]):
            line.set_markersize(size*factor*fig_factor)
    except KeyError:
        pass


    try:
        paths[ax].set_sizes([s*factor*fig_factor for s in point_sizes[ax]])
    except KeyError:
        pass

fig.canvas.mpl_connect('resize_event', on_resize)
for ax in axes:
    ax.callbacks.connect('xlim_changed', lim_change)
    ax.callbacks.connect('ylim_changed', lim_change)
plt.show()

The code has been tested in Pyton 2.7 and 3.6 with matplotlib 2.1.1.

EDIT

Motivated by the comments below and this answer, I created another solution. The main idea here is to only use one type of event, namely draw_event. At first the plots did not update correctly upon zooming. Also ax.draw_artist() followed by a fig.canvas.draw_idle() like in the linked answer did not really solve the problem (however, this might be platform/backend specific). Instead I added an extra call to fig.canvas.draw() whenever the scaling changes (the if statement prevents infinite loops).

In addition, do avoid all the global variables, I wrapped everything into a class called MarkerUpdater. Each Axes instance can be registered separately to the MarkerUpdater instance, so you could also have several subplots in one figure, of which some are updated and some not. I also fixed another bug, where the points in the scatter plot scaled wrongly -- they should scale quadratic, not linear (see here).

Finally, as it was missing from the previous solution, I also added updating for the alpha value of the markers. This is not quite as straight forward as the marker size, because alpha values must not be larger than 1.0. For this reason, in my implementation the alpha value can only be decreased from the original value. Here I implemented it such that the alpha decreases when the figure size is decreased. Note that if no alpha value is provided to the plot command, the artist stores None as alpha value. In this case the automatic alpha tuning is off.

What should be updated in which Axes can be defined with the features keyword -- see below if __name__ == '__main__': for an example how to use MarkerUpdater.

EDIT 2

As pointed out by @ImportanceOfBeingErnest, there was a problem with infinite recursion with my answer when using the TkAgg backend, and apparently problems with the figure not refreshing properly upon zooming (which I couldn't verify, so probably that was implementation dependent). Removing the fig.canvas.draw() and adding ax.draw_artist(ax) within the loop over the Axes instances instead fixed this issue.

EDIT 3

I updated the code to fix an ongoing issue where figure is not updated properly upon a draw_event. The fix was taken from this answer, but modified to also work for several figures.

In terms of an explanation of how the factors are obtained, the MarkerUpdater instance contains a dict that stores for each Axes instance the figure dimensions and the limits of the axes at the time it is added with add_ax. Upon a draw_event, which is for instance triggered when the figure is resized or the user zooms in on the data, the new (current) values for figure size and axes limits are retrieved and a scaling factor is calculated (and stored) such that zooming in and increasing the figure size makes the markers bigger. Because x- and y-dimensions may change at different rates, I use min to pick one of the two calculated factors and always scale against the original size of the figure.

If you want your alpha to scale with a different function, you can easily change the lines that adjust the alpha value. For instance, if you want a power law instead of a linear decrease, you can write path.set_alpha(alpha*facA**n), where n is the power.

from matplotlib import pyplot as plt
import numpy as np

##plt.switch_backend('TkAgg')
class MarkerUpdater:
    def __init__(self):
        ##for storing information about Figures and Axes
        self.figs = {}

        ##for storing timers
        self.timer_dict = {}

    def add_ax(self, ax, features=[]):
        ax_dict = self.figs.setdefault(ax.figure,dict())
        ax_dict[ax] = {
            'xlim' : ax.get_xlim(),
            'ylim' : ax.get_ylim(),
            'figw' : ax.figure.get_figwidth(),
            'figh' : ax.figure.get_figheight(),
            'scale_s' : 1.0,
            'scale_a' : 1.0,
            'features' : [features] if isinstance(features,str) else features,
        }
        ax.figure.canvas.mpl_connect('draw_event', self.update_axes)

    def update_axes(self, event):

        for fig,axes in self.figs.items():
            if fig is event.canvas.figure:

                for ax, args in axes.items():
                    ##make sure the figure is re-drawn
                    update = True

                    fw = fig.get_figwidth()
                    fh = fig.get_figheight()
                    fac1 = min(fw/args['figw'], fh/args['figh'])


                    xl = ax.get_xlim()
                    yl = ax.get_ylim()
                    fac2 = min(
                        abs(args['xlim'][1]-args['xlim'][0])/abs(xl[1]-xl[0]),
                        abs(args['ylim'][1]-args['ylim'][0])/abs(yl[1]-yl[0])
                    )

                    ##factor for marker size
                    facS = (fac1*fac2)/args['scale_s']

                    ##factor for alpha -- limited to values smaller 1.0
                    facA = min(1.0,fac1*fac2)/args['scale_a']

                    ##updating the artists
                    if facS != 1.0:
                        for line in ax.lines:
                            if 'size' in args['features']:
                                line.set_markersize(line.get_markersize()*facS)

                            if 'alpha' in args['features']:
                                alpha = line.get_alpha()
                                if alpha is not None:
                                    line.set_alpha(alpha*facA)


                        for path in ax.collections:
                            if 'size' in args['features']:
                                path.set_sizes([s*facS**2 for s in path.get_sizes()])

                            if 'alpha' in args['features']:
                                alpha = path.get_alpha()
                                if alpha is not None:
                                    path.set_alpha(alpha*facA)

                        args['scale_s'] *= facS
                        args['scale_a'] *= facA

                self._redraw_later(fig)



    def _redraw_later(self, fig):
        timer = fig.canvas.new_timer(interval=10)
        timer.single_shot = True
        timer.add_callback(lambda : fig.canvas.draw_idle())
        timer.start()

        ##stopping previous timer
        if fig in self.timer_dict:
            self.timer_dict[fig].stop()

        ##storing a reference to prevent garbage collection
        self.timer_dict[fig] = timer

if __name__ == '__main__':
    my_updater = MarkerUpdater()

    ##setting up the figure
    fig, axes = plt.subplots(nrows = 2, ncols =2)#, figsize=(1,1))
    ax1,ax2,ax3,ax4 = axes.flatten()

    ## a line plot
    x1 = np.linspace(0,np.pi,30)
    y1 = np.sin(x1)
    ax1.plot(x1, y1, 'ro', markersize = 10, alpha = 0.8)
    ax3.plot(x1, y1, 'ro', markersize = 10, alpha = 1)

    ## a scatter plot
    x2 = np.random.normal(1,1,30)
    y2 = np.random.normal(1,1,30)
    ax2.scatter(x2,y2, c = 'b', s = 100, alpha = 0.6)

    ## scatter and line plot
    ax4.scatter(x2,y2, c = 'b', s = 100, alpha = 0.6)
    ax4.plot([0,0.5,1],[0,0.5,1],'ro', markersize = 10) ##note: no alpha value!

    ##setting up the updater
    my_updater.add_ax(ax1, ['size'])  ##line plot, only marker size
    my_updater.add_ax(ax2, ['size'])  ##scatter plot, only marker size
    my_updater.add_ax(ax3, ['alpha']) ##line plot, only alpha
    my_updater.add_ax(ax4, ['size', 'alpha']) ##scatter plot, marker size and alpha

    plt.show()
Thomas Kühn
  • 9,412
  • 3
  • 47
  • 63
  • There was a small bug in the last `try`-`except` block (twice `ax2` instead of `ax`), which I fixed now. – Thomas Kühn Jan 27 '18 at 15:24
  • I would think that it is enough to connect to the draw event itself, as in [this answer]. – ImportanceOfBeingErnest Jan 27 '18 at 15:30
  • Oh, the link is missing, "[this answer]" should link to https://stackoverflow.com/a/48174228/4124317 The solution is similar indeed, but I haven't checked in how far it would need to be adapted to the non-equal aspect used here. Also, alpha is not subject to the other question, I would hence not close this. – ImportanceOfBeingErnest Jan 27 '18 at 15:45
  • I updated the answer to the other question, such that it works as expected on draw_events. – ImportanceOfBeingErnest Jan 27 '18 at 18:06
  • Well, the solution you have provided here has the same problem of using the `draw_event` than my answer to the other question previously had: It's only updating after an additional redraw (e.g. by clicking again in the axes). I solved this by drawing the axes again, `ax.draw_artist(ax)`. This has the (less significant but still annoying) consequence of drawing the axes twice, such that ticklabels overlap (appear bolder). – ImportanceOfBeingErnest Jan 27 '18 at 19:25
  • I am talking about running the code as it is now in your answer. This shows the undesired behaviour of needing an additional click. Adding `ax.draw_artist(ax)` will remove the need for an additional click after zooming. But it has the drawback of having two axes. Also, the code as it currently is produces an error using the TkAgg backend. – ImportanceOfBeingErnest Jan 27 '18 at 19:54
  • @ImportanceOfBeingErnest I now finally understood what you meant. I implemented your fix in the code and tested on both Python versions with the `MacOSX` and `TkAgg` backends and noticed no further problems. Thanks for your help! – Thomas Kühn Jan 27 '18 at 21:31
  • I did open [this issue](https://github.com/matplotlib/matplotlib/issues/10334) about the undesired behaviour. I have not fully understood if you can or cannot reproduce this and if so, using what setup. If you have something to add to the issue, e.g. if you can report about system settings where it does or does not occur, I'd be good to join into the discussion over there. – ImportanceOfBeingErnest Jan 27 '18 at 22:12
  • This is really impressive! Would you add a bit more commentary on getting the factors? For alpha I would try raising the existing alpha to a power of factor or 1/factor. – adr Jan 27 '18 at 23:51
  • I finally found a working solution. One may delay the redraw until the drawing is possible again. I updated the solutions to [this](https://stackoverflow.com/a/48174228/4124317) and [this](https://stackoverflow.com/a/42972469/4124317) answer with it. – ImportanceOfBeingErnest Jan 28 '18 at 00:41
  • Are you getting any unexpected behaviour running any of the two linked codes or is this only a problem for the code in this answer? – ImportanceOfBeingErnest Jan 29 '18 at 08:23
  • 1
    @adr Please see my latest edit. I added some explanation of the code and a fix for the figure not updating properly. – Thomas Kühn Jan 29 '18 at 12:27
  • You're having too much fun with this! I figured out the factors and with 30000 points the effect great with: `facS = (fac1*fac2)**(0.5)/args['scale_s']` and `set_alpha(alpha**(facS**-0.33))`, where fac1 and fac2 are the products rather than the minima. – adr Jan 29 '18 at 16:52
  • 1
    In case someone is interested: https://stackoverflow.com/questions/72395347/keeping-the-marker-size-to-scale-when-zooming-in-on-a-world-map – Tom May 26 '22 at 16:52