My God, It’s Full of Stars (5/7) — Adding Buttons to an Interactive Matplotlib Interface

Data Science Filmmaker
7 min readDec 18, 2023

--

Last time, we used Python to ask C to create a simulated star cluster, then plotted the color-magnitude diagram for that cluster using matplotlib and a custom function.

Our goal is to build an interactive interface that looks like this:

Our next step will be to tackle adding the buttons that let us select the filter bands that we want to use, and then some more buttons to display the different versions of the CMD that we want to see on screen.

Let’s start by placing our CMD in the proper place on the interface:

import matplotlib.pyplot as plt

# Set labels and make room for buttons and sliders
plt.ion()
fig,ax = plt.subplots(figsize=(13,7))
figwidth = 0.45
fig.subplots_adjust(right=figwidth,left=0.05,top=0.95)
plotScatter()

The first line makes our plot interactive. Next, we create the entire interface. The dimensions (13,7) are chosen to fill my laptop screen. We then tell it to use 45% of the screen for the plot itself, reserving the other 55% for the buttons and sliders.

The function plotScatter takes the scattered data that we created last time and plots it:

def plotScatter():
global scatterPoints
if "scatterHide" in globals():
resetColor(scatterHide)
scatterCleaned = st.scatterClean(scattered,params)
scatterPoints = mc.HR(scatterCleaned,
band1 = band1,
band2 = band2,
overplot=True,
ebars = True,
size = 10,
color = "blue")
refreshLimits()
fig.canvas.draw()

We store the points that it creates so that we can hide and show them later on (via our buttons). The last two lines are there so that any time we call plotScatter (which we will do interactively whenever we change certain parameters via our buttons and sliders), we redetermine the x and y limits on our graph and update the graph.

For now, we have:

We add some similar functions to plot a couple other items of interest: the cluster stars without scatter, the field stars (also without scatter), and an “isochrone” (meaning “same age”), which is a line that that shows where the main sequence and white dwarf sequence are for that cluster’s age, chemical composition, distance, and reddening. We also plot horizontal lines at the bright and faint limits set by the user. Any main sequence stars brighter than the upper line or fainter than the lower line will be removed before we do the scattering.

It’s time to add some buttons! We do this via the Button widget that is part of matplotlib.

from matplotlib.widgets import Button

We set the height, width, and padding for the buttons in general:

# Button sizes
buttonheight = 0.05
buttonwidth = 0.1
pad = 0.01
filterWidth = (2 * buttonwidth + pad) / 8.

The first buttons we want to create are for switching between different filter colors. We create a list of Buttons and a list of axes to hold them, one for each filter in our filter set:

# Add filter buttons
band1Buttons = [None] * len(filters)
band2Buttons = [None] * len(filters)
band1ax = [None] * len(filters)
band2ax = [None] * len(filters)
for i,filt in enumerate(filters):
band1ax[i] = plt.axes([figwidth + pad + i * filterWidth, .95 \
- (buttonheight), filterWidth, buttonheight])
band1Buttons[i] = Button(band1ax[i], filt)
band2ax[i] = plt.axes([figwidth + pad + i * filterWidth, .95 \
- (2 * buttonheight), filterWidth, buttonheight])
band2Buttons[i] = Button(band2ax[i], filt)

Let’s style the buttons. We want to indicate which two bands are currently being used for our graph (note the x axis). We also want some indication when we are hovering over a button. We’ll do both of these with a color change:

# style filter buttons
buttonColor = band1Buttons[0].color
hoverColor = band1Buttons[0].hovercolor
buttonColorOn = "dimgray"
hoverColorOn = "darkgray"
band1ax[b1].set_facecolor(buttonColorOn)
band1Buttons[b1].color = buttonColorOn
band1Buttons[b1].hovercolor = hoverColorOn
band2ax[b2].set_facecolor(buttonColorOn)
band2Buttons[b2].color = buttonColorOn
band2Buttons[b2].hovercolor = hoverColorOn
B and V selected. Hovering over “I” in top row.

Now we have buttons, but they don’t do anything. We need to connect them to a function:

for band1Button in band1Buttons:
band1Button.on_clicked(toggleBand1)

for band2Button in band2Buttons:
band2Button.on_clicked(toggleBand2)

And define the function:

def toggleBand1(val):

global b1,b2,band1

### Check whatever band we just clicked. If it's already the band that
### we are using, or the same band as the other band, do nothing
if band1ax.index(val.inaxes) == b2 or band1ax.index(val.inaxes) == b1:
return

### Otherwise, set band 1 to the band we just clicked
b1 = band1ax.index(val.inaxes)
band1 = filters[b1]

# Set button colors
for i in range(len(band1ax)):
if i == b1:
band1ax[i].set_facecolor(hoverColorOn)
band1Buttons[i].color = buttonColorOn
band1Buttons[i].hovercolor = hoverColorOn
else:
band1ax[i].set_facecolor(buttonColor)
band1Buttons[i].color = buttonColor
band1Buttons[i].hovercolor = hoverColor

# Update axes and bright/faint limits
setBands()

# Redraw graphs using new bands
if isoPoints[0][0].get_visible(): reDrawIso()
if simPoints[0].get_visible(): reDrawSim()
if fieldPoints[0].get_visible(): reDrawFieldStars()
if scatterPoints[0].get_visible(): reDrawScatter()

The way that we determine which button was clicked is with the expression:

band1ax.index(val.inaxes)

When a button is clicked, several pieces of information are sent to the function that is called. These are stored in “val”. One of the bits of info that we can access is the axes of the button that was clicked (“val.inaxes”). When we created the buttons, we stored a list of the axes objects where we placed each button (“band1ax”). We can use the built-in index() method of Python’s list object to find the index of the first item in our list that matches the axes that we are looking for. We now have the index of our new band. We can use our helper function setbands() to set various important bits based on that index:

### Function to set band names to the corresponding filter names
### Changes the axes labels and bright/faint limits accordingly
def setBands():
global b1, b2, band1, band2
band1 = filters[b1]
band2 = filters[b2]
ax.set_ylabel(band2)
ax.set_xlabel(band1 + " - " + band2)
params.brightLimit[2] = b2
fig.canvas.draw()

Now when we click on a new filter band, our plot updates accordingly. If we want to look at R vs U-R:

Out next task is to add a pair of buttons for each of our four plotted elements (cluster stars, field stars, isochrone, and scattered data points). One button will regenerate a new version of that element. The other will hide/show that element.

For each button, we create new axes to hold the button, create the button object itself, then connect the button to a function that will execute when the button is clicked. For example:

# scatter
axes = plt.axes([figwidth + pad, .95 - (6 * buttonheight + 4 * pad), buttonwidth, buttonheight])
scatterButton = Button(axes, 'scatter')
scatterButton.on_clicked(newScatter)
axes = plt.axes([figwidth + pad + buttonwidth + pad, .95 - (6 * buttonheight + 4 * pad), buttonwidth, buttonheight])
scatterHide = Button(axes, 'Show/Hide')
scatterHide.on_clicked(toggleScatter)

Doing this for all four sets of buttons, we have:

If we click the “scatter” button, we take the same simluated cluster and field stars and we do a different random scattering:

The function that is called when the button is clicked is pretty self-explanatory, as are each of the functions it calls in turn:

def newScatter(val):
deleteScatter()
createScatter()
plotScatter()

When we click the “field stars” button, we get new field stars, but the cluster stars (and their scattering) remain unchanged:

Clicking “simulate” does the opposite, leaving the field stars alone and simulating (and scattering) new cluster stars. Clicking the “isochrone” button creates an entirely new simulation, including cluster stars, field stars, and scattering.

The last set of buttons we need are the ones that allow us to switch between different stellar evolution models. The process looks largely the same as the buttons for filter bands.

#### Model Switching Buttons ####
modelNames = ['Girardi','Chaboyer','Yale-Yonsai','DSED']
modelPosition = [[0,0],[1,1],[0,1],[1,0]]
modelButtons = [None] * 4
modelAxes = [None] * 4

for i in range(4):
modelAxes[i] = plt.axes([figwidth + pad + modelPosition[i][0] * (buttonwidth + pad),
.95 - (10 * buttonheight + pad + modelPosition[i][1] * (buttonheight + pad)),
buttonwidth,
buttonheight])
modelButtons[i] = Button(modelAxes[i], modelNames[i])
modelButtons[i].on_clicked(change_model)

modelAxes[params.msRgbModels].set_facecolor(buttonColorOn)
modelButtons[params.msRgbModels].color = buttonColorOn
modelButtons[params.msRgbModels].hovercolor = hoverColorOn

The function “change_model()” does three things (in order): 1) tells the C code to load the new model set, 2) changes the colors of the buttons to indicate the currently active model set, and 3) recalculates and replots all of the points and curves using this new model.

That’s it for our buttons! Next time, we will talk about how to add sliders to the graph to choose continuous parameters.

Full code available at https://github.com/stevendegennaro/mcmc

--

--