SalHargis commited on
Commit
29ccc00
·
verified ·
1 Parent(s): fef6880

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +424 -1091
app.py CHANGED
@@ -1,1135 +1,468 @@
1
- import tkinter as tk
2
- from tkinter import simpledialog, messagebox, Toplevel, filedialog, ttk
3
  import networkx as nx
4
- import math
5
- import json
6
  import random
7
- from PIL import ImageGrab
 
 
8
 
9
- # external modules / files from project
10
  import config
11
  from utils import calculate_metric
12
- from components import InteractiveComparisonPanel, CreateToolTip
13
  import metric_visualizations
14
 
15
- # descriptions of metrics (hover-over-able)
 
16
  METRIC_DESCRIPTIONS = {
17
- "Density": "The ratio of actual connections to potential connections.\nHigh density = highly connected.",
18
- "Cyclomatic Number": "The number of fundamental independent loops.\nMeasures the structural complexity of feedback.",
19
- "Global Efficiency": "A measure (0.0 - 1.0) of how easily information flows across the network.\nHigher is better for connectivity.",
20
- "Supportive Gain": "The amount of efficiency provided specifically by 'Soft' edges.\nHigh gain = Critical reliance on soft interdependencies.",
21
- "Brittleness Ratio": "The balance of Supportive (Soft) vs. Essential (Hard) edges.\nLow soft count may indicate a brittle, rigid system.",
22
- "Critical Vulnerability": "Checks if the 'Hard' skeleton of the graph is connected.\n'Fractured' means the system breaks if soft links fail.",
23
- "Interdependence": "The percentage of edges that cross between different agents.\nHigh interdependence = High requirement for collaboration.",
24
- "Total Cycles": "The total count of all feedback loops in the system.\nIndicates potential for recirculation or resonance.",
25
- "Avg Cycle Length": "The average number of steps in a feedback loop.\nLong loops = delayed feedback.",
26
- "Modularity": "How well the system divides into distinct, isolated groups (modules).\nHigh modularity = Low coupling between groups.",
27
- "Functional Redundancy": "The average number of agents assigned to each function.\n>1.0 implies backup capacity exists.",
28
- "Agent Criticality": "The agent with the most sole-authority tasks.\nLoss of this agent may cause most disruption.",
29
- "Collaboration Ratio": "The percentage of functions that have shared authority.\nCould be a measure of system flexibility."
30
  }
31
 
32
- class GraphBuilderApp:
33
- def __init__(self, root):
34
- self.root = root
35
- self.root.title("Interactive JSAT")
36
- self.root.geometry("1400x900")
37
-
38
- # Backend Data
39
  self.G = nx.DiGraph()
40
- self.saved_archs = {}
 
41
  self.undo_stack = []
42
  self.redo_stack = []
43
-
44
- # State
45
- self.selected_node = None
46
- self.inspected_node = None
47
- self.drag_node = None
48
- self.drag_start_pos = None
49
- self.is_dragging = False
50
- self.pre_drag_graph_state = None
51
- self.sidebar_drag_data = None
52
- self.current_highlights = []
53
- self.active_vis_mode = None
54
-
55
- # View Settings
56
- self.zoom = 1.0
57
- self.offset_x = 0
58
- self.offset_y = 0
59
- self.pan_start = None
60
-
61
- # Mode Settings
62
- self.mode = "SELECT"
63
- self.mode_buttons = {}
64
- self.view_mode = config.VIEW_MODE_FREE
65
- self.edge_view_mode = "ALL"
66
-
67
- self.agents = config.DEFAULT_AGENTS.copy()
68
-
69
- self.setup_ui()
70
-
71
- def setup_ui(self):
72
- # Toolbar
73
- toolbar_frame = tk.Frame(self.root, bd=1, relief=tk.RAISED)
74
- toolbar_frame.pack(side=tk.TOP, fill=tk.X)
75
- self.build_toolbar(toolbar_frame)
76
-
77
- # Main Layout
78
- main_container = tk.Frame(self.root)
79
- main_container.pack(fill=tk.BOTH, expand=True)
80
-
81
- # 1. Dashboard (Right Side)
82
- self.dashboard_frame = tk.Frame(main_container, width=350, bg="#f0f0f0", bd=1, relief=tk.SUNKEN)
83
- self.dashboard_frame.pack(side=tk.RIGHT, fill=tk.Y)
84
- self.dashboard_frame.pack_propagate(False)
85
-
86
- # 2. Canvas (Left Side)
87
- self.canvas = tk.Canvas(main_container, bg="white")
88
- self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
89
-
90
- # Canvas Bindings
91
- self.canvas.bind("<Button-1>", self.on_mouse_down)
92
- self.canvas.bind("<B1-Motion>", self.on_mouse_drag)
93
- self.canvas.bind("<ButtonRelease-1>", self.on_mouse_up)
94
- self.canvas.bind("<Double-Button-1>", self.on_double_click)
95
-
96
- # Zooming
97
- self.canvas.bind("<MouseWheel>", self.on_zoom)
98
- self.canvas.bind("<Button-4>", lambda e: self.on_zoom(e, 1))
99
- self.canvas.bind("<Button-5>", lambda e: self.on_zoom(e, -1))
100
-
101
- # 3. Dashboard Content
102
- self._setup_dashboard_structure()
103
 
104
- # Footer Status
105
- self.status_label = tk.Label(self.root, text="Mode: Select & Inspect", bd=1, relief=tk.SUNKEN, anchor=tk.W)
106
- self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
107
-
108
- self.redraw()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
- def _setup_dashboard_structure(self):
111
- """Initializes static dashboard containers."""
112
- tk.Label(self.dashboard_frame, text="Network Dashboard", font=("Arial", 14, "bold"),
113
- bg="#4a4a4a", fg="white", pady=8).pack(fill=tk.X)
 
 
 
114
 
115
- self.inspector_frame = tk.Frame(self.dashboard_frame, bg="#fff8e1", bd=2, relief=tk.GROOVE)
116
- self.inspector_frame.pack(fill=tk.X, padx=5, pady=5)
 
117
 
118
- # Scrollable Area
119
- self.scroll_canvas = tk.Canvas(self.dashboard_frame, bg="#f0f0f0")
120
- self.scrollbar = tk.Scrollbar(self.dashboard_frame, orient="vertical", command=self.scroll_canvas.yview)
121
- self.scrollable_content = tk.Frame(self.scroll_canvas, bg="#f0f0f0")
122
-
123
- self.scroll_canvas.create_window((0, 0), window=self.scrollable_content, anchor="nw", width=330)
124
- self.scroll_canvas.configure(yscrollcommand=self.scrollbar.set)
125
 
126
- self.scroll_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
127
- self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
128
- self.scrollable_content.bind("<Configure>", lambda e: self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all")))
129
-
130
- # Coordinate Transforms
131
-
132
- def to_screen(self, wx, wy):
133
- return (wx * self.zoom) + self.offset_x, (wy * self.zoom) + self.offset_y
134
 
135
- def to_world(self, sx, sy):
136
- return (sx - self.offset_x) / self.zoom, (sy - self.offset_y) / self.zoom
 
137
 
138
- def get_draw_pos(self, node_id):
139
- """Calculates WORLD coordinates based on current view mode."""
140
- data = self.G.nodes[node_id]
141
- raw_x, raw_y = data.get('pos', (100, 100))
142
-
143
- # If dragging in JSAT mode, show raw position until dropped
144
- if self.view_mode == config.VIEW_MODE_JSAT and self.drag_node == node_id and self.is_dragging:
145
- return raw_x, raw_y
146
-
147
- if self.view_mode == config.VIEW_MODE_FREE:
148
- return raw_x, raw_y
149
-
150
- # JSAT Mode: Snap Y to layer
151
- layer = self.get_node_layer(data)
152
- return raw_x, config.JSAT_LAYERS[layer]
153
 
154
- def get_node_layer(self, data):
155
- if 'layer' in data and data['layer'] in config.JSAT_LAYERS:
156
- return data['layer']
157
- return "Base Environment" if data.get('type') == "Resource" else "Distributed Work"
158
-
159
- # Interaction Logic
160
-
161
- def on_zoom(self, event, direction=None):
162
- factor = 1.1 if (direction or event.delta) > 0 else 0.9
163
- self.zoom *= factor
164
- self.redraw()
165
-
166
- def on_mouse_down(self, event):
167
- # 1. Check for Node Click
168
- clicked_node = self._get_node_at(event.x, event.y)
169
-
170
- if clicked_node is not None:
171
- self._handle_node_press(clicked_node, event)
172
- else:
173
- self._handle_background_press(event)
174
-
175
- def _handle_node_press(self, node_id, event):
176
- self.pre_drag_graph_state = self.G.copy()
177
- self.drag_node = node_id
178
- self.drag_start_pos = (event.x, event.y)
179
- self.is_dragging = False
180
-
181
- def _handle_background_press(self, event):
182
- if self.mode == "DELETE":
183
- clicked_edge = self.find_edge_at(event.x, event.y)
184
- if clicked_edge:
185
- self.save_state()
186
- self.G.remove_edge(*clicked_edge)
187
- self.redraw()
188
- return
189
 
190
- # Prepare for Pan or Add
191
- self.inspected_node = None
192
- self.pan_start = (event.x, event.y)
193
- self.is_dragging = False
194
- self.redraw()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
- def on_mouse_drag(self, event):
197
- if self.drag_node is not None:
198
- if not self.is_dragging and math.hypot(event.x - self.drag_start_pos[0], event.y - self.drag_start_pos[1]) > 5:
199
- self.is_dragging = True
200
-
201
- if self.is_dragging:
202
- wx, wy = self.to_world(event.x, event.y)
203
- self.G.nodes[self.drag_node]['pos'] = (wx, wy)
204
- self.redraw()
205
 
206
- elif self.pan_start is not None:
207
- if not self.is_dragging and math.hypot(event.x - self.pan_start[0], event.y - self.pan_start[1]) > 5:
208
- self.is_dragging = True
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
- if self.is_dragging:
211
- dx, dy = event.x - self.pan_start[0], event.y - self.pan_start[1]
212
- self.offset_x += dx
213
- self.offset_y += dy
214
- self.pan_start = (event.x, event.y)
215
- self.redraw()
216
-
217
- def on_mouse_up(self, event):
218
- if self.drag_node is not None:
219
- if self.is_dragging:
220
- self._finalize_drag(event)
221
- else:
222
- self.handle_click(self.drag_node)
223
- self.drag_node = None
224
- self.is_dragging = False
225
 
226
- elif self.pan_start is not None:
227
- if not self.is_dragging and self.mode in ["ADD_FUNC", "ADD_RES"]:
228
- self.save_state()
229
- wx, wy = self.to_world(event.x, event.y)
230
- self.add_node(wx, wy)
231
- self.pan_start = None
232
- self.is_dragging = False
233
 
234
- def _finalize_drag(self, event):
235
- self.save_state(state=self.pre_drag_graph_state) # save previous state before modification
236
-
237
- # JSAT Snapping Logic
238
- if self.view_mode == config.VIEW_MODE_JSAT:
239
- _, world_y = self.to_world(event.x, event.y)
240
- new_layer = self.get_layer_from_y(world_y)
241
- if new_layer:
242
- self.G.nodes[self.drag_node]['layer'] = new_layer
243
- # Visual snap logic handled in get_draw_pos via data update
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- self.redraw()
 
 
246
 
247
- def handle_click(self, node_id):
248
- self.inspected_node = node_id
249
-
250
- if self.mode == "SELECT":
251
- self.redraw()
252
-
253
- elif self.mode == "DELETE":
254
- self.save_state()
255
- self.G.remove_node(node_id)
256
- self.inspected_node = None
257
- self.redraw()
258
-
259
- elif self.mode == "ADD_EDGE":
260
- self._handle_add_edge(node_id)
261
-
262
- def _handle_add_edge(self, node_id):
263
- if not self.selected_node:
264
- self.selected_node = node_id
265
- self.redraw()
266
- return
267
-
268
- if self.selected_node == node_id:
269
- return
270
-
271
- # Enforce Alternating connection types (Func <-> Res, no fun-->fun or res-->res)
272
- type_start = self.G.nodes[self.selected_node].get('type')
273
- type_end = self.G.nodes[node_id].get('type')
274
-
275
- if type_start == type_end:
276
- messagebox.showerror("Connection Error", f"Cannot connect {type_start} to {type_end}.\nConnections must alternate (Func <-> Res).")
277
- else:
278
- is_hard = messagebox.askyesno("Interdependency Type", "Is this a HARD constraint?\n\nYes = Essential (Hard)\nNo = Supportive (Soft)")
279
- edge_type = config.EDGE_TYPE_HARD if is_hard else config.EDGE_TYPE_SOFT
280
-
281
- self.save_state()
282
- self.G.add_edge(self.selected_node, node_id, type=edge_type)
283
-
284
- self.selected_node = None
285
- self.redraw()
286
-
287
- # Drawing Logic
288
-
289
- def redraw(self):
290
- self.canvas.delete("all")
291
-
292
- # 1. Background Layers
293
- if self.view_mode == config.VIEW_MODE_JSAT:
294
- self._draw_layer_lines()
295
-
296
- # 2. Highlights (Behind nodes)
297
- self._draw_highlights()
298
-
299
- # 3. Graph Content
300
- self._draw_edges()
301
- self._draw_nodes()
302
-
303
- # 4. Refresh Dashboard (Consider optimizing this to not run on every drag)
304
- if not self.is_dragging:
305
- self.rebuild_dashboard()
306
-
307
- def _draw_layer_lines(self):
308
- for layer_name in config.LAYER_ORDER:
309
- world_y = config.JSAT_LAYERS[layer_name]
310
- _, screen_y = self.to_screen(0, world_y)
311
- self.canvas.create_line(0, screen_y, 20000, screen_y, fill="#ddd", dash=(4, 4))
312
- self.canvas.create_text(10, screen_y - 10, text=layer_name, anchor="w", fill="#888", font=("Arial", 8, "italic"))
313
 
314
- def _draw_highlights(self):
315
- if not self.current_highlights: return
316
-
317
- edge_counts = {}
318
- for h in self.current_highlights:
319
- color = h.get('color', 'yellow')
320
- width = h.get('width', 8) * self.zoom
321
-
322
- # Draw Nodes
323
- for n in h.get('nodes', []):
324
- wx, wy = self.get_draw_pos(n)
325
- sx, sy = self.to_screen(wx, wy)
326
- rad = (config.NODE_RADIUS * self.zoom) + (width/2)
327
- self.canvas.create_oval(sx-rad, sy-rad, sx+rad, sy+rad, fill=color, outline=color)
328
-
329
- # Draw Edges (with offset for overlaps)
330
- for u, v in h.get('edges', []):
331
- edge_key = tuple(sorted((u, v)))
332
- count = edge_counts.get(edge_key, 0)
333
- edge_counts[edge_key] = count + 1
 
 
 
 
 
 
334
 
335
- wx1, wy1 = self.get_draw_pos(u)
336
- wx2, wy2 = self.get_draw_pos(v)
337
- sx1, sy1 = self.to_screen(wx1, wy1)
338
- sx2, sy2 = self.to_screen(wx2, wy2)
 
 
 
339
 
340
- # Offset logic
341
- offset_step = width / 2
342
- current_offset = (count * width) - offset_step
343
- dx, dy = sx2 - sx1, sy2 - sy1
344
- length = math.hypot(dx, dy)
345
- if length == 0: continue
 
 
 
 
 
346
 
347
- nx_vec, ny_vec = -dy / length, dx / length
348
- os_x, os_y = nx_vec * current_offset, ny_vec * current_offset
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
- self.canvas.create_line(sx1+os_x, sy1+os_y, sx2+os_x, sy2+os_y,
351
- fill=color, width=width, capstyle=tk.ROUND)
352
-
353
- def _draw_edges(self):
354
- r = config.NODE_RADIUS * self.zoom
355
-
356
- for u, v, d in self.G.edges(data=True):
357
- e_type = d.get('type', config.EDGE_TYPE_HARD)
358
-
359
- # Filter
360
- if self.edge_view_mode != "ALL" and e_type != self.edge_view_mode:
361
- continue
362
-
363
- # Style
364
- is_soft = (e_type == config.EDGE_TYPE_SOFT)
365
- color = config.SOFT_EDGE_COLOR if is_soft else config.HARD_EDGE_COLOR
366
- dash = config.SOFT_EDGE_DASH if is_soft else None
367
- width = (1.5 if is_soft else 2.0) * self.zoom
368
-
369
- # Coordinates
370
- wx1, wy1 = self.get_draw_pos(u)
371
- wx2, wy2 = self.get_draw_pos(v)
372
- sx1, sy1 = self.to_screen(wx1, wy1)
373
- sx2, sy2 = self.to_screen(wx2, wy2)
374
-
375
- dx, dy = sx2 - sx1, sy2 - sy1
376
- dist = math.hypot(dx, dy)
377
- if dist == 0: continue
378
-
379
- # Stop line at node edge
380
- gap = r + 2
381
- tx = sx2 - (dx/dist)*gap
382
- ty = sy2 - (dy/dist)*gap
383
-
384
- self.canvas.create_line(sx1, sy1, tx, ty, arrow=tk.LAST, width=width, fill=color, dash=dash)
385
-
386
- def _draw_nodes(self):
387
- r = config.NODE_RADIUS * self.zoom
388
- font_size = max(15, int(10 * self.zoom))
389
-
390
- for n, d in self.G.nodes(data=True):
391
- wx, wy = self.get_draw_pos(n)
392
- sx, sy = self.to_screen(wx, wy)
393
-
394
- ag_list = d.get('agent', ["Unassigned"])
395
- if not isinstance(ag_list, list): ag_list = [ag_list]
396
-
397
- # Outline Style
398
- outline, width = "black", 1
399
- if n == self.selected_node:
400
- outline, width = "blue", 3
401
- elif n == self.inspected_node:
402
- outline, width = "orange", 3
403
-
404
- # Shape Render
405
- if d.get('type') == "Function":
406
- self._draw_rect_node(sx, sy, r, ag_list, outline, width)
407
- else:
408
- self._draw_circle_node(sx, sy, r, ag_list, outline, width)
409
 
410
- # Label
411
- label_offset = r + (5 * self.zoom)
412
- self.canvas.create_text(sx, sy-label_offset, text=d.get('label',''), font=("Arial", font_size, "bold"), anchor="s")
413
-
414
- def _draw_rect_node(self, sx, sy, r, agents, outline, width):
415
- total_w = (r * 2)
416
- strip_w = total_w / len(agents)
417
- start_x = sx - r
418
-
419
- for i, ag in enumerate(agents):
420
- fill = self.agents.get(ag, "white")
421
- x1 = start_x + (i * strip_w)
422
- x2 = start_x + ((i + 1) * strip_w)
423
- self.canvas.create_rectangle(x1, sy-r, x2, sy+r, fill=fill, outline="")
424
-
425
- self.canvas.create_rectangle(sx-r, sy-r, sx+r, sy+r, fill="", outline=outline, width=width)
426
-
427
- def _draw_circle_node(self, sx, sy, r, agents, outline, width):
428
- if len(agents) == 1:
429
- fill = self.agents.get(agents[0], "white")
430
- self.canvas.create_oval(sx-r, sy-r, sx+r, sy+r, fill=fill, outline=outline, width=width)
431
- else:
432
- extent = 360 / len(agents)
433
- start_angle = 90
434
- for ag in agents:
435
- fill = self.agents.get(ag, "white")
436
- self.canvas.create_arc(sx-r, sy-r, sx+r, sy+r, start=start_angle, extent=extent, fill=fill, outline="")
437
- start_angle += extent
438
- self.canvas.create_oval(sx-r, sy-r, sx+r, sy+r, fill="", outline=outline, width=width)
439
-
440
- # Dashboard Builders
441
-
442
- def rebuild_dashboard(self):
443
- # Preserve Scroll
444
- try: scroll_pos = self.scroll_canvas.yview()[0]
445
- except: scroll_pos = 0.0
446
-
447
- # Clear
448
- for w in self.inspector_frame.winfo_children(): w.destroy()
449
- for w in self.scrollable_content.winfo_children(): w.destroy()
450
-
451
- # Build Sections
452
- self._build_stats_section()
453
- self._build_agent_section()
454
- self._build_inspector_section()
455
-
456
- # Restore Scroll
457
- self.scrollable_content.update_idletasks()
458
- self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all"))
459
- self.scroll_canvas.yview_moveto(scroll_pos)
460
-
461
- def _build_stats_section(self):
462
- tk.Label(self.scrollable_content, text="Network Statistics", font=("Arial", 14, "bold"), bg="#f0f0f0").pack(fill=tk.X, pady=(10, 5))
463
- stats_frame = tk.Frame(self.scrollable_content, bg="white", bd=1, relief=tk.SOLID)
464
- stats_frame.pack(fill=tk.X, padx=5)
465
-
466
- def add_row(text, color="black", callback=None, metric_key=None):
467
- lbl = tk.Label(stats_frame, text=text, bg="white", fg=color)
468
- if callback:
469
- lbl.config(cursor="hand2")
470
- lbl.bind("<Button-1>", lambda e: callback())
471
- lbl.pack(anchor="w", padx=5)
472
- if metric_key and metric_key in METRIC_DESCRIPTIONS:
473
- CreateToolTip(lbl, METRIC_DESCRIPTIONS[metric_key])
474
-
475
- # Standard Metrics
476
- standard_metrics = [
477
- ("Density", "Density"),
478
- ("Cyclomatic Number", "Cyclomatic Number"),
479
- ("Global Efficiency", "Global Efficiency"),
480
- ("Supportive Gain", "Supportive Gain"),
481
- ("Soft/Hard Ratio", "Brittleness Ratio"),
482
- ("Critical Vulnerability", "Critical Vulnerability"),
483
- ("Func. Redundancy", "Functional Redundancy"),
484
- ("Agent Criticality", "Agent Criticality"),
485
- ("Collab. Ratio", "Collaboration Ratio")
486
- ]
487
-
488
- for label, key in standard_metrics:
489
- val = calculate_metric(self.G, key)
490
- add_row(f"{label}: {val}", metric_key=key)
491
-
492
- # Interactive Metrics
493
- add_row(f"Interdependence: {calculate_metric(self.G, 'Interdependence')}", "blue",
494
- lambda: self.trigger_visual_analytics("interdependence"), "Interdependence")
495
-
496
- add_row(f"Total Cycles: {calculate_metric(self.G, 'Total Cycles')}", "blue",
497
- lambda: self.trigger_visual_analytics("cycles"), "Total Cycles")
498
-
499
- # Metric: Cycles List
500
- cycles = list(nx.simple_cycles(self.G))
501
- avg_len = sum(len(c) for c in cycles) / len(cycles) if cycles else 0.0
502
- add_row(f"Avg Cycle Length: {avg_len:.2f}", metric_key="Avg Cycle Length")
503
-
504
- if cycles:
505
- items = [{'label': len(c), 'tooltip': f"Cycle {i+1}:\n" + " -> ".join([str(self.G.nodes[n].get('label', n)) for n in c])} for i, c in enumerate(cycles)]
506
- self._create_scrollable_list_ui(stats_frame, "", items, ["blue"], lambda idx: self.trigger_single_cycle_vis(idx)).pack(fill=tk.X, padx=5, pady=2)
507
-
508
- # Metric: Modularity
509
- try:
510
- mod_val = calculate_metric(self.G, 'Modularity')
511
- add_row(f"Modularity: {mod_val}", "blue", lambda: self.trigger_visual_analytics("modularity"), "Modularity")
512
-
513
- comms = sorted(nx.community.greedy_modularity_communities(self.G.to_undirected()), key=len, reverse=True)
514
- if comms:
515
- mod_items = [{'label': len(c), 'tooltip': f"Group {i+1}:\n" + ", ".join([str(self.G.nodes[n].get('label', n)) for n in c])} for i, c in enumerate(comms)]
516
- self._create_scrollable_list_ui(stats_frame, "", mod_items, ["blue"], lambda idx: self.trigger_single_modularity_vis(idx)).pack(fill=tk.X, padx=5, pady=2)
517
- except:
518
- add_row("Modularity: Err")
519
-
520
- def _build_agent_section(self):
521
- tk.Label(self.scrollable_content, text="Agent Overview", font=("Arial", 14, "bold"), bg="#f0f0f0").pack(fill=tk.X, pady=(15, 2))
522
-
523
- ctrl = tk.Frame(self.scrollable_content, bg="#e0e0e0", bd=1, relief=tk.RAISED)
524
- ctrl.pack(fill=tk.X, padx=5, pady=5)
525
- tk.Button(ctrl, text="New Agent", command=self.create_agent, bg="white").pack(pady=5)
526
-
527
- # Group Nodes by Agent
528
- agent_map = {name: [] for name in self.agents}
529
- for n, d in self.G.nodes(data=True):
530
- ag_list = d.get('agent', ["Unassigned"])
531
- if not isinstance(ag_list, list): ag_list = [ag_list]
532
- for ag in ag_list:
533
- target = ag if ag in agent_map else "Unassigned"
534
- if target not in agent_map: agent_map[target] = []
535
- agent_map[target].append(n)
536
-
537
- # Create UI
538
- for name, color in self.agents.items():
539
- af = tk.Frame(self.scrollable_content, bg="#e0e0e0", bd=1, relief=tk.RAISED)
540
- af.pack(fill=tk.X, pady=2, padx=5)
541
- af.agent_name = name # For drag-n-drop
542
-
543
- # Header
544
- hf = tk.Frame(af, bg="#e0e0e0"); hf.pack(fill=tk.X)
545
- tk.Label(hf, bg=color, width=3).pack(side=tk.LEFT, padx=5, fill=tk.X)
546
- lbl = tk.Label(hf, text=name, bg="#e0e0e0", font=("Arial", 10, "bold"))
547
- lbl.pack(side=tk.LEFT, fill=tk.X)
548
- lbl.bind("<Button-1>", lambda e, a=name: self.edit_agent(a))
549
 
550
- # Nodes
551
- nodes = agent_map.get(name, [])
552
- if nodes:
553
- for nid in nodes:
554
- lbl_text = self.G.nodes[nid].get('label', str(nid))
555
- b = tk.Button(af, text=f"• {lbl_text}", anchor="w", bg="white", relief=tk.FLAT, font=("Arial", 9))
556
- b.config(command=lambda n=nid: self.handle_click(n))
557
- b.pack(fill=tk.X, padx=10, pady=1)
558
-
559
- # Sidebar Drag Logic
560
- b.bind("<Button-1>", lambda e, n=nid: self.on_sidebar_node_press(e, n))
561
- b.bind("<ButtonRelease-1>", self.on_sidebar_node_release)
562
- else:
563
- tk.Label(af, text="(Empty)", bg="#e0e0e0", fg="#666", font=("Arial", 8, "italic")).pack(anchor="w", padx=10)
564
-
565
- def _build_inspector_section(self):
566
- if self.inspected_node is None or not self.G.has_node(self.inspected_node):
567
- tk.Label(self.inspector_frame, text="(Select a node to inspect)", bg="#fff8e1", fg="#888").pack(pady=5)
568
- return
569
-
570
- d = self.G.nodes[self.inspected_node]
571
- tk.Label(self.inspector_frame, text="SELECTED NODE INSPECTOR", bg="#fff8e1", font=("Arial", 10, "bold")).pack(pady=2)
572
-
573
- # Details
574
- tk.Label(self.inspector_frame, text=f"ID: {self.inspected_node} | Lbl: {d.get('label')}", bg="#fff8e1", font=("Arial", 9, "bold")).pack(anchor="w", padx=5)
575
-
576
- # Layer Control
577
- r2 = tk.Frame(self.inspector_frame, bg="#fff8e1"); r2.pack(fill=tk.X, padx=5, pady=2)
578
- tk.Label(r2, text="Layer:", bg="#fff8e1").pack(side=tk.LEFT)
579
-
580
- current_layer = self.get_node_layer(d)
581
- layer_var = tk.StringVar(value=current_layer)
582
- layer_box = ttk.Combobox(r2, textvariable=layer_var, values=config.LAYER_ORDER, state="readonly", width=18)
583
- layer_box.pack(side=tk.LEFT, padx=5)
584
-
585
- def on_layer_change(event):
586
- self.save_state()
587
- self.G.nodes[self.inspected_node]['layer'] = layer_var.get()
588
- self.redraw()
589
- layer_box.bind("<<ComboboxSelected>>", on_layer_change)
590
-
591
- # Metrics
592
- def safe_metric(func, **kwargs):
593
- try: return f"{func(self.G, **kwargs)[self.inspected_node]:.3f}"
594
- except: return "0.000"
595
-
596
- stats = (f"In-Degree: {self.G.in_degree(self.inspected_node)}\n"
597
- f"Out-Degree: {self.G.out_degree(self.inspected_node)}\n"
598
- f"Degree Cent.: {safe_metric(nx.degree_centrality)}\n"
599
- f"Eigenvector: {safe_metric(nx.eigenvector_centrality, max_iter=100, tol=1e-04)}\n"
600
- f"Betweenness: {safe_metric(nx.betweenness_centrality)}")
601
-
602
- tk.Label(self.inspector_frame, text=stats, bg="#fff8e1", justify=tk.LEFT, font=("Consolas", 13)).pack(anchor="w", padx=5, pady=5)
603
-
604
- def _create_scrollable_list_ui(self, parent, label_text, items, colors, click_callback, label_click_callback=None):
605
- container = tk.Frame(parent, bg=parent.cget('bg'))
606
-
607
- if not items:
608
- tk.Label(container, text=f"{label_text} (None)", bg=parent.cget('bg')).pack(anchor="w")
609
- return container
610
-
611
- lbl = tk.Label(container, text=f"{label_text} [", bg=parent.cget('bg'))
612
- lbl.pack(side=tk.LEFT, anchor="n", pady=2)
613
-
614
- if label_click_callback:
615
- lbl.config(fg="blue", cursor="hand2")
616
- lbl.bind("<Button-1>", lambda e: label_click_callback())
617
-
618
- scroll_wrapper = tk.Frame(container, bg="white")
619
- scroll_wrapper.pack(side=tk.LEFT, fill=tk.X, expand=True, anchor="n")
620
-
621
- h_scroll = tk.Scrollbar(scroll_wrapper, orient=tk.HORIZONTAL)
622
- h_scroll.pack(side=tk.BOTTOM, fill=tk.X)
623
-
624
- h_canvas = tk.Canvas(scroll_wrapper, height=25, bg="white", highlightthickness=0, xscrollcommand=h_scroll.set)
625
- h_canvas.pack(side=tk.TOP, fill=tk.X, expand=True)
626
- h_scroll.config(command=h_canvas.xview)
627
-
628
- inner_frame = tk.Frame(h_canvas, bg="white")
629
- h_canvas.create_window((0, 0), window=inner_frame, anchor="nw")
630
-
631
- for i, item in enumerate(items):
632
- txt_color = colors[i % len(colors)]
633
- btn = tk.Label(inner_frame, text=str(item['label']), font=("Arial", 14, "bold"),
634
- fg=txt_color, cursor="hand2", bg="white")
635
- btn.pack(side=tk.LEFT)
636
- btn.bind("<Button-1>", lambda e, idx=i: click_callback(idx))
637
-
638
- if item.get('tooltip'): CreateToolTip(btn, text=item['tooltip'])
639
- if i < len(items) - 1: tk.Label(inner_frame, text=", ", bg="white").pack(side=tk.LEFT)
640
-
641
- tk.Label(inner_frame, text=" ]", bg="white").pack(side=tk.LEFT)
642
-
643
- inner_frame.update_idletasks()
644
- h_canvas.config(scrollregion=h_canvas.bbox("all"))
645
-
646
- h_canvas.bind("<MouseWheel>", lambda e: h_canvas.xview_scroll(int(-1*(e.delta/120)), "units"))
647
- return container
648
-
649
- # Mode & Toolbar Logic
650
-
651
- def build_toolbar(self, parent):
652
- r1 = tk.Frame(parent); r1.pack(fill=tk.X, pady=2)
653
-
654
- # History
655
- tk.Button(r1, text="↶", command=self.undo, width=2).pack(side=tk.LEFT, padx=1)
656
- tk.Button(r1, text="↷", command=self.redo, width=2).pack(side=tk.LEFT, padx=1)
657
-
658
- # View Filters
659
- tk.Label(r1, text=" | Edges: ", fg="#555", font=("Arial", 15)).pack(side=tk.LEFT)
660
- for name, mode in [("All", "ALL"), ("Required", config.EDGE_TYPE_HARD), ("Assistive", config.EDGE_TYPE_SOFT)]:
661
- tk.Button(r1, text=name, command=lambda m=mode: self.set_edge_view(m), font=("Arial", 12)).pack(side=tk.LEFT, padx=1)
662
-
663
- self.view_btn = tk.Button(r1, text="👁 View: Free", command=self.toggle_view, bg="#e1bee7", font=("Arial", 12, "bold"))
664
- self.view_btn.pack(side=tk.LEFT, padx=10)
665
-
666
- # Modes
667
- for k, txt in [("SELECT", "➤ Select"), ("ADD_FUNC", "Add Func"), ("ADD_RES", "Add Res"), ("ADD_EDGE", "Connect"), ("DELETE", "Delete")]:
668
- self.create_mode_button(r1, k, txt)
669
-
670
- # Row 2: File Ops
671
- r2 = tk.Frame(parent); r2.pack(fill=tk.X, pady=2)
672
- tk.Label(r2, text="| RAM:", fg="#888").pack(side=tk.LEFT, padx=5)
673
- tk.Button(r2, text="Store Architecture", command=self.save_architecture_internal).pack(side=tk.LEFT, padx=2)
674
- tk.Button(r2, text="Compare", command=self.open_comparison_dialog, bg="#ffd700").pack(side=tk.LEFT, padx=5)
675
-
676
- tk.Label(r2, text="| Disk:", fg="#888").pack(side=tk.LEFT, padx=5)
677
- tk.Button(r2, text="Save JSON", command=self.initiate_save_json).pack(side=tk.LEFT, padx=2)
678
- tk.Button(r2, text="Open JSON", command=self.load_from_json).pack(side=tk.LEFT, padx=2)
679
- tk.Button(r2, text="📷 PNG", command=self.export_as_image, bg="#e0e0e0").pack(side=tk.LEFT, padx=2)
680
-
681
- self.update_mode_indicator()
682
-
683
- def set_edge_view(self, mode):
684
- self.edge_view_mode = mode
685
- self.redraw()
686
-
687
- def toggle_view(self):
688
- is_free = (self.view_mode == config.VIEW_MODE_FREE)
689
- self.view_mode = config.VIEW_MODE_JSAT if is_free else config.VIEW_MODE_FREE
690
- self.view_btn.config(text="👁 View: JSAT Layers" if is_free else "👁 View: Free")
691
- self.redraw()
692
-
693
- def create_mode_button(self, parent, mode_key, text):
694
- btn = tk.Button(parent, text=text, command=lambda: self.set_mode(mode_key))
695
- btn.pack(side=tk.LEFT, padx=2)
696
- self.mode_buttons[mode_key] = btn
697
-
698
- def set_mode(self, m):
699
- self.mode = m
700
- self.selected_node = None
701
- self.status_label.config(text=f"Mode: {m}")
702
- self.update_mode_indicator()
703
- self.redraw()
704
-
705
- def update_mode_indicator(self):
706
- for mode_key, btn in self.mode_buttons.items():
707
- is_active = (mode_key == self.mode)
708
- btn.config(bg="#87CEFA" if is_active else ("#ffcccc" if mode_key == "DELETE" else "#f0f0f0"),
709
- relief=tk.SUNKEN if is_active else tk.RAISED)
710
-
711
- # Node/Agent Helpers
712
-
713
- def assign_agent_logic(self, node_id, agent_name):
714
- current_data = self.G.nodes[node_id].get('agent', ["Unassigned"])
715
- if not isinstance(current_data, list): current_data = [current_data]
716
-
717
- if "Unassigned" in current_data and agent_name != "Unassigned":
718
- current_data.remove("Unassigned")
719
-
720
- if agent_name in current_data:
721
- current_data.remove(agent_name)
722
- if not current_data: current_data = ["Unassigned"]
723
- else:
724
- current_data.append(agent_name)
725
-
726
- self.G.nodes[node_id]['agent'] = current_data
727
-
728
- def on_double_click(self, event):
729
- # Check Node
730
- node = self._get_node_at(event.x, event.y)
731
- if node is not None:
732
- self.open_node_editor(node)
733
- return
734
-
735
- # Check Edge
736
- edge = self.find_edge_at(event.x, event.y)
737
- if edge:
738
- u, v = edge
739
- curr = self.G.edges[u, v].get('type', config.EDGE_TYPE_HARD)
740
- new_type = config.EDGE_TYPE_SOFT if curr == config.EDGE_TYPE_HARD else config.EDGE_TYPE_HARD
741
- self.save_state()
742
- self.G.edges[u, v]['type'] = new_type
743
- self.redraw()
744
-
745
- def _get_node_at(self, x, y):
746
- r_screen = config.NODE_RADIUS * self.zoom
747
- for n in self.G.nodes:
748
- wx, wy = self.get_draw_pos(n)
749
- sx, sy = self.to_screen(wx, wy)
750
- if math.hypot(x - sx, y - sy) <= r_screen:
751
- return n
752
- return None
753
-
754
- def open_node_editor(self, nid):
755
- win = Toplevel(self.root)
756
- win.title("Edit Node")
757
-
758
- tk.Label(win, text="Label:").pack()
759
- e_lbl = tk.Entry(win)
760
- e_lbl.insert(0, self.G.nodes[nid].get('label', ''))
761
- e_lbl.pack()
762
-
763
- def save():
764
- self.save_state()
765
- self.G.nodes[nid]['label'] = e_lbl.get()
766
- win.destroy()
767
- self.redraw()
768
-
769
- tk.Button(win, text="Save", command=save).pack(pady=10)
770
-
771
- def add_node(self, x, y):
772
- nid = (max(self.G.nodes)+1) if self.G.nodes else 0
773
- typ = "Function" if self.mode == "ADD_FUNC" else "Resource"
774
- self.G.add_node(nid, pos=(x, y), type=typ, agent="Unassigned",
775
- label=typ[0], layer=("Base Environment" if typ == "Resource" else "Distributed Work"))
776
- self.redraw()
777
-
778
- def create_agent(self):
779
- n = simpledialog.askstring("Input", "Name:")
780
- if n and n not in self.agents:
781
- c = simpledialog.askstring("Input", "Color:") or "grey"
782
- self.agents[n] = c
783
- self.rebuild_dashboard()
784
-
785
- def edit_agent(self, agent_name):
786
- win = Toplevel(self.root); win.title("Edit Agent"); win.geometry("250x220")
787
-
788
- tk.Label(win, text="Name:").pack(pady=(10, 0))
789
- ne = tk.Entry(win); ne.insert(0, agent_name); ne.pack()
790
- tk.Label(win, text="Color:").pack(pady=(10, 0))
791
- ce = tk.Entry(win); ce.insert(0, self.agents[agent_name]); ce.pack()
792
-
793
- def save():
794
- new_name, new_color = ne.get(), ce.get()
795
- if new_name and new_color:
796
- self.save_state()
797
- del self.agents[agent_name]
798
- self.agents[new_name] = new_color
799
-
800
- # Update nodes
801
- for n, d in self.G.nodes(data=True):
802
- ag = d.get('agent')
803
- if ag == agent_name: self.G.nodes[n]['agent'] = new_name # handle simple string
804
- elif isinstance(ag, list) and agent_name in ag: # handle list
805
- idx = ag.index(agent_name)
806
- ag[idx] = new_name
807
-
808
- self.redraw()
809
- win.destroy()
810
-
811
- def delete():
812
- if agent_name == "Unassigned": return
813
- if messagebox.askyesno("Delete", f"Delete '{agent_name}'?"):
814
- self.save_state()
815
- for n, d in self.G.nodes(data=True):
816
- ag = d.get('agent')
817
- if isinstance(ag, list):
818
- if agent_name in ag: ag.remove(agent_name)
819
- if not ag: self.G.nodes[n]['agent'] = ["Unassigned"]
820
- del self.agents[agent_name]
821
- self.redraw()
822
- win.destroy()
823
-
824
- tk.Button(win, text="Save", command=save, bg="#e1bee7").pack(pady=15, fill=tk.X, padx=20)
825
- tk.Button(win, text="Delete", command=delete, bg="#ffcccc").pack(pady=5, fill=tk.X, padx=20)
826
-
827
- # History & IO
828
-
829
- def save_state(self, state=None):
830
- snapshot = state if state else self.G.copy()
831
- self.undo_stack.append(snapshot)
832
- if len(self.undo_stack) > config.HISTORY_LIMIT: self.undo_stack.pop(0)
833
- self.redo_stack.clear()
834
 
835
- def undo(self):
836
- if self.undo_stack:
837
- self.redo_stack.append(self.G.copy())
838
- self.G = self.undo_stack.pop()
839
- self.redraw()
840
-
841
- def redo(self):
842
- if self.redo_stack:
843
- self.undo_stack.append(self.G.copy())
844
- self.G = self.redo_stack.pop()
845
- self.redraw()
846
-
847
- def export_as_image(self):
848
- fp = filedialog.asksaveasfilename(defaultextension=".png", filetypes=[("PNG", "*.png")])
849
- if not fp: return
850
- try:
851
- x, y = self.canvas.winfo_rootx(), self.canvas.winfo_rooty()
852
- w, h = self.canvas.winfo_width(), self.canvas.winfo_height()
853
- ImageGrab.grab(bbox=(x, y, x+w, y+h)).save(fp)
854
- messagebox.showinfo("Success", f"Saved to {fp}")
855
- except Exception as e:
856
- messagebox.showerror("Error", str(e))
857
 
858
- def initiate_save_json(self):
859
- fp = filedialog.asksaveasfilename(defaultextension=".json")
860
- if not fp: return
861
-
862
- # Prepare Data Structure
863
- nodes_dict = {}
864
- agent_authorities = {name: [] for name in self.agents}
865
-
866
- for nid, d in self.G.nodes(data=True):
867
- lbl = d.get('label', f"Node_{nid}")
868
- layer = d.get('layer', "Base Environment").replace(" ", "")
869
- typ = d.get('type', "Function")
870
- nodes_dict[lbl] = {"Type": f"{layer}{typ}", "UserData": lbl}
871
-
872
- ag_list = d.get('agent', ["Unassigned"])
873
- if not isinstance(ag_list, list): ag_list = [ag_list]
874
- for ag in ag_list:
875
- if ag in agent_authorities: agent_authorities[ag].append(lbl)
876
-
877
- edges_list = []
878
- for u, v, d in self.G.edges(data=True):
879
- edges_list.append({
880
- "Source": self.G.nodes[u].get('label', f"Node_{u}"),
881
- "Target": self.G.nodes[v].get('label', f"Node_{v}"),
882
- "UserData": {"type": d.get('type', config.EDGE_TYPE_HARD)}
883
- })
884
-
885
- final = {"GraphData": {
886
- "Nodes": nodes_dict,
887
- "Edges": edges_list,
888
- "Agents": {name: {"Authority": auth} for name, auth in agent_authorities.items()}
889
- }}
890
-
891
- with open(fp, 'w') as f: json.dump(final, f, indent=4)
892
-
893
- def load_from_json(self):
894
- fp = filedialog.askopenfilename()
895
- if not fp: return
896
- try:
897
- with open(fp, 'r', encoding='utf-8-sig') as f: data = json.load(f)["GraphData"]
898
-
899
- self.save_state()
900
- self.G.clear()
901
- self.agents = config.DEFAULT_AGENTS.copy()
902
-
903
- # 1. Load Agents
904
- label_to_agents = {}
905
- for ag_name, ag_data in data.get("Agents", {}).items():
906
- if ag_name not in self.agents:
907
- self.agents[ag_name] = "#" + ''.join([random.choice('ABCDEF89') for _ in range(6)])
908
- for node_lbl in ag_data.get("Authority", []):
909
- label_to_agents.setdefault(node_lbl, []).append(ag_name)
910
-
911
- # 2. Load Nodes
912
- label_to_id = {}
913
- layer_counters = {l: 100 for l in config.LAYER_ORDER}
914
-
915
- for i, (lbl, props) in enumerate(data.get("Nodes", {}).items()):
916
- n_type, n_layer = self._parse_node_attributes(props.get("Type", ""))
917
-
918
- # Position logic
919
- pos_y = config.JSAT_LAYERS.get(n_layer, 550)
920
- pos_x = layer_counters.get(n_layer, 100)
921
- layer_counters[n_layer] = pos_x + 120
922
-
923
- assigned = label_to_agents.get(lbl, ["Unassigned"])
924
- self.G.add_node(i, pos=(pos_x, pos_y), layer=n_layer, type=n_type,
925
- label=props.get("UserData", lbl), agent=assigned)
926
- label_to_id[lbl] = i
927
-
928
- # 3. Load Edges
929
- for e in data.get("Edges", []):
930
- u, v = label_to_id.get(e["Source"]), label_to_id.get(e["Target"])
931
- if u is not None and v is not None:
932
- self.G.add_edge(u, v, type=e.get("UserData", {}).get("type", config.EDGE_TYPE_HARD))
933
-
934
- self.redraw()
935
- except Exception as e:
936
- messagebox.showerror("Error", f"Failed to load: {e}")
937
-
938
- def _parse_node_attributes(self, combined_string):
939
- """Extracts (Type, Layer) from the specific JSON string format."""
940
- n_type = "Resource"
941
- layer = "Base Environment"
942
-
943
- if combined_string.endswith("Function"):
944
- n_type = "Function"
945
- prefix = combined_string.replace("Function", "")
946
- elif combined_string.endswith("Resource"):
947
- prefix = combined_string.replace("Resource", "")
948
- else:
949
- prefix = combined_string
950
-
951
- norm_prefix = prefix.lower().replace(" ", "")
952
- for known in config.LAYER_ORDER:
953
- if known.lower().replace(" ", "") == norm_prefix:
954
- layer = known
955
- break
956
- return n_type, layer
957
 
958
- # Comparative Analytics
 
 
959
 
960
- def save_architecture_internal(self):
961
- n = simpledialog.askstring("Name", "Name:")
962
- if n: self.saved_archs[n] = self.G.copy()
963
 
964
- def open_comparison_dialog(self):
965
- if not self.saved_archs:
966
- messagebox.showinfo("Info", "No saved architectures.")
967
- return
968
 
969
- w = Toplevel(self.root)
970
- tk.Label(w, text="Select (Ctrl+Click)").pack()
971
- lb = tk.Listbox(w, selectmode=tk.MULTIPLE)
972
- lb.pack()
973
- opts = ["Current"] + list(self.saved_archs.keys())
974
- for o in opts: lb.insert(tk.END, o)
975
- lb.selection_set(0)
976
 
977
- def go():
978
- selected = [lb.get(i) for i in lb.curselection()]
979
- graphs = [(n, self.G.copy() if n == "Current" else self.saved_archs[n].copy()) for n in selected]
980
- w.destroy()
981
- self.launch_compare_window(graphs)
982
- tk.Button(w, text="Go", command=go).pack()
983
 
984
- def launch_compare_window(self, graph_list):
985
- w = Toplevel(self.root)
986
- w.title("Comparative Analytics")
987
- w.geometry("1400x900")
988
-
989
- # UI Structure
990
- top_frame = tk.Frame(w, bd=2, relief=tk.RAISED)
991
- top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
992
-
993
- # Header
994
- h_row = tk.Frame(top_frame)
995
- h_row.pack(fill=tk.X, pady=5)
996
- tk.Label(h_row, text="Comparative Analytics", font=("Arial", 16, "bold")).pack(side=tk.LEFT, padx=10)
997
-
998
- paned = tk.PanedWindow(w, orient=tk.VERTICAL)
999
- paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
1000
-
1001
- graph_container = tk.Frame(paned)
1002
- paned.add(graph_container, minsize=400)
1003
-
1004
- inspector_frame = tk.Frame(paned, bd=2, relief=tk.SUNKEN, bg="#f0f0f0")
1005
- paned.add(inspector_frame, minsize=200)
1006
-
1007
- panels = []
1008
-
1009
- # Helper: Highlight Trigger
1010
- def set_highlights(metric_name):
1011
- for _, panel in panels:
1012
- if metric_name == "Total Cycles":
1013
- panel.set_highlights(metric_visualizations.get_cycle_highlights(panel.G))
1014
- elif metric_name == "Interdependence":
1015
- panel.set_highlights(metric_visualizations.get_interdependence_highlights(panel.G))
1016
- elif metric_name == "Modularity":
1017
- panel.set_highlights(metric_visualizations.get_modularity_highlights(panel.G))
1018
- else:
1019
- panel.set_highlights([])
1020
-
1021
- # Helper: Refresh Grid
1022
- def refresh_grid():
1023
- for child in top_frame.winfo_children():
1024
- if child != h_row: child.destroy()
1025
-
1026
- grid_f = tk.Frame(top_frame)
1027
- grid_f.pack(fill=tk.X, padx=10)
1028
-
1029
- # Headers
1030
- tk.Label(grid_f, text="Metric", font=("Arial", 12, "bold"), width=18, relief="solid", bd=1, bg="#e0e0e0").grid(row=0, column=0, sticky="nsew")
1031
- for i, (name, _) in enumerate(graph_list):
1032
- tk.Label(grid_f, text=name, font=("Arial", 12, "bold"), width=15, relief="solid", bd=1, bg="#e0e0e0").grid(row=0, column=i+1, sticky="nsew")
1033
-
1034
- metrics = ["Nodes", "Edges", "Density", "Cyclomatic Number", "Total Cycles", "Avg Cycle Length", "Interdependence", "Modularity", "Global Efficiency"]
1035
-
1036
- for r, m in enumerate(metrics):
1037
- # Row Header
1038
- lbl = tk.Label(grid_f, text=m, font=("Arial", 12), relief="solid", bd=1, anchor="w", padx=5)
1039
- lbl.grid(row=r+1, column=0, sticky="nsew")
1040
- if m in ["Total Cycles", "Interdependence", "Modularity"]:
1041
- lbl.config(fg="blue", cursor="hand2")
1042
- lbl.bind("<Button-1>", lambda e, name=m: set_highlights(name))
1043
-
1044
- # Values
1045
- for c, (_, g) in enumerate(graph_list):
1046
- val = calculate_metric(g, m)
1047
- tk.Label(grid_f, text=str(val), font=("Arial", 12), relief="solid", bd=1).grid(row=r+1, column=c+1, sticky="nsew")
1048
-
1049
- # Initialize Panels
1050
- for name, g in graph_list:
1051
- # We assume a dummy callback for inspector refresh as strict wiring isn't the focus of this refactor
1052
- p = InteractiveComparisonPanel(graph_container, g, name, config.NODE_RADIUS, self.agents, None, lambda x: None)
1053
- panels.append((name, p))
1054
-
1055
- refresh_grid()
1056
-
1057
- # Utils
1058
-
1059
- def get_layer_from_y(self, y):
1060
- closest, min_dist = None, 9999
1061
- for name, ly in config.JSAT_LAYERS.items():
1062
- dist = abs(y - ly)
1063
- if dist < min_dist:
1064
- min_dist, closest = dist, name
1065
- return closest
1066
-
1067
- def on_sidebar_node_press(self, event, node_id):
1068
- self.sidebar_drag_data = node_id
1069
- self.root.config(cursor="hand2")
1070
-
1071
- def on_sidebar_node_release(self, event):
1072
- self.root.config(cursor="")
1073
- if not self.sidebar_drag_data: return
1074
-
1075
- target = self.root.winfo_containing(event.x_root, event.y_root)
1076
- while target:
1077
- if hasattr(target, "agent_name"):
1078
- self.save_state()
1079
- self.assign_agent_logic(self.sidebar_drag_data, target.agent_name)
1080
- self.redraw()
1081
- break
1082
- target = target.master
1083
- if target == self.root: break
1084
- self.sidebar_drag_data = None
1085
-
1086
- def find_edge_at(self, x, y):
1087
- threshold = 8
1088
- for u, v in self.G.edges():
1089
- wx1, wy1 = self.get_draw_pos(u)
1090
- wx2, wy2 = self.get_draw_pos(v)
1091
- sx1, sy1 = self.to_screen(wx1, wy1)
1092
- sx2, sy2 = self.to_screen(wx2, wy2)
1093
-
1094
- # Distance from point to segment
1095
- dx, dy = sx2 - sx1, sy2 - sy1
1096
- if dx == 0 and dy == 0: dist = math.hypot(x - sx1, y - sy1)
1097
- else:
1098
- t = ((x - sx1) * dx + (y - sy1) * dy) / (dx*dx + dy*dy)
1099
- t = max(0, min(1, t))
1100
- dist = math.hypot(x - (sx1 + t * dx), y - (sy1 + t * dy))
1101
-
1102
- if dist < threshold: return (u, v)
1103
- return None
1104
-
1105
- def trigger_visual_analytics(self, mode):
1106
- if self.active_vis_mode == mode:
1107
- self.current_highlights = []
1108
- self.active_vis_mode = None
1109
- else:
1110
- self.active_vis_mode = mode
1111
- if mode == "cycles":
1112
- self.current_highlights = metric_visualizations.get_cycle_highlights(self.G)
1113
- elif mode == "interdependence":
1114
- self.current_highlights = metric_visualizations.get_interdependence_highlights(self.G)
1115
- elif mode == "modularity":
1116
- self.current_highlights = metric_visualizations.get_modularity_highlights(self.G)
1117
- self.redraw()
1118
-
1119
- def trigger_single_cycle_vis(self, index, graph_source=None):
1120
- target = graph_source if graph_source else self.G
1121
- hl = metric_visualizations.get_single_cycle_highlight(target, index)
1122
- if graph_source: return hl
1123
-
1124
- self.current_highlights = hl
1125
- self.active_vis_mode = f"cycle_{index}"
1126
- self.redraw()
1127
-
1128
- def trigger_single_modularity_vis(self, index, graph_source=None):
1129
- target = graph_source if graph_source else self.G
1130
- hl = metric_visualizations.get_single_modularity_highlight(target, index)
1131
- if graph_source: return hl
1132
-
1133
- self.current_highlights = hl
1134
- self.active_vis_mode = f"mod_group_{index}"
1135
- self.redraw()
 
1
+ import gradio as gr
 
2
  import networkx as nx
3
+ import matplotlib.pyplot as plt
4
+ import pandas as pd
5
  import random
6
+ import copy
7
+ import json
8
+ import io
9
 
10
+ # --- Local Imports (Must exist in your file system) ---
11
  import config
12
  from utils import calculate_metric
 
13
  import metric_visualizations
14
 
15
+ # --- Constants ---
16
+ # Metrics description for tooltips/help
17
  METRIC_DESCRIPTIONS = {
18
+ "Density": "Ratio of actual connections to potential connections.",
19
+ "Cyclomatic Number": "Number of fundamental independent loops.",
20
+ "Global Efficiency": "Measure (0-1) of information flow ease.",
21
+ "Supportive Gain": "Efficiency provided specifically by 'Soft' edges.",
22
+ "Brittleness Ratio": "Balance of Supportive (Soft) vs Essential (Hard) edges.",
23
+ "Critical Vulnerability": "Checks if 'Hard' skeleton is connected.",
24
+ "Interdependence": "% of edges crossing between different agents.",
25
+ "Total Cycles": "Total count of feedback loops.",
26
+ "Modularity": "How well the system divides into isolated groups.",
27
+ "Functional Redundancy": "Avg number of agents per function.",
28
+ "Collaboration Ratio": "% of functions with shared authority."
 
 
29
  }
30
 
31
+ # --- State Management Class ---
32
+ class GraphState:
33
+ """
34
+ Acts as the 'self' from your Tkinter app.
35
+ Holds the graph, history, and configuration for the current session.
36
+ """
37
+ def __init__(self):
38
  self.G = nx.DiGraph()
39
+ self.pos = {} # Store display positions
40
+ self.agents = config.DEFAULT_AGENTS.copy()
41
  self.undo_stack = []
42
  self.redo_stack = []
43
+ self.saved_snapshots = {} # For comparison
44
+ self.counter = 0 # Unique ID counter
45
+
46
+ def save_to_history(self):
47
+ """Push current state to undo stack"""
48
+ # Deep copy the graph to ensure isolation
49
+ self.undo_stack.append(self.G.copy())
50
+ if len(self.undo_stack) > 10:
51
+ self.undo_stack.pop(0)
52
+ self.redo_stack.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
+ def get_node_choices(self):
55
+ """Returns list of (Label, ID) tuples for Dropdowns"""
56
+ return [(d.get('label', str(n)), n) for n, d in self.G.nodes(data=True)]
57
+
58
+ # --- Global Initialization ---
59
+ # In Gradio, we instantiate this once. For multi-user isolation,
60
+ # you would pass this state through the function arguments,
61
+ # but for a Space demo, a global instance often suffices for simplicity
62
+ # unless you expect heavy concurrent traffic.
63
+ session = GraphState()
64
+
65
+ # --- Core Logic Functions ---
66
+
67
+ def render_plot(vis_mode="None", edge_filter="ALL"):
68
+ """
69
+ Replaces the Canvas drawing logic.
70
+ Uses Matplotlib to render the JSAT layers and NetworkX graph.
71
+ """
72
+ plt.figure(figsize=(12, 8))
73
+ ax = plt.gca()
74
+
75
+ # 1. Draw Layer Backgrounds
76
+ # We map config layers to Y-axis. Matplotlib Y increases upwards.
77
+ for name, y_val in config.JSAT_LAYERS.items():
78
+ plt.axhline(y=y_val, color='#e0e0e0', linestyle='--', zorder=0)
79
+ plt.text(50, y_val + 10, name, color='grey', fontsize=10, fontweight='bold', zorder=0)
80
+
81
+ if session.G.number_of_nodes() == 0:
82
+ plt.text(0.5, 0.5, "Graph is Empty.\nUse 'Editor' tab to add nodes.",
83
+ ha='center', va='center', transform=ax.transAxes, color='grey')
84
+ plt.axis('off')
85
+ return plt.gcf()
86
+
87
+ # 2. Filter Edges
88
+ edges_to_draw = []
89
+ for u, v, d in session.G.edges(data=True):
90
+ etype = d.get('type', config.EDGE_TYPE_HARD)
91
+ if edge_filter == "ALL" or etype == edge_filter:
92
+ edges_to_draw.append((u, v))
93
+
94
+ # 3. Draw Highlights (Visual Analytics)
95
+ # This replaces the yellow overlay logic
96
+ highlight_nodes = []
97
+ highlight_edges = []
98
+
99
+ if vis_mode == "Cycles":
100
+ hl_data = metric_visualizations.get_cycle_highlights(session.G)
101
+ for item in hl_data:
102
+ highlight_nodes.extend(item.get('nodes', []))
103
+ highlight_edges.extend(item.get('edges', []))
104
+ elif vis_mode == "Interdependence":
105
+ hl_data = metric_visualizations.get_interdependence_highlights(session.G)
106
+ for item in hl_data:
107
+ highlight_nodes.extend(item.get('nodes', []))
108
+ highlight_edges.extend(item.get('edges', []))
109
+ elif vis_mode == "Modularity":
110
+ hl_data = metric_visualizations.get_modularity_highlights(session.G)
111
+ for item in hl_data:
112
+ highlight_nodes.extend(item.get('nodes', []))
113
+ highlight_edges.extend(item.get('edges', []))
114
+
115
+ pos = session.pos
116
+
117
+ # Draw Highlights Underneath
118
+ if highlight_nodes:
119
+ nx.draw_networkx_nodes(session.G, pos, nodelist=highlight_nodes, node_color='yellow', node_size=900, alpha=0.5)
120
+ if highlight_edges:
121
+ nx.draw_networkx_edges(session.G, pos, edgelist=highlight_edges, edge_color='yellow', width=6, alpha=0.5)
122
+
123
+ # 4. Draw Standard Edges
124
+ hard_edges = [(u,v) for (u,v) in edges_to_draw if session.G.edges[u,v].get('type') == 'hard']
125
+ soft_edges = [(u,v) for (u,v) in edges_to_draw if session.G.edges[u,v].get('type') == 'soft']
126
+
127
+ nx.draw_networkx_edges(session.G, pos, edgelist=hard_edges, edge_color='black', width=2, arrowstyle='-|>')
128
+ nx.draw_networkx_edges(session.G, pos, edgelist=soft_edges, edge_color='grey', width=2, style='dashed', arrowstyle='-|>')
129
 
130
+ # 5. Draw Nodes (Shapes and Colors based on Type/Agent)
131
+ for n, d in session.G.nodes(data=True):
132
+ x, y = pos[n]
133
+ lbl = d.get('label', str(n))
134
+ ntype = d.get('type', 'Function')
135
+ agents = d.get('agent', ['Unassigned'])
136
+ if isinstance(agents, str): agents = [agents]
137
 
138
+ # Determine Color (Primary Agent)
139
+ primary_agent = agents[0]
140
+ color = session.agents.get(primary_agent, 'white')
141
 
142
+ # Shape
143
+ marker = 's' if ntype == 'Function' else 'o'
 
 
 
 
 
144
 
145
+ # Manual scatter plot to handle mixed shapes/colors
146
+ plt.scatter(x, y, s=600, c=color, marker=marker, edgecolors='black', linewidth=1.5, zorder=2)
147
+ plt.text(x, y-40, lbl, ha='center', va='top', fontsize=9, fontweight='bold', zorder=3)
 
 
 
 
 
148
 
149
+ plt.axis('off')
150
+ plt.tight_layout()
151
+ return plt.gcf()
152
 
153
+ # --- Interaction Functions ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
+ def add_node_fn(label, n_type, layer, agent):
156
+ session.save_to_history()
157
+
158
+ # Generate ID
159
+ nid = session.counter
160
+ session.counter += 1
161
+
162
+ # Calculate position
163
+ y = config.JSAT_LAYERS.get(layer, 0)
164
+ x = random.randint(100, 900)
165
+
166
+ session.G.add_node(nid, label=label, type=n_type, layer=layer,
167
+ agent=[agent], pos=(x, y))
168
+ session.pos[nid] = (x, y)
169
+
170
+ return render_plot(), update_node_dropdown(), f"Added {label}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
+ def add_edge_fn(u_id, v_id, e_type):
173
+ if u_id is None or v_id is None:
174
+ return render_plot(), update_node_dropdown(), "Error: Select nodes"
175
+
176
+ # Enforce alternating type logic from Tkinter app
177
+ t1 = session.G.nodes[u_id].get('type')
178
+ t2 = session.G.nodes[v_id].get('type')
179
+
180
+ if t1 == t2:
181
+ return render_plot(), update_node_dropdown(), f"Error: Cannot connect {t1} to {t2}"
182
+
183
+ session.save_to_history()
184
+ session.G.add_edge(u_id, v_id, type=e_type)
185
+ return render_plot(), update_node_dropdown(), "Connection Created"
186
+
187
+ def delete_node_fn(u_id):
188
+ if u_id is None: return render_plot(), update_node_dropdown(), "No node selected"
189
+ session.save_to_history()
190
+ session.G.remove_node(u_id)
191
+ return render_plot(), update_node_dropdown(), "Node Deleted"
192
+
193
+ def create_agent_fn(name, color):
194
+ if not name: return "Name required", gr.update()
195
+ session.agents[name] = color
196
+ # Update choices for agent dropdowns
197
+ return f"Created agent {name}", gr.Dropdown(choices=list(session.agents.keys()))
198
+
199
+ def assign_agent_fn(node_id, agent_name):
200
+ if node_id is None: return render_plot(), "Select a node"
201
+
202
+ session.save_to_history()
203
+ current = session.G.nodes[node_id].get('agent', [])
204
+ if isinstance(current, str): current = [current]
205
+
206
+ # Logic from Tkinter: Toggle agent
207
+ if agent_name in current:
208
+ current.remove(agent_name)
209
+ else:
210
+ if "Unassigned" in current: current.remove("Unassigned")
211
+ current.append(agent_name)
212
+
213
+ if not current: current = ["Unassigned"]
214
+
215
+ session.G.nodes[node_id]['agent'] = current
216
+ return render_plot(), f"Agents for node {node_id}: {current}"
217
 
218
+ def update_node_dropdown():
219
+ # Helper to refresh dropdown options
220
+ choices = session.get_node_choices()
221
+ return gr.Dropdown(choices=choices)
 
 
 
 
 
222
 
223
+ def calculate_stats_fn():
224
+ report = "### Network Statistics\n"
225
+
226
+ metrics = [
227
+ "Density", "Cyclomatic Number", "Global Efficiency",
228
+ "Supportive Gain", "Brittleness Ratio", "Interdependence",
229
+ "Total Cycles", "Modularity"
230
+ ]
231
+
232
+ for m in metrics:
233
+ try:
234
+ val = calculate_metric(session.G, m)
235
+ desc = METRIC_DESCRIPTIONS.get(m, "")
236
+ report += f"**{m}**: {val}\n> *{desc}*\n\n"
237
+ except Exception as e:
238
+ report += f"**{m}**: Error ({str(e)})\n\n"
239
 
240
+ return report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
+ def snapshot_fn(name):
243
+ if not name: return "Enter a name", gr.update()
244
+ session.saved_snapshots[name] = session.G.copy()
245
+ return f"Saved snapshot: {name}", gr.update(choices=list(session.saved_snapshots.keys()))
 
 
 
246
 
247
+ def compare_fn(selected_snapshots):
248
+ if not selected_snapshots: return pd.DataFrame()
249
+
250
+ data = []
251
+ metrics = ["Density", "Global Efficiency", "Total Cycles", "Modularity"]
252
+
253
+ for name in selected_snapshots:
254
+ g = session.saved_snapshots[name]
255
+ row = {"Snapshot": name, "Nodes": g.number_of_nodes(), "Edges": g.number_of_edges()}
256
+ for m in metrics:
257
+ row[m] = calculate_metric(g, m)
258
+ data.append(row)
259
+
260
+ return pd.DataFrame(data)
261
+
262
+ def export_json_fn():
263
+ # Mimic the Save JSON logic
264
+ nodes_dict = {}
265
+ agent_authorities = {name: [] for name in session.agents}
266
+
267
+ for nid, d in session.G.nodes(data=True):
268
+ lbl = d.get('label', f"Node_{nid}")
269
+ layer = d.get('layer', "Base Environment").replace(" ", "")
270
+ typ = d.get('type', "Function")
271
+ nodes_dict[lbl] = {"Type": f"{layer}{typ}", "UserData": lbl}
272
+
273
+ ag_list = d.get('agent', ["Unassigned"])
274
+ if not isinstance(ag_list, list): ag_list = [ag_list]
275
+ for ag in ag_list:
276
+ if ag in agent_authorities: agent_authorities[ag].append(lbl)
277
+
278
+ edges_list = []
279
+ for u, v, d in session.G.edges(data=True):
280
+ src_lbl = session.G.nodes[u].get('label', str(u))
281
+ tgt_lbl = session.G.nodes[v].get('label', str(v))
282
+ edges_list.append({
283
+ "Source": src_lbl,
284
+ "Target": tgt_lbl,
285
+ "UserData": {"type": d.get('type', config.EDGE_TYPE_HARD)}
286
+ })
287
+
288
+ final = {"GraphData": {
289
+ "Nodes": nodes_dict,
290
+ "Edges": edges_list,
291
+ "Agents": {name: {"Authority": auth} for name, auth in agent_authorities.items()}
292
+ }}
293
+
294
+ # Return as string for Textbox
295
+ return json.dumps(final, indent=4)
296
+
297
+ def load_json_fn(json_str):
298
+ try:
299
+ data = json.loads(json_str)["GraphData"]
300
+ session.G.clear()
301
+ session.pos = {}
302
+ session.counter = 0
303
+
304
+ # Load Agents
305
+ for ag_name, ag_data in data.get("Agents", {}).items():
306
+ if ag_name not in session.agents:
307
+ session.agents[ag_name] = "#999999" # Default color if unknown
308
+
309
+ # Load Nodes
310
+ label_to_id = {}
311
+ for lbl, props in data.get("Nodes", {}).items():
312
+ combined_type = props.get("Type", "")
313
+ # Basic Parse logic
314
+ ntype = "Function" if "Function" in combined_type else "Resource"
315
+ # Find layer
316
+ layer = "Distributed Work"
317
+ for l in config.LAYER_ORDER:
318
+ if l.replace(" ","") in combined_type:
319
+ layer = l
320
+ break
321
+
322
+ nid = session.counter
323
+ session.counter += 1
324
+
325
+ # Position logic
326
+ y = config.JSAT_LAYERS.get(layer, 0)
327
+ x = random.randint(100, 900)
328
+
329
+ session.G.add_node(nid, label=lbl, type=ntype, layer=layer, agent=["Unassigned"], pos=(x,y))
330
+ session.pos[nid] = (x,y)
331
+ label_to_id[lbl] = nid
332
+
333
+ # Load Edges
334
+ for e in data.get("Edges", []):
335
+ u = label_to_id.get(e["Source"])
336
+ v = label_to_id.get(e["Target"])
337
+ if u is not None and v is not None:
338
+ etype = e.get("UserData", {}).get("type", "hard")
339
+ session.G.add_edge(u, v, type=etype)
340
 
341
+ return render_plot(), update_node_dropdown(), "JSON Loaded Successfully"
342
+ except Exception as e:
343
+ return render_plot(), update_node_dropdown(), f"Error loading JSON: {str(e)}"
344
 
345
+ # --- Layout Construction ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
+ with gr.Blocks(title="Interactive JSAT", theme=gr.themes.Soft()) as demo:
348
+ gr.Markdown("# 🕸️ Interactive JSAT Graph Builder")
349
+
350
+ with gr.Row():
351
+ # LEFT COLUMN: Visualization
352
+ with gr.Column(scale=2):
353
+ plot_output = gr.Plot(label="Network Architecture")
354
+ log_output = gr.Textbox(label="System Log", value="Ready.", interactive=False)
355
+
356
+ with gr.Row():
357
+ vis_mode = gr.Radio(["None", "Cycles", "Interdependence", "Modularity"], label="Visual Analytics Overlay", value="None")
358
+ edge_filter = gr.Radio(["ALL", "hard", "soft"], label="Show Edges", value="ALL")
359
+
360
+ # RIGHT COLUMN: Controls (Tabs)
361
+ with gr.Column(scale=1):
362
+
363
+ # --- TAB 1: EDITOR ---
364
+ with gr.Tab("📝 Editor"):
365
+ gr.Markdown("### Add Node")
366
+ with gr.Row():
367
+ n_lbl = gr.Textbox(label="Label", placeholder="F1")
368
+ n_type = gr.Dropdown(["Function", "Resource"], label="Type", value="Function")
369
+ with gr.Row():
370
+ n_layer = gr.Dropdown(config.LAYER_ORDER, label="Layer", value="Distributed Work")
371
+ n_agent = gr.Dropdown(list(session.agents.keys()), label="Initial Agent", value="Unassigned")
372
+ btn_add_n = gr.Button("➕ Create Node", variant="primary")
373
 
374
+ gr.Markdown("### Connections")
375
+ with gr.Row():
376
+ # These dropdowns update dynamically
377
+ src_drop = gr.Dropdown(label="Source", choices=[])
378
+ tgt_drop = gr.Dropdown(label="Target", choices=[])
379
+ e_type = gr.Radio(["hard", "soft"], label="Constraint", value="hard")
380
+ btn_add_e = gr.Button("🔗 Connect", variant="secondary")
381
 
382
+ gr.Markdown("### Management")
383
+ del_node_drop = gr.Dropdown(label="Select Node to Delete", choices=[])
384
+ btn_del = gr.Button("🗑️ Delete Node", variant="stop")
385
+
386
+ # --- TAB 2: AGENTS ---
387
+ with gr.Tab("👥 Agents"):
388
+ gr.Markdown("### Create New Agent")
389
+ with gr.Row():
390
+ new_ag_name = gr.Textbox(label="Name")
391
+ new_ag_col = gr.ColorPicker(label="Color", value="#00ff00")
392
+ btn_create_ag = gr.Button("Save Agent")
393
 
394
+ gr.Markdown("### Assign to Node")
395
+ with gr.Row():
396
+ node_assign_drop = gr.Dropdown(label="Node", choices=[])
397
+ agent_assign_drop = gr.Dropdown(label="Agent", choices=list(session.agents.keys()))
398
+ btn_assign = gr.Button("Toggle Assignment")
399
+
400
+ # --- TAB 3: ANALYTICS ---
401
+ with gr.Tab("📊 Analytics"):
402
+ stats_box = gr.Markdown("Click 'Calculate' to see metrics...")
403
+ btn_stats = gr.Button("Calculate Metrics")
404
+
405
+ # --- TAB 4: COMPARE ---
406
+ with gr.Tab("⚖️ Compare"):
407
+ snap_name = gr.Textbox(label="Snapshot Name")
408
+ btn_snap = gr.Button("Save Snapshot")
409
 
410
+ snap_select = gr.CheckboxGroup(label="Select Snapshots to Compare", choices=[])
411
+ btn_compare = gr.Button("Generate Comparison Table")
412
+ compare_table = gr.Dataframe(label="Comparison Matrix")
413
+
414
+ # --- TAB 5: I/O ---
415
+ with gr.Tab("💾 I/O"):
416
+ btn_export = gr.Button("Generate JSON")
417
+ json_out = gr.Textbox(label="JSON Output", lines=5, show_copy_button=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
+ gr.Markdown("---")
420
+ json_in = gr.Textbox(label="Paste JSON Here", lines=5)
421
+ btn_import = gr.Button("Load from JSON")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
+ # --- Event Wiring ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
+ # Initialization
426
+ demo.load(render_plot, None, plot_output)
427
+ demo.load(update_node_dropdown, None, src_drop)
428
+ demo.load(update_node_dropdown, None, tgt_drop)
429
+ demo.load(update_node_dropdown, None, del_node_drop)
430
+ demo.load(update_node_dropdown, None, node_assign_drop)
431
+
432
+ # Visualization Triggers
433
+ vis_mode.change(render_plot, [vis_mode, edge_filter], plot_output)
434
+ edge_filter.change(render_plot, [vis_mode, edge_filter], plot_output)
435
+
436
+ # Editor Actions
437
+ btn_add_n.click(add_node_fn, [n_lbl, n_type, n_layer, n_agent], [plot_output, src_drop, log_output]) \
438
+ .then(update_node_dropdown, None, tgt_drop) \
439
+ .then(update_node_dropdown, None, del_node_drop) \
440
+ .then(update_node_dropdown, None, node_assign_drop)
441
+
442
+ btn_add_e.click(add_edge_fn, [src_drop, tgt_drop, e_type], [plot_output, src_drop, log_output])
 
 
 
 
443
 
444
+ btn_del.click(delete_node_fn, [del_node_drop], [plot_output, del_node_drop, log_output]) \
445
+ .then(update_node_dropdown, None, src_drop) \
446
+ .then(update_node_dropdown, None, tgt_drop)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
+ # Agent Actions
449
+ btn_create_ag.click(create_agent_fn, [new_ag_name, new_ag_col], [log_output, n_agent]) \
450
+ .then(lambda: gr.Dropdown(choices=list(session.agents.keys())), None, agent_assign_drop)
451
 
452
+ btn_assign.click(assign_agent_fn, [node_assign_drop, agent_assign_drop], [plot_output, log_output])
 
 
453
 
454
+ # Analytics
455
+ btn_stats.click(calculate_stats_fn, None, stats_box)
 
 
456
 
457
+ # Comparison
458
+ btn_snap.click(snapshot_fn, snap_name, [log_output, snap_select])
459
+ btn_compare.click(compare_fn, snap_select, compare_table)
 
 
 
 
460
 
461
+ # I/O
462
+ btn_export.click(export_json_fn, None, json_out)
463
+ btn_import.click(load_json_fn, json_in, [plot_output, src_drop, log_output]) \
464
+ .then(update_node_dropdown, None, tgt_drop) \
465
+ .then(update_node_dropdown, None, del_node_drop)
 
466
 
467
+ if __name__ == "__main__":
468
+ demo.launch()