Spaces:
Paused
Paused
| # components.py | |
| # Contains custom UI widgets, the InteractiveComparisonPanel is isolated here | |
| import tkinter as tk | |
| import math | |
| class InteractiveComparisonPanel: | |
| """ | |
| A specific panel for the Comparison Window. | |
| Features: Move Nodes, Pan View (Background Drag), Zoom (Scroll). | |
| """ | |
| def __init__(self, parent, graph, name, node_radius, agents_map, redraw_callback, click_callback): | |
| self.G = graph | |
| self.name = name | |
| self.node_radius = node_radius | |
| self.agents = agents_map | |
| self.redraw_callback = redraw_callback | |
| self.click_callback = click_callback | |
| self.zoom = 1.0 | |
| self.offset_x = 0 | |
| self.offset_y = 0 | |
| self.drag_mode = None | |
| self.drag_data = None | |
| self.initialized = False | |
| self.highlights = [] | |
| self.outer = tk.Frame(parent, bd=2, relief=tk.GROOVE) | |
| self.outer.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=5, pady=5) | |
| tk.Label(self.outer, text=name, font=("Arial", 13, "bold"), bg="#ddd").pack(fill=tk.X) | |
| self.canvas = tk.Canvas(self.outer, bg="white") | |
| self.canvas.pack(fill=tk.BOTH, expand=True) | |
| # Bindings | |
| self.canvas.bind("<Button-1>", self.on_mouse_down) | |
| self.canvas.bind("<B1-Motion>", self.on_mouse_drag) | |
| self.canvas.bind("<ButtonRelease-1>", self.on_mouse_up) | |
| # Mouse Wheel | |
| self.canvas.bind("<MouseWheel>", self.on_zoom) | |
| self.canvas.bind("<Button-4>", lambda e: self.on_zoom(e, 1)) | |
| self.canvas.bind("<Button-5>", lambda e: self.on_zoom(e, -1)) | |
| # Resize event for centering | |
| self.canvas.bind("<Configure>", self.on_resize) | |
| def set_highlights(self, highlights): | |
| """Updates the visual highlights and triggers a redraw.""" | |
| self.highlights = highlights | |
| self.redraw() | |
| def on_resize(self, event): | |
| if not self.initialized: | |
| self.center_view(event.width, event.height) | |
| self.initialized = True | |
| self.redraw() | |
| def center_view(self, width, height): | |
| if self.G.number_of_nodes() == 0: return | |
| xs = [d.get('pos', (0,0))[0] for n, d in self.G.nodes(data=True)] | |
| ys = [d.get('pos', (0,0))[1] for n, d in self.G.nodes(data=True)] | |
| if not xs: return | |
| min_x, max_x = min(xs), max(xs) | |
| min_y, max_y = min(ys), max(ys) | |
| graph_cx = (min_x + max_x) / 2 | |
| graph_cy = (min_y + max_y) / 2 | |
| self.offset_x = (width / 2) - (graph_cx * self.zoom) | |
| self.offset_y = (height / 2) - (graph_cy * self.zoom) | |
| def to_screen(self, wx, wy): | |
| sx = (wx * self.zoom) + self.offset_x | |
| sy = (wy * self.zoom) + self.offset_y | |
| return sx, sy | |
| def to_world(self, sx, sy): | |
| wx = (sx - self.offset_x) / self.zoom | |
| wy = (sy - self.offset_y) / self.zoom | |
| return wx, wy | |
| def redraw(self): | |
| self.canvas.delete("all") | |
| r = self.node_radius * self.zoom | |
| # Draw Highlights | |
| if self.highlights: | |
| edge_counts = {} | |
| for h in self.highlights: | |
| color = h.get('color', 'yellow') | |
| width = h.get('width', 8) * self.zoom | |
| # Draw Nodes | |
| for n in h.get('nodes', []): | |
| wx, wy = self.G.nodes[n].get('pos', (0,0)) | |
| sx, sy = self.to_screen(wx, wy) | |
| rad = r + (width / 2) | |
| self.canvas.create_oval(sx-rad, sy-rad, sx+rad, sy+rad, fill=color, outline=color) | |
| # Draw Edges (Offset) | |
| for u, v in h.get('edges', []): | |
| edge_key = tuple(sorted((u, v))) | |
| count = edge_counts.get(edge_key, 0) | |
| edge_counts[edge_key] = count + 1 | |
| offset_step = width / 2 | |
| current_offset = (count * width) - offset_step | |
| p1 = self.G.nodes[u].get('pos', (0,0)) | |
| p2 = self.G.nodes[v].get('pos', (0,0)) | |
| sx1, sy1 = self.to_screen(p1[0], p1[1]) | |
| sx2, sy2 = self.to_screen(p2[0], p2[1]) | |
| dx, dy = sx2 - sx1, sy2 - sy1 | |
| length = math.hypot(dx, dy) | |
| if length == 0: continue | |
| nx, ny = -dy / length, dx / length | |
| os_x = nx * current_offset | |
| os_y = ny * current_offset | |
| self.canvas.create_line(sx1+os_x, sy1+os_y, sx2+os_x, sy2+os_y, | |
| fill=color, width=width, capstyle=tk.ROUND, joinstyle=tk.ROUND) | |
| # DRAW STANDARD GRAPH (Edges & Nodes) | |
| # Edges | |
| for u, v in self.G.edges(): | |
| p1 = self.G.nodes[u].get('pos', (0,0)) | |
| p2 = self.G.nodes[v].get('pos', (0,0)) | |
| sx1, sy1 = self.to_screen(p1[0], p1[1]) | |
| sx2, sy2 = self.to_screen(p2[0], p2[1]) | |
| self.canvas.create_line(sx1, sy1, sx2, sy2, arrow=tk.LAST, width=2*self.zoom) | |
| # Nodes | |
| for n, d in self.G.nodes(data=True): | |
| wx, wy = d.get('pos', (0,0)) | |
| sx, sy = self.to_screen(wx, wy) | |
| ag = d.get('agent', "Unassigned") | |
| fill = self.agents.get(ag, "white") | |
| if '_color_cache' in d: fill = d['_color_cache'] | |
| if d.get('type') == "Function": | |
| self.canvas.create_rectangle(sx-r, sy-r, sx+r, sy+r, fill=fill, outline="black") | |
| else: | |
| self.canvas.create_oval(sx-r, sy-r, sx+r, sy+r, fill=fill, outline="black") | |
| lbl = d.get('label', '') | |
| font_size = max(15, int(10 * self.zoom)) | |
| label_offset = r + (5*self.zoom) | |
| self.canvas.create_text(sx, sy-label_offset, text=lbl, font=("Arial", font_size, "bold",), anchor="s") | |
| def on_zoom(self, event, direction=None): | |
| if direction is None: | |
| factor = 1.1 if event.delta > 0 else 0.9 | |
| else: | |
| factor = 1.1 if direction > 0 else 0.9 | |
| self.zoom *= factor | |
| self.redraw() | |
| def on_mouse_down(self, event): | |
| mx, my = event.x, event.y | |
| clicked_node = None | |
| r_screen = self.node_radius * self.zoom | |
| for n, d in self.G.nodes(data=True): | |
| wx, wy = d.get('pos', (0,0)) | |
| sx, sy = self.to_screen(wx, wy) | |
| if math.hypot(mx-sx, my-sy) <= r_screen: | |
| clicked_node = n; break | |
| if clicked_node is not None: | |
| self.drag_mode = "NODE"; self.drag_data = clicked_node | |
| label = self.G.nodes[clicked_node].get('label', '') | |
| if self.click_callback: self.click_callback(label) | |
| else: | |
| self.drag_mode = "PAN"; self.drag_data = (mx, my) | |
| def on_mouse_drag(self, event): | |
| mx, my = event.x, event.y | |
| if self.drag_mode == "NODE": | |
| wx, wy = self.to_world(mx, my) | |
| self.G.nodes[self.drag_data]['pos'] = (wx, wy) | |
| self.redraw() | |
| if self.redraw_callback: self.redraw_callback() | |
| elif self.drag_mode == "PAN": | |
| start_x, start_y = self.drag_data | |
| dx = mx - start_x; dy = my - start_y | |
| self.offset_x += dx; self.offset_y += dy | |
| self.drag_data = (mx, my) | |
| self.redraw() | |
| def on_mouse_up(self, event): | |
| self.drag_mode = None; self.drag_data = None | |
| class CreateToolTip(object): | |
| """ | |
| create a tooltip for a given widget | |
| """ | |
| def __init__(self, widget, text='widget info'): | |
| self.waittime = 500 # miliseconds | |
| self.wraplength = 180 # pixels | |
| self.widget = widget | |
| self.text = text | |
| self.widget.bind("<Enter>", self.enter) | |
| self.widget.bind("<Leave>", self.leave) | |
| self.widget.bind("<ButtonPress>", self.leave) | |
| self.id = None | |
| self.tw = None | |
| def enter(self, event=None): | |
| self.schedule() | |
| def leave(self, event=None): | |
| self.unschedule() | |
| self.hidetip() | |
| def schedule(self): | |
| self.unschedule() | |
| self.id = self.widget.after(self.waittime, self.showtip) | |
| def unschedule(self): | |
| id = self.id | |
| self.id = None | |
| if id: | |
| self.widget.after_cancel(id) | |
| def showtip(self, event=None): | |
| x = y = 0 | |
| x, y, cx, cy = self.widget.bbox("insert") | |
| x += self.widget.winfo_rootx() + 25 | |
| y += self.widget.winfo_rooty() + 20 | |
| # creates a toplevel window | |
| self.tw = tk.Toplevel(self.widget) | |
| # Leaves only the label and removes the app window | |
| self.tw.wm_overrideredirect(True) | |
| self.tw.wm_geometry("+%d+%d" % (x, y)) | |
| label = tk.Label(self.tw, text=self.text, justify='left', | |
| background="#ffffe0", relief='solid', borderwidth=1, | |
| font=("tahoma", "8", "normal")) | |
| label.pack(ipadx=1) | |
| def hidetip(self): | |
| tw = self.tw | |
| self.tw = None | |
| if tw: | |
| tw.destroy() |