GUI Grafo

Com'è nata l'idea

Iniziando un corso di informatica, a scuola, in testa girava l'idea di creare un programma capace di creare dei grafi e mi piaceva il fatto di visualizzare su gui il cammino più breve tra due nodi usando l' Algoritmo di Dijkstra.

Fase di Sviluppo

Sono partito dell'idea di creare un programma molto simile al Software CAD con l'unica cosa che bisognava aggiungere qualche figura in più quando si creano linee, meglio dire Archi.
Ogni Arco che si crea vengono creati due nodi, se premo su un nodo l'arco parte da quel nodo e finisce su un altro. Ogni arco ha il suo peso e di default viene calcolata la distanza cartesiana dal centro dei due nodi e viene divisa per 15, successivamente viene inserita in una casella di testo.
Premendo il tasto destro sull'Arco, viene aperto un piccolo menù in cui viene specificato:

  • Direzione che indica il nodo di partenza e di destinazione
  • Peso dell'arco, che se premuto sopra si può cambiare
  • Bidirezionale appare un tick (✔) se l'arco è bidirezionale, altrimenti non comparirà nulla, se premuto l'arco viene considerato bidirezionale
Si possono poi fare diverse cose, come trovare la distanza minima tra due nodi usando l'Algoritmo di Dijkstra e in caso di errore cancellare l'Arco, il programma poi capirà quali nodi dovrà cancellare.

Commento sul Codice

- Classe Nodo

  1. class Nodo:
  2. def __init__(self, coords: list[tuple], n:int, line_id: int):
  3. self.__coords = coords
  4. self.__number = n
  5. self.__line_id = line_id

  6. def getCoords(self):
  7. return self.__coords

  8. def getNumber(self):
  9. return self.__number

  10. def getId(self):
  11. return self.__line_id
Questo è come vede il programma i Nodi dei grafi. Composto da:
  • Coordinate del centro del nodo
  • Numero del nodo
  • ID della linea del canvas
Gli attributi per convenzione li ho dichiarati privati, in python non avendo una keyword che specifica esplicitamente l'ambito di visibilità si usano i doppi underscore per indicare un attributo/metodo privato.
Per il resto non c'è nulla di particolare e il codice parla solo.

- Classe Arco

  1. class Arco:
  2. def __init__(self, coords: list[tuple], nodes: list[Nodo], height:int, line_id:int, bidirectional = False,**kargs):
  3. self.__coords = coords
  4. self.__nodes = nodes
  5. self.__height = height
  6. self.__line_id = line_id
  7. self.__bidirectional = bidirectional
  8. self.__kargs = kargs
  9. def getCoords(self):
  10. return self.__coords
  11. def getNodes(self):
  12. return self.__nodes
  13. def getHeight(self):
  14. return self.__height
  15. def getId(self):
  16. return self.__line_id
  17. def getBidirectional(self) -> bool:
  18. return self.__bidirectional
  19. def getKargs(self):
  20. return self.__kargs
  21. def getScheme(self, withHeight = True):
  22. s = ""
  23. s += " ".join([str(n.getNumber()) for n in self.__nodes])
  24. if withHeight:
  25. s += f" {self.__height}"
  26. if self.__bidirectional:
  27. s1 = s.split(" ")
  28. s1[0], s1[1] = s1[1], s1[0]
  29. s1 = " ".join(s1)
  30. s += f"\n{s1}"
  31. return s.strip()
  32. def getTotalInfo(self):
  33. s = self.__coords.copy()
  34. s.append({})
  35. if self.__bidirectional:
  36. s[-1]["BIDIREZIONALE"] = 1
  37. s[-1]["PESO"] = self.__height
  38. return s
  39. # ------------------------------------------ #
  40. def toggleBidirectional(self):
  41. self.__bidirectional = not self.__bidirectional
  42. def setBidirectional(self, value):
  43. self.__bidirectional = value
  44. def setHeight(self, value):
  45. self.__height = value
Questo è come vede il programma l'Arco, composto da:
  • coords in cui specifica le coordinate dell'arco
  • nodes è l'array formato da due nodi, quello di partenza e di destinazione
  • height indica il peso dell'arco
  • line_id è l'identificativo dell'arco nel canvas
  • bidirectional di default impostato su False, indica se l'arco è bidirezionale o meno
  • **kargs, sono degli argomenti aggiuntivi, marcati da una chiave (key)
Sui metodi get non c'è molto da dire in quanto hanno un semplice return. C'è da aggiungere qualcosa su getScheme & getTotalInfo:
  • Il metodo getScheme ritorna sotto forma di stringa il nodo di partenza, di fine e il peso.
    Il parametro withHeight, impostato di default su True, mi aggiunge o meno il peso alla fine.

    Su riga 31, s += " ".join([str(n.getNumber()) for n in self.__nodes]), prendo l'array dei Nodi e vado a richiamare il metodo getNumber in modo da ottenere il numero del nodo, e se bidirezionale mi va a copiare lo schema precedente e va aggiungere gli stessi nodi, ma invertiti.
    Infine il return s.strip(), ritorna lo schema strippato, senza spazi in eccesso.

  • Il metodo getTotalInfo, ritorna un array composo dalle coordinate cartesiane dell'arco, seguito da un dizionario in cui si distinguono, il peso e il fatto di essere bidirezionale, che se impostato è 1, altrimenti non c'è.
Successivamente abbiamo i metodi set e uno particolare, toggle. I metodi set impostano il valore il valore del peso(setHeight) e il valore del bidirezionale (setBidirectional), solo che ancora non l'ho usato.
Il metodo che uso è il toggleBidirectional in cui mi inverte il valore di self.__bidirectional.

- Rilevazione Errore

  1. def logFunzione(func):
  2. def wrapper(*args):
  3. try:
  4. func(*args)
  5. except:
  6. err = traceback.format_exc()
  7. ora = time.strftime("%d-%m-%Y | %H:%M", time.localtime())
  8. messagebox.showerror("Notifica di Servizio", "Si è verificato un errore. Chiedere più informazioni al proprietario del software")
  9. with open("log.txt", "a") as file:
  10. file.write(f"[{ora}] =>{err}\n{'-'*130}\n")
  11. file.close()
  12. return wrapper
  13. # ------------------------------------------ #
  14. class GUI(ctk.CTk):
  15. ....
  16. @logFunzione
  17. def setPoint(self, event):
  18. ...
Per controllare gli eventuali errori che si verificano durante l'eseguzione del programma, senza riscrivere molto codice ho introdotto un decoratore.
Usando il blocco try-except riesco a trovare gli errori che che avvengono nella funzione, in questo caso una essenziale perché riguarda il posizionamento di Nodi & Archi, per trovare poi l'errore utilizzo il traceback che mi fornisce dettagli sull'errore e poi viene salvato nel file log.txt.
Quando si verifica un errore appare un messaggio, che dice di segnalarmelo, ho programmato una funzione segreta che mi permette di accedere al file dei log, per accederci bisogna fare tasto destro sul bottone Cancella Tutto e si aprirà una finestra in cui ci sarà il contenuto del file di log.

- Creazione Arco

  1. self.archi += 1
  2. temp = self.temp_coods.copy()
  3. pm = [(temp[0][0] + temp[1][0])/2, (temp[0][1] + temp[1][1])/2]
  4. try: angle = math.degrees(math.atan2((temp[0][1] - temp[1][1]), (temp[0][0] - temp[1][0])))
  5. except: angle = 90
  6. lenght = math.sqrt((temp[0][0] - temp[1][0])**2 + (temp[0][1] - temp[1][1])**2)
  7. lenght /= 2
  8. lenght -= 10
  9. temp = list(map(lambda x: list(x), temp))
  10. temp[0][0] = lenght*cos(angle) + pm[0] # x
  11. temp[0][1] = lenght*sin(angle) + pm[1] # y
  12. temp[1][0] = lenght*cos(angle+180) + pm[0] # x
  13. temp[1][1] = lenght*sin(angle+180) + pm[1] # y
  14. line = self.canvas.create_line(temp, fill="white", arrow="last", arrowshape=(7.0, 11.0, 6.0), activewidth=3, width=2)
Per iniziare incremento il contatore degli archi self.archi += 1.
Successivamente mi copio le coordinate in una variabile temp.
L'obiettivo è accorciare la linea di 10 punti, perché se lasciata di default oltre dare fastidio all'occhio, il disegno non è corretto.
Ora, utilizzando un po' di goniometria, considero la linea come il diametro di un cerchio con lighezza tale a quella della linea.
Calcolandomi il punto medio (pm), lo considero come centro della mia circonferenza, mi trovo la pendenza della linea (angle) e la sua lunghezza (lenght).
Visto che la lunghezza la considero come il diametro, la divido per 2, in modo da ottenere il raggio della mia circonferenza, e da li gli tolgo 10 punti, dato che serve più corta.
Adesso per sapere le nuove coordinate dei punti utilizzo la parametrizzazione di una circonferenza, usando come raggio, la nuova lunghezza lenght e come angolo angle. Faccio una precisazione sulle ultime righe dove esce come argomento del seno e coseno angle+180, è una regola degli angoli associati e, pm[0] e pm[1] sono le coordinate x e y del centro della circonferenza.
Infine creo la linea (line) usando la lista di coordinate temp.

- Riconoscimento Click

  1. if self.permitted_coods[0][0]:
  2. self.nodi += 1
  3. nodo1 = self.canvas.create_oval((x1,y1),(x2,y2), fill="#202123", outline="white", activewidth=2)
  4. text_nodo1 = self.canvas.create_text(self.temp_coods[0], text=str(self.nodi), activefill="red", fill="white", state="disabled")
  5. ogg_nodo1 = Nodo(self.temp_coods[0], self.nodi, nodo1)
  6. self.canvas.itemconfig(nodo1, tags=([pickle.dumps(ogg_nodo1)]))
  7. if event.num == 2:
  8. x,y = self.temp_coods[0]
  9. self.dict_nodes[f"{x} {y}"] = [(x,y), pickle.dumps(ogg_nodo1)]
  10. else:
  11. ogg_nodo1 = pickle.loads(self.permitted_coods[0][1])
self.permitted_coods è una matrice 2x2 in cui dice lo stato attuale nell'esatto punto in cui ho premuto, il primo elemento è un booleano in cui mi dice se è uno spazio vuoto o meno, ciò lo determina usando i tags, se c'è una determianta condizione usando quel tag, sa se è uno spazio vuoto o meno. Il secondo elemento è il tag stesso.
Se lo spazio è vuoto (if self.permitted_coods[0][0]) incrementa di 1 il counter dei nodi, self.nodi += 1 e crea la parte visiva del nodo (nodo1) rappresentandolo come un cerchio e il testo del numero del nodo. Poi viene creato un oggetto di tipo Nodo, il costruttore accetta:
  • Coordinate del centro del nodo
  • Numero del nodo (che il quel momento viene rappresentato da self.nodi)
  • ID del disegno del cerchio (nodo1), nel suo tag sarà racchiuso l'oggetto interpretato da pickle
Dove si vede if event.num == 2 è un "metodo" per quando si deve caricare un file sul programma, quindi, senza creare un altra funzione che faccia la stessa identica cosa, all'evento gli modifico il parametro num e lo imposto uguale a 2.
In un evento num è il numero del pulsante del mouse
  • 1 tasto sinistro del mouse
  • 2 tasto centrale del mouse
  • 3 tasto destro del mouse
tuttavia quando si chiama la funzione per quando si preme il tasto sinistro del mouse, il parametro num è sempre 1, impostandolo quindi a 2 capisce che si tratta di un caricamento del file e non di un suo utilizzo normale, prende le coordinate dall'array self.temp_coods e lo inserire in un dizionario (conosciuto come dict) che ha come key le coordinate sottoforma di stringa e come value le coordinate insieme all'oggetto Nodo interpretato da pickle. Quando invece rileva qualcosa recupera l'oggetto contenuto in self.permitted_coods[0][1] utilizzando pickle.