top of page
JMathGLogo1.png

Making a Graph Class in Python 3 - More GUI

Updated: Jul 1, 2023

Let's jump straight into building up more of the Graph GUI. As always, look at the last post to see the code and progress up to this point. Now we're going to add the Remove Vertex, Add Edge, and Remove Edge buttons and functionality. Some variables from last time have been slightly renamed to fit the pattern.


First we define the add/remove vertex text and the flags that tell us what mode we're in.

avtext = '+V'
rvtext = '-V'
show_avertex_prompt = False
remove_vertex_mode = False

Then we have the add vertex button and prompt from last time. The remaining operations won't need a user-typed prompt, so they'll look much simpler and we just need to define a remove vertex button. The positioning I'm just doing by eye.

AVPrompt = User_Typed_Input(sw//2 - 50,sh//2 - gui_height//4,100,bfont,10)
AVButton = Button_Object(10,sh+10,avtext,bfont,text_colors,bg_colors,bord_color,5,5)
RVButton = Button_Object(10,sh+gui_height//2 + 5,rvtext,bfont,text_colors,bg_colors,bord_color,5,16)

Next, we define the same things but with adding/removing edges. We define two generic vertices "aev1" and "aev2", whose names are strange enough that I won't reuse them accidentally. These are placeholders for when we choose the two endpoints to add/remove an edge to/from.

aetext = '+E'
retext = '-E'
add_edge_mode = False
remove_edge_mode = False
aev1 = None
aev2 = None
AEButton = Button_Object(sw-75,sh+10,aetext,bfont,text_colors,bg_colors,bord_color,5,5)
REButton = Button_Object(sw-75,sh+gui_height//2+5,retext,bfont,text_colors,bg_colors,bord_color,5,16)

Under the Main Loop, we go back to where we drew the "add vertex" button. We've renamed some things by adding "A" to the front. But we'll also be sure to disable every other mode if "show_avertex_prompt" is on.

AVButton.draw(screen)
AVBool = AVButton.update(event_list)
if AVBool == 1:
    show_avertex_prompt = True
    remove_vertex_mode = False
    add_edge_mode = False
    remove_edge_mode = False
if show_avertex_prompt:
    AVPrompt.draw(screen)
    ...

And then every other button has the same form as the beginning of "add vertex", except that if that mode is on, we keep the button active to show it. Then we draw and update the function as usual and if the update boolean returns a "1", we turn that mode on. Here they all are:

#RemoveVertex
if remove_vertex_mode:
     RVButton.active = True
RVButton.draw(screen)
RVBool = RVButton.update(event_list)
if RVBool == 1:
     remove_vertex_mode = True
     show_avertex_prompt = False
     add_edge_mode = False
     remove_edge_mode = False
#AddEdge
if add_edge_mode:
     AEButton.active = True
AEButton.draw(screen)
AEBool = AEButton.update(event_list)
if AEBool == 1:
     add_edge_mode = True
     show_avertex_prompt = False
     remove_vertex_mode = False
     remove_edge_mode = False
#RemoveEdge
if remove_edge_mode:
     REButton.active = True
REButton.draw(screen)
REBool = REButton.update(event_list)
if REBool == 1:
     remove_edge_mode = True
     show_avertex_mode = False
     remove_vertex_mode = False
     add_edge_mode = False

So what does our display look at after all this? Well it has our +V, -V, +E, -E buttons and they can be selected.

Look great! To have them actually do something, we'll go inside our Event Loop inside the Main Loop and add conditionals for being in each mode. The code that we had in there before was just the "moving_vertex" code that allowed us to click a vertex and move it. Now we'll throw that in the final "else:" statement after these conditionals.

if event.type == pygame.MOUSEBUTTONDOWN:
    mpos = pygame.mouse.get_pos()
    if add_edge_mode: 
        ...
    elif remove_edge_mode:
        ...
    elif remove_vertex_mode:
        ...  
    else:
        if moving_vertex == None:
            #update vertex position
            for ind,v in enumerate(vert_pos):
                if distance(mpos,v) < vrad:
                    moving_vertex = (ind,self.vertices[ind])
                    break
                else:
                    moving_vertex = None

For the "remove_vertex_mode", we detect whether a vertex has been clicked by looking at the distance between the click and the vertex. If so, we remove it using our class method, delete its position from "vert_pos", then set "remove_vertex_mode = False".

elif remove_vertex_mode:
     for ind,v in enumerate(vert_pos):
          if distance(mpos,v) < vrad:
               self.remove_vertex(self.vertices[ind])
               del vert_pos[ind]
               remove_vertex_mode = False

Strangely enough, I kept getting an error here, and it kept going back to the "remove vertex" method. We had to do a small change, so here it is again:

def remove_vertex(self,v):
     if v not in self.vertices:
          print(f'Vertex {v} not found')
          return 0
     deg = 0
     for e in self.edges:
          if e[0] == v or e[1] == v:
               self.remove_edge(e) ###UPDATE TO FIX -V
               deg += 1
     self.size -= deg//2
     self.vertices.remove(v)
     self.order -= 1

As you can see by the comment, that code "self.remove_edge(e)" was replacing "self.edges.remove(e)" and "self.edge.remove(e[1],e[0])". Using the class method updates all the values correctly!


The add/remove edge buttons are similar except we look at whether "aev1 = None" and "aev2 = None", and if so, we assign those vertices to the ones you click on.

if add_edge_mode:   
     if aev1 == None:
          for ind,v in enumerate(vert_pos):
               if distance(mpos,v) < vrad:
                    aev1 = self.vertices[ind]
     elif aev2 == None:
          for ind,v in enumerate(vert_pos):
               if distance(mpos,v) < vrad and v2 != v1:
                    aev2 = self.vertices[ind]
elif remove_edge_mode:
     if aev1 == None:
          for ind,v in enumerate(vert_pos):
               if distance(mpos,v) < vrad:
                    aev1 = self.vertices[ind]
     elif aev2 == None:
          for ind,v in enumerate(vert_pos):
               if distance(mpos,v) < vrad and v2 != v1:
                    aev2 = self.vertices[ind]

Next, we go outside the Event Loop and add a check for whether both "aev1 != None" and "aev2 != None". If this is true, then both generic vertices have been assigned, so we check whether we're in "add edge" mode or "remove edge" mode, and call the appropriate class method to do it. We also reset the generic vertices and deactivate the mode and button.

if aev1 != None and aev2 != None: 
    if add_edge_mode:
         self.add_edge((aev1,aev2))
         aev1 = None
         aev2 = None
         add_edge_mode = False
         AEButton.active = False
     elif remove_edge_mode:
         self.remove_edge((aev1,aev2))
         aev1 = None
         aev2 = None
         remove_edge_mode = False
         REButton.active = False

And after much work, we're done! Let's go through these buttons.

  • Adding Vertices:

  • Removing Vertices:

  • Adding Edges

  • Removing Edges

We can now build any graphs we want without ever having to reference the code! For example, the Petersen Graph:

GIF Building the Petersen Graph using the GUI

I'm very happy with this! Let's fill some more of that GUI space.


Returning Graph Info, Loading Presets, and Showing Matrices


Let's add two buttons directly in the center, one called "GET" and the other "PRESETS". We'll make "GET" print out the graph's vertex set, their positions, and its edge set. This way, you could enter it back in by copy-pasting if you wanted to get back to it later.


There's a pattern to adding buttons now:

  1. First define a button before the Main Loop inside the "show()" function.

  2. Draw and update the button near the bottom of the Main Loop, getting the clicked state.

  3. Within our Event Loop, write what we do when the button is clicked.

The first part looks like so:

#get
GETtext = 'GET'
GETButton = Button_Object(sw//2,sh+10,GETtext,bfont,text_colors,bg_colors,bord_color,5,16)
GETButton.rect.x -= GETButton.rect.w//2

The second part like so:

GETButton.draw(screen)
GETBool = GETButton.update(event_list)
if GETBool == 1:
    print('Vertices:',self.vertices)
    print('Vert_Pos:',vert_pos)
    print('Edges:',self.edges)

And we actually don't even need the third part for this function, we just print what we want! Here's how it looks and our output when we click.

Adding the GET button to the GUI

And the output is:

Vertices: [0, 1, 2, 3, 5]
Vert_Pos: [(609, 67), (63, 147), (330, 191), (149, 611), (552, 578)]
Edges: [(0, 1), (1, 0), (5, 3), (3, 5), (5, 2), (2, 5), (2, 3), (3, 2), (1, 3), (3, 1), (0, 5), (5, 0)]

Great! Adding the next button starts off the same. Before our main loop, we have

#presets
PRESETtext = 'PRESET'
preset_menu = False
PRESETButton = Button_Object(sw//2,sh+gui_height//2+5,PRESETtext,bfont2,text_colors,bg_colors,bord_color,5,16)
PRESETButton.rect.x -= PRESETButton.rect.w//2

And near the end of the Main Loop, we put

#PRESETS
PRESETButton.draw(screen)
PRESETBool = PRESETButton.update(event_list)
if PRESETBool == 1:
    preset_menu = True

But now we actually have a flag "preset_menu", so we have to add something into our Event Loop to account for this. And what we want is for a menu to pop up that lets you choose a preset graph. We want the menu to not be hard-coded so that if we add another preset, it updates itself correctly. Do we show pictures? Names? Most of the presets depend on some inputs, so how will the user determine those when choosing a preset?


Here's what I'm thinking. When we click PRESET, it'll pop up a smaller floating graph on a greyed background with its name across the bottom and a X on the topleft. We'll have arrows on both sides and clicking those arrows will scroll the presets. That way, if we add any more presets, they'll just be automatically included in the loop. In fact, we could even easily implement the ability to add your own graphs to the preset menu. That'd be as simple as creating a "SAVE" button that takes a user-typed name and adds it and the graph info to the preset menu list.


If we choose a graph that needs parameters, we'll pop up a User_Typed_Input box along with a text prompt describing what inputs we need. If any entries are invalid or anything fails, we'll just have it do nothing. Otherwise, we load the vertices, edges, and vertex positions that we've stored for the presets. Then the display will automatically switch to that graph and we will be able to work with it as we please!


Since that will certainly be pretty involved, let's save it for its own section and add something a little simpler first. We'll add three buttons "AMat", "IMat", and "LMat", which will print the Adjacency, Incidence, or Laplacian Matrix respectively. Once we get the work with the preset menu done, we'll create a little screen to display the matrices nicely within the GUI. But for now, we define the three buttons.

#matrices
AMattext = 'AMat'
IMattext = 'IMat'
LMattext = 'LMat'
AMatButton = Button_Object(AVButton.rect.right + 10,sh+10,AMattext,bfont3,text_colors,bg_colors,bord_color,5,16)
IMatButton = Button_Object(AVButton.rect.right + 10,AMatButton.rect.top + gui_height//3,IMattext,bfont3,text_colors,bg_colors,bord_color,5,16)
LMatButton = Button_Object(AVButton.rect.right + 10,IMatButton.rect.top + gui_height//3,LMattext,bfont3,text_colors,bg_colors,bord_color,5,16)

Then we add the draw/update methods to the end of the Main Loop. Since we're just printing information, we don't need any flag variable at the moment.

#MATRICES
AMatButton.draw(screen)
AMatBool = AMatButton.update(event_list)
if AMatBool == 1:
     print('Adjacency Matrix')
     print('NPA:')
     print(self.adjacency_matrix(True))
     print('Lists:')
     print(self.adjacency_matrix())
IMatButton.draw(screen)
IMatBool = IMatButton.update(event_list)
if IMatBool == 1:
     print('Incidence Matrix')
     print('NPA:')
     print(self.incidence_matrix(True))
     print('Lists:')
     print(self.incidence_matrix())
LMatButton.draw(screen)
LMatBool = LMatButton.update(event_list)
if LMatBool == 1:
     print('Laplacian Matrix')
     print('NPA:')
     print(self.laplacian_matrix(True))
     print('Lists:')
     print(self.laplacian_matrix())

And here are a couple of examples: a 2-cycle and the RooksGraph(2,3).

I think that actually makes a pretty decent post! If we try the preset menu stuff, this will be very long. So we'll save that for the next post.


For the files, I'm not sure if the zip files are working correctly, because I can't get them to unzip even with The Unarchiver. Just in case, here is the .py file and the full dist folder containing all the dependencies we need for the program to run, as well as the actual Executable, from Pyinstaller.

GraphClass.py
.zip
Download ZIP • 5KB

GraphClassExecutableV2
.zip
Download ZIP • 27.79MB

In case that doesn't work, I've converted it to a .txt file, which you should be able to just open with any Python IDE and run as a Python script!

GraphClass copy
.txt
Download TXT • 20KB

Hopefully at least one of those methods will work!


UPDATE: I tested downloading the .txt file and it works - Just rename it to a .py file, then open it and your default IDE will open it as a .py file. This will be my default way to put code after these posts now!



Thank you for reading!



Jonathan M Gerhard

Recent Posts

See All

Commentaires


bottom of page