nettracer3d 1.2.7__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nettracer3d might be problematic. Click here for more details.

@@ -0,0 +1,2066 @@
1
+ import numpy as np
2
+ import networkx as nx
3
+ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QMenu,
4
+ QSizePolicy, QApplication, QScrollArea, QLabel, QFrame,
5
+ QFileDialog, QMessageBox)
6
+ from PyQt6.QtCore import Qt, pyqtSignal, QThread, pyqtSlot, QTimer, QPointF, QRectF
7
+ from PyQt6.QtGui import QColor, QPen, QBrush
8
+ import pyqtgraph as pg
9
+ from pyqtgraph import ScatterPlotItem, PlotCurveItem, GraphicsLayoutWidget, ROI
10
+ import colorsys
11
+
12
+
13
+ class GraphLoadThread(QThread):
14
+ """Thread for loading graph layouts without blocking the UI"""
15
+ finished = pyqtSignal(object) # Emits the computed layout data
16
+
17
+ def __init__(self, graph, geometric, component, centroids, communities,
18
+ community_dict, identities, identity_dict, weight, z_size):
19
+ super().__init__()
20
+ self.graph = graph
21
+ self.geometric = geometric
22
+ self.component = component
23
+ self.centroids = centroids
24
+ self.communities = communities
25
+ self.community_dict = community_dict
26
+ self.identities = identities
27
+ self.identity_dict = identity_dict
28
+ self.weight = weight
29
+ self.z_size = z_size
30
+
31
+ def run(self):
32
+ """Compute layout and colors in background thread"""
33
+ result = {}
34
+
35
+ # Compute node positions
36
+ if not self.geometric and not self.component:
37
+ n = len(self.graph.nodes())
38
+ if n < 500:
39
+ result['pos'] = nx.spring_layout(self.graph, seed=42, iterations=50)
40
+ else:
41
+ result['pos'] = self._compute_fast_spring_layout()
42
+ elif self.geometric:
43
+ result['pos'] = self._compute_geometric_layout()
44
+ elif self.component:
45
+ nodes = list(self.graph.nodes())
46
+ n = len(nodes)
47
+ result['pos'] = self._spring_layout_numpy_super(nodes, n)
48
+
49
+ # Compute node colors and sizes
50
+ result['colors'], result['sizes'] = self._compute_node_attributes()
51
+
52
+ # Compute edge data
53
+ result['edges'] = self._compute_edge_data(result['pos'])
54
+
55
+ # Prepare node spots for rendering (with pre-computed brushes)
56
+ result['node_spots'], result['brush_cache'] = self._prepare_node_spots(result['pos'], result['colors'], result['sizes'])
57
+
58
+ # Prepare label data
59
+ result['label_data'] = self._prepare_label_data(result['pos'])
60
+
61
+ # Prepare edge items
62
+ result['edge_pens'] = self._prepare_edge_pens(result['edges'])
63
+
64
+ self.finished.emit(result)
65
+
66
+ def _compute_fast_spring_layout(self):
67
+ """Fast vectorized spring layout using numpy"""
68
+ nodes = list(self.graph.nodes())
69
+ n = len(nodes)
70
+
71
+ if n == 0:
72
+ return {}
73
+
74
+ # For small graphs, use networkx (overhead is negligible)
75
+ if n < 200:
76
+ return nx.spring_layout(self.graph, seed=42, iterations=50)
77
+
78
+ # Use fast vectorized implementation for larger graphs
79
+ try:
80
+ return self._spring_layout_numpy(nodes, n)
81
+ except Exception as e:
82
+ return nx.spring_layout(self.graph, seed=42, iterations=15)
83
+
84
+ def _spring_layout_numpy_super(self, nodes, n, iterations=50):
85
+ """
86
+ Spring layout with physically separated connected components
87
+ """
88
+ from scipy.spatial import cKDTree
89
+ import networkx as nx
90
+
91
+ np.random.seed(42)
92
+
93
+ # Find connected components
94
+ components = list(nx.connected_components(self.graph))
95
+
96
+ if len(components) == 1:
97
+ # Single component - use original algorithm
98
+ return self._spring_layout_numpy(nodes, n, iterations)
99
+
100
+ # Layout each component independently
101
+ component_layouts = []
102
+ component_bounds = []
103
+
104
+ for component in components:
105
+ comp_nodes = list(component)
106
+ comp_n = len(comp_nodes)
107
+
108
+ # Create subgraph for this component
109
+ subgraph_edges = [
110
+ (u, v) for u, v in self.graph.edges()
111
+ if u in component and v in component
112
+ ]
113
+
114
+ # Run spring layout on this component only
115
+ comp_pos = self._layout_component(comp_nodes, comp_n, subgraph_edges, iterations)
116
+
117
+ # Calculate bounding box
118
+ positions = np.array(list(comp_pos.values()))
119
+ min_coords = positions.min(axis=0)
120
+ max_coords = positions.max(axis=0)
121
+ size = max_coords - min_coords
122
+
123
+ component_layouts.append((comp_nodes, comp_pos))
124
+ component_bounds.append(size)
125
+
126
+ # Arrange components in a grid with spacing
127
+ num_components = len(components)
128
+ grid_cols = int(np.ceil(np.sqrt(num_components)))
129
+
130
+ # Calculate spacing based on largest component
131
+ max_width = max(bounds[0] for bounds in component_bounds)
132
+ max_height = max(bounds[1] for bounds in component_bounds)
133
+ spacing_x = max_width * 1.5 # 50% padding between components
134
+ spacing_y = max_height * 1.5
135
+
136
+ # Place components in grid
137
+ final_positions = {}
138
+ for idx, (comp_nodes, comp_pos) in enumerate(component_layouts):
139
+ grid_x = idx % grid_cols
140
+ grid_y = idx // grid_cols
141
+
142
+ # Calculate offset for this component
143
+ offset = np.array([grid_x * spacing_x, grid_y * spacing_y])
144
+
145
+ # Apply offset to all nodes in component
146
+ for node in comp_nodes:
147
+ final_positions[node] = comp_pos[node] + offset
148
+
149
+ # Center the entire layout
150
+ all_pos = np.array([final_positions[node] for node in nodes])
151
+ all_pos -= all_pos.mean(axis=0)
152
+
153
+ return {node: all_pos[i] for i, node in enumerate(nodes)}
154
+
155
+ def _layout_component(self, nodes, n, edges, iterations):
156
+ """
157
+ Spring layout for a single component
158
+ """
159
+ from scipy.spatial import cKDTree
160
+
161
+ np.random.seed(42 + len(nodes)) # Different seed per component size
162
+ pos = np.random.rand(n, 2)
163
+
164
+ if len(edges) == 0:
165
+ return {node: pos[i] for i, node in enumerate(nodes)}
166
+
167
+ node_to_idx = {node: i for i, node in enumerate(nodes)}
168
+ edge_indices = np.array([[node_to_idx[u], node_to_idx[v]] for u, v in edges])
169
+
170
+ k = np.sqrt(1.0 / n)
171
+ t = 0.1
172
+ dt = t / (iterations + 1)
173
+ cutoff_distance = 4 * k
174
+
175
+ for iteration in range(iterations):
176
+ displacement = np.zeros_like(pos)
177
+
178
+ tree = cKDTree(pos)
179
+ pairs = tree.query_pairs(r=cutoff_distance, output_type='ndarray')
180
+
181
+ if len(pairs) > 0:
182
+ i_indices = pairs[:, 0]
183
+ j_indices = pairs[:, 1]
184
+
185
+ delta = pos[i_indices] - pos[j_indices]
186
+ distance = np.linalg.norm(delta, axis=1, keepdims=True)
187
+ distance = np.maximum(distance, 0.01)
188
+
189
+ force_magnitude = (k * k) / distance
190
+ force = delta * (force_magnitude / distance)
191
+
192
+ np.add.at(displacement, i_indices, force)
193
+ np.add.at(displacement, j_indices, -force)
194
+
195
+ if len(edge_indices) > 0:
196
+ edge_delta = pos[edge_indices[:, 0]] - pos[edge_indices[:, 1]]
197
+ edge_distance = np.sqrt((edge_delta ** 2).sum(axis=1, keepdims=True))
198
+ edge_distance = np.maximum(edge_distance, 0.01)
199
+
200
+ edge_force_magnitude = (edge_distance * edge_distance) / k
201
+ edge_force = edge_delta * (edge_force_magnitude / edge_distance)
202
+
203
+ np.add.at(displacement, edge_indices[:, 0], -edge_force)
204
+ np.add.at(displacement, edge_indices[:, 1], edge_force)
205
+
206
+ disp_magnitude = np.sqrt((displacement ** 2).sum(axis=1, keepdims=True))
207
+ disp_magnitude = np.maximum(disp_magnitude, 0.01)
208
+ displacement = displacement * np.minimum(t / disp_magnitude, 1.0)
209
+
210
+ pos += displacement
211
+ t -= dt
212
+
213
+ pos -= pos.mean(axis=0)
214
+ return {node: pos[i] for i, node in enumerate(nodes)}
215
+
216
+ def _spring_layout_numpy(self, nodes, n, iterations = 50):
217
+ """
218
+ Original algorithm for single component case
219
+ """
220
+ from scipy.spatial import cKDTree
221
+ np.random.seed(42)
222
+ pos = np.random.rand(n, 2)
223
+
224
+ edges = list(self.graph.edges())
225
+ if len(edges) == 0:
226
+ return {node: pos[i] for i, node in enumerate(nodes)}
227
+
228
+ node_to_idx = {node: i for i, node in enumerate(nodes)}
229
+ edge_indices = np.array([[node_to_idx[u], node_to_idx[v]] for u, v in edges])
230
+
231
+ k = np.sqrt(1.0 / n)
232
+ t = 0.1
233
+ dt = t / (iterations + 1)
234
+ cutoff_distance = 4 * k
235
+
236
+ for iteration in range(iterations):
237
+ displacement = np.zeros_like(pos)
238
+ tree = cKDTree(pos)
239
+ pairs = tree.query_pairs(r=cutoff_distance, output_type='ndarray')
240
+
241
+ if len(pairs) > 0:
242
+ i_indices = pairs[:, 0]
243
+ j_indices = pairs[:, 1]
244
+ delta = pos[i_indices] - pos[j_indices]
245
+ distance = np.linalg.norm(delta, axis=1, keepdims=True)
246
+ distance = np.maximum(distance, 0.01)
247
+ force_magnitude = (k * k) / distance
248
+ force = delta * (force_magnitude / distance)
249
+ np.add.at(displacement, i_indices, force)
250
+ np.add.at(displacement, j_indices, -force)
251
+
252
+ if len(edge_indices) > 0:
253
+ edge_delta = pos[edge_indices[:, 0]] - pos[edge_indices[:, 1]]
254
+ edge_distance = np.sqrt((edge_delta ** 2).sum(axis=1, keepdims=True))
255
+ edge_distance = np.maximum(edge_distance, 0.01)
256
+ edge_force_magnitude = (edge_distance * edge_distance) / k
257
+ edge_force = edge_delta * (edge_force_magnitude / edge_distance)
258
+ np.add.at(displacement, edge_indices[:, 0], -edge_force)
259
+ np.add.at(displacement, edge_indices[:, 1], edge_force)
260
+
261
+ disp_magnitude = np.sqrt((displacement ** 2).sum(axis=1, keepdims=True))
262
+ disp_magnitude = np.maximum(disp_magnitude, 0.01)
263
+ displacement = displacement * np.minimum(t / disp_magnitude, 1.0)
264
+ pos += displacement
265
+ t -= dt
266
+
267
+ pos -= pos.mean(axis=0)
268
+ return {node: pos[i] for i, node in enumerate(nodes)}
269
+
270
+ def _prepare_node_spots(self, pos, colors, sizes):
271
+ """Prepare spots array and brush caches for ScatterPlotItem"""
272
+ nodes = list(self.graph.nodes())
273
+ pos_array = np.array([pos[n] for n in nodes])
274
+
275
+ # Pre-compute both normal and selected brushes in separate caches
276
+ spots = []
277
+ brush_cache = {} # {node: {'normal': brush, 'selected': brush}}
278
+
279
+ for i, node in enumerate(nodes):
280
+ hex_color = colors[i]
281
+ hex_color = hex_color.lstrip('#')
282
+ rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
283
+
284
+ # Create brushes (do this in thread to save time later)
285
+ normal_brush = pg.mkBrush(*rgb, 200)
286
+ selected_brush = pg.mkBrush(255, 255, 0, 255) # Yellow for selected
287
+
288
+ # Store brushes separately
289
+ brush_cache[node] = {
290
+ 'normal': normal_brush,
291
+ 'selected': selected_brush
292
+ }
293
+
294
+ # Only include pyqtgraph-valid parameters in spot
295
+ spot = {
296
+ 'pos': pos_array[i],
297
+ 'size': sizes[i],
298
+ 'brush': normal_brush, # Start with normal brush
299
+ 'data': node
300
+ }
301
+ spots.append(spot)
302
+
303
+ return spots, brush_cache
304
+
305
+ def _prepare_label_data(self, pos):
306
+ """Prepare label positions and text"""
307
+ label_data = []
308
+ for node in self.graph.nodes():
309
+ if node in pos:
310
+ label_data.append({
311
+ 'node': node,
312
+ 'text': str(node),
313
+ 'pos': pos[node]
314
+ })
315
+ return label_data
316
+
317
+ def _prepare_edge_pens(self, edges):
318
+ """Prepare edge drawing data - batch edges by weight bins for efficient rendering"""
319
+ if not edges:
320
+ return []
321
+
322
+ # If weights are disabled, use uniform thickness
323
+ if not self.weight:
324
+ # All edges get same thickness - combine into single batch
325
+ x_coords = []
326
+ y_coords = []
327
+ for x, y, weight in edges:
328
+ x_coords.extend([x[0], x[1], np.nan])
329
+ y_coords.extend([y[0], y[1], np.nan])
330
+
331
+ return [{
332
+ 'x': np.array(x_coords),
333
+ 'y': np.array(y_coords),
334
+ 'thickness': 1.0
335
+ }]
336
+
337
+ # Weight-based rendering - batch by thickness
338
+ weights = [w for _, _, w in edges]
339
+ if not weights:
340
+ return []
341
+
342
+ min_weight = min(weights)
343
+ max_weight = max(weights)
344
+ weight_range = max_weight - min_weight if max_weight > min_weight else 1
345
+
346
+ # Define thickness bins (e.g., 10 discrete thickness levels)
347
+ num_bins = 10
348
+ thickness_min = 0.5
349
+ thickness_max = 3.0 # Maximum thickness cap
350
+
351
+ # Batch edges by thickness bin
352
+ edge_batches = {} # {thickness: [(x_coords, y_coords), ...]}
353
+
354
+ for x, y, weight in edges:
355
+ # Normalize weight to thickness
356
+ if weight_range > 0:
357
+ normalized = (weight - min_weight) / weight_range
358
+ else:
359
+ normalized = 0.5
360
+
361
+ # Calculate thickness with cap
362
+ thickness = thickness_min + normalized * (thickness_max - thickness_min)
363
+
364
+ # Bin the thickness to reduce number of batches
365
+ thickness_bin = round(thickness * num_bins) / num_bins
366
+ thickness_bin = min(thickness_bin, thickness_max) # Apply cap
367
+
368
+ # Add to batch
369
+ if thickness_bin not in edge_batches:
370
+ edge_batches[thickness_bin] = {'x': [], 'y': []}
371
+
372
+ # Add edge coordinates with NaN separator
373
+ edge_batches[thickness_bin]['x'].extend([x[0], x[1], np.nan])
374
+ edge_batches[thickness_bin]['y'].extend([y[0], y[1], np.nan])
375
+
376
+ # Convert to list format for rendering
377
+ batch_data = []
378
+ for thickness, coords in edge_batches.items():
379
+ batch_data.append({
380
+ 'x': np.array(coords['x']),
381
+ 'y': np.array(coords['y']),
382
+ 'thickness': thickness
383
+ })
384
+
385
+ return batch_data
386
+
387
+ def _compute_geometric_layout(self):
388
+ """Compute positions from centroids"""
389
+ pos = {}
390
+ for node in self.graph.nodes():
391
+ if node in self.centroids:
392
+ z, y, x = self.centroids[node]
393
+ pos[node] = np.array([x, -y])
394
+ else:
395
+ pos[node] = np.array([0, 0])
396
+ return pos
397
+
398
+ def _compute_node_attributes(self):
399
+ """Compute node colors and sizes"""
400
+ nodes = list(self.graph.nodes())
401
+ colors = []
402
+ sizes = self._compute_all_node_sizes_vectorized(nodes)
403
+
404
+ # Determine coloring mode
405
+ if self.identities and self.identity_dict:
406
+ color_map = self._generate_community_colors(self.identity_dict)
407
+ for node in self.graph.nodes():
408
+ identity = self.identity_dict.get(node, 'Unknown')
409
+ colors.append(color_map.get(identity, '#808080'))
410
+ elif self.communities and self.community_dict:
411
+ color_map = self._generate_community_colors(self.community_dict)
412
+ for node in self.graph.nodes():
413
+ community = self.community_dict.get(node, -1)
414
+ colors.append(color_map.get(community, '#808080'))
415
+ else:
416
+ # Default coloring
417
+ for node in self.graph.nodes():
418
+ colors.append('#4A90E2')
419
+
420
+ return colors, sizes
421
+
422
+ def _compute_all_node_sizes_vectorized(self, nodes):
423
+ if not self.geometric or not self.centroids or not self.z_size:
424
+ return [10] * len(nodes)
425
+
426
+ # GLOBAL z range (matches original behavior)
427
+ all_z = np.array([
428
+ self.centroids[n][0]
429
+ for n in self.graph.nodes()
430
+ if n in self.centroids
431
+ ])
432
+
433
+ if len(all_z) == 0:
434
+ return [10] * len(nodes)
435
+
436
+ z_min, z_max = all_z.min(), all_z.max()
437
+
438
+ sizes = [10] * len(nodes)
439
+
440
+ if z_max <= z_min:
441
+ return sizes
442
+
443
+ # Collect z-values ONLY for requested nodes
444
+ z_values = []
445
+ node_indices = []
446
+
447
+ for i, node in enumerate(nodes):
448
+ if node in self.centroids:
449
+ z_values.append(self.centroids[node][0])
450
+ node_indices.append(i)
451
+
452
+ if not z_values:
453
+ return sizes
454
+
455
+ z_array = np.array(z_values)
456
+
457
+ normalized = 1 - (z_array - z_min) / (z_max - z_min)
458
+ computed_sizes = 5 + normalized * 20
459
+
460
+ for idx, node_idx in enumerate(node_indices):
461
+ sizes[node_idx] = float(computed_sizes[idx])
462
+
463
+ return sizes
464
+
465
+ def _generate_identity_colors(self):
466
+ """Generate colors for identities using the specified strategy"""
467
+ unique_categories = list(set(self.identity_dict.values()))
468
+ num_categories = len(unique_categories)
469
+
470
+ if num_categories <= 12:
471
+ base_colors = [
472
+ '#FF0000', '#0066FF', '#00CC00', '#FF8800',
473
+ '#8800FF', '#FFFF00', '#FF0088', '#00FFFF',
474
+ '#88FF00', '#FF4400', '#0088FF', '#CC00FF'
475
+ ]
476
+ colors = base_colors[:num_categories]
477
+ else:
478
+ colors = []
479
+ for i in range(num_categories):
480
+ hue = (i * 360 / num_categories) % 360
481
+ sat = 0.85 if i % 2 == 0 else 0.95
482
+ val = 0.95 if i % 3 != 0 else 0.85
483
+
484
+ rgb = colorsys.hsv_to_rgb(hue/360, sat, val)
485
+ hex_color = '#{:02x}{:02x}{:02x}'.format(
486
+ int(rgb[0]*255), int(rgb[1]*255), int(rgb[2]*255)
487
+ )
488
+ colors.append(hex_color)
489
+
490
+ return dict(zip(unique_categories, colors))
491
+
492
+ def _generate_community_colors(self, my_dict):
493
+ """Generate colors for communities using the specified strategy"""
494
+ from collections import Counter
495
+
496
+ unique_communities = sorted(set(my_dict.values()))
497
+ community_sizes = Counter(my_dict.values())
498
+ sorted_communities = sorted(unique_communities,
499
+ key=lambda x: community_sizes[x],
500
+ reverse=True)
501
+ colors_rgb = self._generate_distinct_colors_rgb(len(unique_communities))
502
+ color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
503
+ if 0 in unique_communities:
504
+ color_map[0] = "#8B4513"
505
+
506
+ return color_map
507
+
508
+ def _generate_distinct_colors_rgb(self, n_colors):
509
+ """
510
+ Generate visually distinct RGB colors using HSV color space.
511
+ Colors are generated with maximum saturation and value, varying only in hue.
512
+ """
513
+ colors = []
514
+ for i in range(n_colors):
515
+ hue = i / n_colors
516
+ rgb = colorsys.hsv_to_rgb(hue, 1.0, 1.0) # S=1, V=1 for max saturation/brightness
517
+ hex_color = '#{:02x}{:02x}{:02x}'.format(
518
+ int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)
519
+ )
520
+ colors.append(hex_color)
521
+ return colors
522
+
523
+ def _compute_edge_data(self, pos):
524
+ """Compute edge coordinates and weights"""
525
+ edges = []
526
+ for u, v, data in self.graph.edges(data=True):
527
+ if u in pos and v in pos:
528
+ weight = data.get('weight', 1.0)
529
+ x = [pos[u][0], pos[v][0]]
530
+ y = [pos[u][1], pos[v][1]]
531
+ edges.append((x, y, weight))
532
+ return edges
533
+
534
+
535
+ class NetworkGraphWidget(QWidget):
536
+ """Interactive NetworkX graph visualization widget"""
537
+
538
+ node_selected = pyqtSignal(object) # Emits list of selected nodes
539
+
540
+ def __init__(self, parent=None, weight=False, geometric=False, component = False,
541
+ centroids=None, communities=False, community_dict=None,
542
+ identities=False, identity_dict=None, labels=False, z_size = False):
543
+ super().__init__(parent)
544
+
545
+ self.parent_window = parent
546
+ self.weight = weight
547
+ self.geometric = geometric
548
+ self.component = component
549
+ self.centroids = centroids or {}
550
+ self.communities = communities
551
+ self.community_dict = community_dict or {}
552
+ self.identities = identities
553
+ self.identity_dict = identity_dict or {}
554
+ self.labels = labels
555
+ self.z_size = z_size
556
+
557
+ # Graph data
558
+ self.graph = None
559
+ self.node_positions = {}
560
+ self.node_colors = []
561
+ self.node_sizes = []
562
+ self.node_items = {}
563
+ self.edge_items = []
564
+ self.label_items = {}
565
+ self.label_data = [] # Store label data for on-demand rendering
566
+ self.selected_nodes = set()
567
+ self.rendered = False
568
+
569
+ # CACHING for fast updates
570
+ self.cached_spots = [] # Full spot data with brushes
571
+ self.cached_node_to_index = {} # Node -> spot index mapping
572
+ self.cached_brushes = {} # Node -> {'normal': brush, 'selected': brush}
573
+ self.last_selected_set = set() # Track last selection state
574
+ self.cached_sizes_for_lod = [] # Base sizes for LOD scaling
575
+
576
+ # Interaction mode
577
+ self.selection_mode = True
578
+ self.zoom_mode = False
579
+
580
+ # Area selection
581
+ self.selection_rect = None
582
+ self.selection_start_pos = None
583
+ self.is_area_selecting = False
584
+ self.click_timer = None
585
+
586
+ # Middle mouse panning in selection mode
587
+ self.temp_pan_active = False
588
+ self.last_mouse_pos = None
589
+
590
+ # Wheel zoom timer for selection mode
591
+ self.wheel_timer = None
592
+ self.was_in_selection_before_wheel = False
593
+
594
+ # Thread for loading
595
+ self.load_thread = None
596
+
597
+ # Setup UI
598
+ self._setup_ui()
599
+
600
+ def _setup_ui(self):
601
+ """Setup the user interface"""
602
+ layout = QHBoxLayout() # Changed from QVBoxLayout to accommodate legend
603
+ layout.setContentsMargins(0, 0, 0, 0)
604
+ layout.setSpacing(2)
605
+
606
+ # Left side: graph container
607
+ graph_container = QWidget()
608
+ graph_layout = QVBoxLayout()
609
+ graph_layout.setContentsMargins(0, 0, 0, 0)
610
+ graph_layout.setSpacing(2)
611
+
612
+ # Create graphics layout widget
613
+ self.graphics_widget = pg.GraphicsLayoutWidget()
614
+ self.graphics_widget.setBackground('w')
615
+
616
+ # Create plot
617
+ self.plot = self.graphics_widget.addPlot()
618
+ self.plot.setAspectLocked(True)
619
+ self.plot.hideAxis('left')
620
+ self.plot.hideAxis('bottom')
621
+ self.plot.showGrid(x=False, y=False)
622
+ # Show loading indicator
623
+ self.loading_text = pg.TextItem(
624
+ text="No network detected",
625
+ color=(100, 100, 100),
626
+ anchor=(0.5, 0.5)
627
+ )
628
+ self.loading_text.setPos(0, 0) # Center of view
629
+ self.plot.addItem(self.loading_text)
630
+
631
+ # Enable mouse tracking for area selection
632
+ self.plot.scene().sigMouseClicked.connect(self._on_plot_clicked)
633
+ self.plot.scene().sigMouseMoved.connect(self._on_mouse_moved)
634
+
635
+ # Disable default mouse interaction - will enable only in pan mode
636
+ self.plot.setMouseEnabled(x=False, y=False)
637
+ self.plot.vb.setMenuEnabled(False)
638
+ self.plot.vb.setMouseMode(pg.ViewBox.PanMode)
639
+
640
+ # Create scatter plot for nodes
641
+ self.scatter = ScatterPlotItem(size=10, pen=pg.mkPen(None),
642
+ brush=pg.mkBrush(74, 144, 226, 200))
643
+ self.plot.addItem(self.scatter)
644
+
645
+ # Connect click events
646
+ self.scatter.sigClicked.connect(self._on_node_clicked)
647
+
648
+ # Connect view change for level-of-detail updates
649
+ self.plot.sigRangeChanged.connect(self._on_view_changed)
650
+
651
+ # Level of detail parameters
652
+ self.base_node_sizes = []
653
+ self.current_zoom_factor = 1.0
654
+
655
+ graph_layout.addWidget(self.graphics_widget, stretch=1) # Keep this one
656
+
657
+ # Create control panel
658
+ control_panel = self._create_control_panel()
659
+ graph_layout.addWidget(control_panel)
660
+
661
+ graph_container.setLayout(graph_layout)
662
+ layout.addWidget(graph_container, stretch=1)
663
+
664
+ # Right side: legend (placeholder, will be populated when graph loads)
665
+ self.legend_container = QWidget()
666
+ self.legend_layout = QVBoxLayout()
667
+ self.legend_layout.setContentsMargins(0, 0, 0, 0)
668
+ self.legend_container.setLayout(self.legend_layout)
669
+ self.legend_container.setMaximumWidth(0) # Hidden initially
670
+ layout.addWidget(self.legend_container)
671
+
672
+ self.setLayout(layout)
673
+
674
+ # Set size policy
675
+ self.setSizePolicy(QSizePolicy.Policy.Expanding,
676
+ QSizePolicy.Policy.Expanding)
677
+
678
+ # Install event filter for custom mouse handling
679
+ self.graphics_widget.viewport().installEventFilter(self)
680
+ self.plot.scene().installEventFilter(self)
681
+
682
+ def _create_identity_legend(self):
683
+ """Create a legend panel for node identities"""
684
+
685
+ def _generate_distinct_colors_rgb(n_colors: int):
686
+ """
687
+ Generate visually distinct RGB colors using HSV color space.
688
+ Colors are generated with maximum saturation and value, varying only in hue.
689
+ """
690
+ colors = []
691
+ for i in range(n_colors):
692
+ hue = i / n_colors
693
+ rgb = colorsys.hsv_to_rgb(hue, 1.0, 1.0) # S=1, V=1 for max saturation/brightness
694
+ hex_color = '#{:02x}{:02x}{:02x}'.format(
695
+ int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255)
696
+ )
697
+ colors.append(hex_color)
698
+ return colors
699
+
700
+ if self.identities:
701
+ from collections import Counter
702
+
703
+ unique_identities = sorted(set(self.identity_dict.values()))
704
+ community_sizes = Counter(self.identity_dict.values())
705
+ sorted_communities = sorted(unique_identities,
706
+ key=lambda x: community_sizes[x],
707
+ reverse=True)
708
+
709
+ colors_rgb = _generate_distinct_colors_rgb(len(unique_identities))
710
+ color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
711
+ if 0 in unique_identities:
712
+ color_map[0] = "#8B4513"
713
+ elif self.communities:
714
+ from collections import Counter
715
+
716
+ unique_identities = sorted(set(self.community_dict.values()))
717
+ community_sizes = Counter(self.community_dict.values())
718
+ sorted_communities = sorted(unique_identities,
719
+ key=lambda x: community_sizes[x],
720
+ reverse=True)
721
+
722
+ colors_rgb = _generate_distinct_colors_rgb(len(unique_identities))
723
+ color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
724
+ if 0 in unique_identities:
725
+ color_map[0] = "#8B4513"
726
+
727
+ # Create legend widget
728
+ legend_widget = QWidget()
729
+ legend_layout = QVBoxLayout()
730
+ legend_layout.setContentsMargins(5, 5, 5, 5)
731
+ legend_layout.setSpacing(2)
732
+
733
+ # Add title
734
+ if self.identities:
735
+ title = QLabel("Node Identities")
736
+ elif self.communities:
737
+ title = QLabel("Node Community/Neighborhood")
738
+ title.setStyleSheet("font-weight: bold; font-size: 11pt; padding: 3px;")
739
+ legend_layout.addWidget(title)
740
+
741
+ # Create scrollable area for legend items
742
+ scroll = QScrollArea()
743
+ scroll.setWidgetResizable(True)
744
+ scroll.setMaximumWidth(200)
745
+ scroll.setMinimumWidth(150)
746
+ scroll.setFrameShape(QFrame.Shape.StyledPanel)
747
+
748
+ # Container for legend items
749
+ items_widget = QWidget()
750
+ items_layout = QVBoxLayout()
751
+ items_layout.setContentsMargins(2, 2, 2, 2)
752
+ items_layout.setSpacing(3)
753
+
754
+ # Add each identity with colored box
755
+ for identity in unique_identities:
756
+ item_widget = QWidget()
757
+ item_layout = QHBoxLayout()
758
+ item_layout.setContentsMargins(0, 0, 0, 0)
759
+ item_layout.setSpacing(5)
760
+
761
+ # Color box
762
+ color_box = QLabel()
763
+ color_box.setFixedSize(16, 16)
764
+ color_box.setStyleSheet(f"background-color: {color_map[identity]}; border: 1px solid #888;")
765
+
766
+ # Label
767
+ label = QLabel(str(identity))
768
+ label.setStyleSheet("font-size: 9pt;")
769
+
770
+ item_layout.addWidget(color_box)
771
+ item_layout.addWidget(label)
772
+ item_layout.addStretch()
773
+
774
+ item_widget.setLayout(item_layout)
775
+ items_layout.addWidget(item_widget)
776
+
777
+ if '#808080' in self.node_colors:
778
+ item_widget = QWidget()
779
+ item_layout = QHBoxLayout()
780
+ item_layout.setContentsMargins(0, 0, 0, 0)
781
+ item_layout.setSpacing(5)
782
+
783
+ # Color box
784
+ color_box = QLabel()
785
+ color_box.setFixedSize(16, 16)
786
+ color_box.setStyleSheet(f"background-color: #808080; border: 1px solid #888;")
787
+
788
+ # Label
789
+ label = QLabel('Unassigned')
790
+ label.setStyleSheet("font-size: 9pt;")
791
+
792
+ item_layout.addWidget(color_box)
793
+ item_layout.addWidget(label)
794
+ item_layout.addStretch()
795
+
796
+ item_widget.setLayout(item_layout)
797
+ items_layout.addWidget(item_widget)
798
+
799
+ items_layout.addStretch()
800
+ items_widget.setLayout(items_layout)
801
+ scroll.setWidget(items_widget)
802
+
803
+ legend_layout.addWidget(scroll)
804
+ legend_widget.setLayout(legend_layout)
805
+
806
+ return legend_widget
807
+
808
+ def _create_control_panel(self):
809
+ """Create the control panel with emoji buttons"""
810
+ panel = QWidget()
811
+ panel_layout = QHBoxLayout()
812
+ panel_layout.setContentsMargins(2, 2, 2, 2)
813
+ panel_layout.setSpacing(2)
814
+
815
+ # Create buttons with emojis
816
+ self.select_btn = QPushButton("🖱️")
817
+ self.select_btn.setToolTip("Selection Tool")
818
+ self.select_btn.setCheckable(True)
819
+ self.select_btn.setChecked(True)
820
+ self.select_btn.setMaximumSize(32, 32)
821
+ self.select_btn.clicked.connect(self._toggle_selection_mode)
822
+
823
+ self.pan_btn = QPushButton("✋")
824
+ self.pan_btn.setToolTip("Pan Tool")
825
+ self.pan_btn.setCheckable(True)
826
+ self.pan_btn.setChecked(False)
827
+ self.pan_btn.setMaximumSize(32, 32)
828
+ self.pan_btn.clicked.connect(self._toggle_pan_mode)
829
+
830
+ self.zoom_btn = QPushButton("🔍")
831
+ self.zoom_btn.setToolTip("Zoom Tool (Left Click: Zoom Out, Right Click: Zoom In)")
832
+ self.zoom_btn.setCheckable(True)
833
+ self.zoom_btn.setMaximumSize(32, 32)
834
+ self.zoom_btn.clicked.connect(self._toggle_zoom_mode)
835
+
836
+ self.home_btn = QPushButton("🏠")
837
+ self.home_btn.setToolTip("Reset View")
838
+ self.home_btn.setMaximumSize(32, 32)
839
+ self.home_btn.clicked.connect(self._reset_view)
840
+
841
+ self.refresh_btn = QPushButton("🔄")
842
+ self.refresh_btn.setToolTip("Refresh Graph")
843
+ self.refresh_btn.setMaximumSize(32, 32)
844
+ self.refresh_btn.clicked.connect(self.load_graph)
845
+
846
+ self.settings_btn = QPushButton("⚙")
847
+ self.settings_btn.setToolTip("Render Settings")
848
+ self.settings_btn.setMaximumSize(32, 32)
849
+ self.settings_btn.clicked.connect(self.settings)
850
+
851
+ self.clear_btn = QPushButton("🗑️")
852
+ self.clear_btn.setToolTip("Clear Graph")
853
+ self.clear_btn.setMaximumSize(32, 32)
854
+ self.clear_btn.clicked.connect(self._clear_graph)
855
+
856
+ # Add buttons to layout
857
+ panel_layout.addWidget(self.select_btn)
858
+ panel_layout.addWidget(self.pan_btn)
859
+ panel_layout.addWidget(self.zoom_btn)
860
+ panel_layout.addWidget(self.home_btn)
861
+ panel_layout.addWidget(self.refresh_btn)
862
+ panel_layout.addWidget(self.settings_btn)
863
+ panel_layout.addWidget(self.clear_btn)
864
+ panel_layout.addStretch()
865
+
866
+ panel.setLayout(panel_layout)
867
+ panel.setMaximumHeight(40)
868
+
869
+ return panel
870
+
871
+ def settings(self):
872
+
873
+ self.parent_window.show_netshow_dialog(called = self)
874
+
875
+ def set_graph(self, graph):
876
+ """Set the NetworkX graph to visualize"""
877
+ self.graph = graph
878
+
879
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
880
+ self.plot.removeItem(self.loading_text)
881
+ self.loading_text = None
882
+
883
+ if self.graph is not None and not self.rendered:
884
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
885
+ self.plot.removeItem(self.loading_text)
886
+ self.loading_text = None
887
+ # Show loading indicator
888
+ self.loading_text = pg.TextItem(
889
+ text="Press 🔄 to load your graph",
890
+ color=(100, 100, 100),
891
+ anchor=(0.5, 0.5)
892
+ )
893
+ self.loading_text.setPos(0, 0) # Center of view
894
+ self.plot.addItem(self.loading_text)
895
+ elif not self.rendered:
896
+ self.loading_text = pg.TextItem(
897
+ text="No network detected",
898
+ color=(100, 100, 100),
899
+ anchor=(0.5, 0.5)
900
+ )
901
+ self.loading_text.setPos(0, 0) # Center of view
902
+ self.plot.addItem(self.loading_text)
903
+
904
+ def load_graph(self):
905
+ """Load and render the graph (in separate thread)"""
906
+
907
+ # Clear existing visualization
908
+ self._clear_graph()
909
+
910
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
911
+ self.plot.removeItem(self.loading_text)
912
+ self.loading_text = None
913
+
914
+ if self.graph is None or len(self.graph.nodes()) == 0:
915
+ # Show loading indicator
916
+ self.loading_text = pg.TextItem(
917
+ text="No network detected",
918
+ color=(100, 100, 100),
919
+ anchor=(0.5, 0.5)
920
+ )
921
+ self.loading_text.setPos(0, 0) # Center of view
922
+ self.plot.addItem(self.loading_text)
923
+ return
924
+
925
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
926
+ self.plot.removeItem(self.loading_text)
927
+ self.loading_text = None
928
+
929
+ # Show loading indicator
930
+ self.loading_text = pg.TextItem(
931
+ text="Loading graph...",
932
+ color=(100, 100, 100),
933
+ anchor=(0.5, 0.5)
934
+ )
935
+ self.loading_text.setPos(0, 0) # Center of view
936
+ self.plot.addItem(self.loading_text)
937
+
938
+ # Start loading in thread
939
+ self.load_thread = GraphLoadThread(
940
+ self.graph, self.geometric, self.component, self.centroids,
941
+ self.communities, self.community_dict,
942
+ self.identities, self.identity_dict, self.weight, self.z_size
943
+ )
944
+ self.load_thread.finished.connect(self._on_graph_loaded)
945
+ self.load_thread.start()
946
+
947
+ @pyqtSlot(object)
948
+ def _on_graph_loaded(self, result):
949
+ """Handle loaded graph data from thread"""
950
+ # Remove loading indicator
951
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
952
+ self.plot.removeItem(self.loading_text)
953
+ self.loading_text = None
954
+
955
+ self.node_positions = result['pos']
956
+ self.node_colors = result['colors']
957
+ self.node_sizes = result['sizes']
958
+ self.base_node_sizes = result['sizes'].copy()
959
+
960
+ # Cache the prepared data for fast updates
961
+ self.cached_spots = result['node_spots']
962
+ self.cached_brushes = result['brush_cache']
963
+ self.cached_sizes_for_lod = result['sizes'].copy()
964
+
965
+ # Build node-to-index mapping
966
+ self.cached_node_to_index = {spot['data']: i
967
+ for i, spot in enumerate(self.cached_spots)}
968
+
969
+ # Fast render - data is already prepared
970
+ self._render_prepared_data(result)
971
+
972
+ # Reset view to show entire graph
973
+ self.rendered = True
974
+ # Block signals during reset to avoid triggering _on_view_changed
975
+ self.plot.blockSignals(True)
976
+ self._reset_view()
977
+ self.plot.blockSignals(False)
978
+ # Add legend if identities are enabled
979
+ if (self.identities and self.identity_dict) or (self.communities and self.community_dict):
980
+ # Clear old legend
981
+ for i in reversed(range(self.legend_layout.count())):
982
+ self.legend_layout.itemAt(i).widget().setParent(None)
983
+
984
+ # Create and add new legend
985
+ legend = self._create_identity_legend()
986
+ if legend:
987
+ self.legend_layout.addWidget(legend)
988
+ self.legend_container.setMaximumWidth(200)
989
+ else:
990
+ self.legend_container.setMaximumWidth(0) # Hide if no identities
991
+ if len(self.parent_window.clicked_values['nodes']) > 0:
992
+ self.select_nodes(self.parent_window.clicked_values['nodes'])
993
+
994
+
995
+ def _render_prepared_data(self, result):
996
+ """Render pre-computed data (minimal main thread work)"""
997
+ # Clear old items
998
+ self.scatter.clear()
999
+ for item in self.edge_items:
1000
+ self.plot.removeItem(item)
1001
+ self.edge_items.clear()
1002
+ for label_item in self.label_items.values():
1003
+ self.plot.removeItem(label_item)
1004
+ self.label_items.clear()
1005
+
1006
+ # Render edges - batched by weight for efficiency
1007
+ edge_batches = result['edge_pens']
1008
+ if edge_batches:
1009
+ for batch in edge_batches:
1010
+ edge_line = PlotCurveItem(
1011
+ x=batch['x'],
1012
+ y=batch['y'],
1013
+ pen=pg.mkPen(color=(150, 150, 150, 100), width=batch['thickness']),
1014
+ connect='finite' # Break lines at NaN
1015
+ )
1016
+ self.plot.addItem(edge_line)
1017
+ self.edge_items.append(edge_line)
1018
+
1019
+ # Render nodes - use cached spots directly
1020
+ self.scatter.setData(spots=self.cached_spots)
1021
+ self.scatter.setZValue(10)
1022
+
1023
+ # Build node items mapping
1024
+ nodes = list(self.graph.nodes())
1025
+ self.node_items = {node: i for i, node in enumerate(nodes)}
1026
+
1027
+ # Store label data for later rendering
1028
+ self.label_data = result['label_data']
1029
+
1030
+ # Only render labels immediately if graph is small (< 100 nodes)
1031
+ if self.labels and len(self.label_data) < 100:
1032
+ self._update_labels_in_viewport(len(self.label_data))
1033
+
1034
+ def _render_nodes(self):
1035
+ """OPTIMIZED: Only update brushes for nodes that changed selection state"""
1036
+
1037
+ if not self.cached_spots or not self.cached_brushes:
1038
+ return
1039
+
1040
+ # Find nodes whose selection state changed
1041
+ newly_selected = self.selected_nodes - self.last_selected_set
1042
+ newly_deselected = self.last_selected_set - self.selected_nodes
1043
+
1044
+ # If nothing changed, skip update
1045
+ if not newly_selected and not newly_deselected:
1046
+ return
1047
+
1048
+ # Update only changed nodes using cached brushes
1049
+ for node in newly_selected:
1050
+ if node in self.cached_node_to_index:
1051
+ idx = self.cached_node_to_index[node]
1052
+ self.cached_spots[idx]['brush'] = self.cached_brushes[node]['selected']
1053
+
1054
+ for node in newly_deselected:
1055
+ if node in self.cached_node_to_index:
1056
+ idx = self.cached_node_to_index[node]
1057
+ self.cached_spots[idx]['brush'] = self.cached_brushes[node]['normal']
1058
+
1059
+ # Update the scatter plot with modified spots
1060
+ self.scatter.setData(spots=self.cached_spots)
1061
+
1062
+ # Update last selection state
1063
+ self.last_selected_set = self.selected_nodes.copy()
1064
+
1065
+ def _render_labels_batch(self, label_data_subset):
1066
+ """Render labels in batches (but still slow for large graphs)"""
1067
+ # Clear existing labels first
1068
+ for label_item in self.label_items.values():
1069
+ self.plot.removeItem(label_item)
1070
+ self.label_items.clear()
1071
+
1072
+ if not label_data_subset:
1073
+ return
1074
+
1075
+ # Batch size for yielding control back to event loop
1076
+ batch_size = 50
1077
+
1078
+ for i, label_info in enumerate(label_data_subset):
1079
+ text_item = pg.TextItem(
1080
+ text=label_info['text'],
1081
+ color=(0, 0, 0),
1082
+ anchor=(0.5, 0.5)
1083
+ )
1084
+ text_item.setPos(label_info['pos'][0], label_info['pos'][1])
1085
+ text_item.setZValue(20)
1086
+ self.plot.addItem(text_item)
1087
+ self.label_items[label_info['node']] = text_item
1088
+
1089
+ # Yield control periodically to keep UI responsive
1090
+ if (i + 1) % batch_size == 0:
1091
+ QApplication.processEvents()
1092
+
1093
+ def _update_labels_for_zoom(self):
1094
+ """Show/hide labels based on zoom level and graph size with viewport awareness"""
1095
+ if not self.labels or not self.label_data:
1096
+ return
1097
+
1098
+ num_nodes = len(self.label_data)
1099
+
1100
+ # Determine zoom threshold based on graph size
1101
+ if num_nodes < 100:
1102
+ zoom_threshold = 0 # Always show labels
1103
+ elif num_nodes < 500:
1104
+ zoom_threshold = 1.5
1105
+ else:
1106
+ zoom_threshold = 3.0
1107
+
1108
+ # Check if we're above the zoom threshold
1109
+ should_show_labels = self.current_zoom_factor > zoom_threshold
1110
+
1111
+ if should_show_labels:
1112
+ # Update labels based on current viewport
1113
+ self._update_labels_in_viewport(num_nodes)
1114
+ else:
1115
+ # Clear all labels when zoomed out below threshold
1116
+ if self.label_items:
1117
+ for label_item in self.label_items.values():
1118
+ self.plot.removeItem(label_item)
1119
+ self.label_items.clear()
1120
+
1121
+ def _update_labels_in_viewport(self, num_nodes):
1122
+ """Update labels to show only those in current viewport"""
1123
+ # Get current view range
1124
+ view_range = self.plot.viewRange()
1125
+ x_min, x_max = view_range[0]
1126
+ y_min, y_max = view_range[1]
1127
+
1128
+ # Find which labels should be visible
1129
+ visible_node_set = set()
1130
+ labels_to_render = []
1131
+
1132
+ for label_info in self.label_data:
1133
+ if x_min <= label_info['pos'][0] <= x_max and y_min <= label_info['pos'][1] <= y_max:
1134
+ visible_node_set.add(label_info['node'])
1135
+ labels_to_render.append(label_info)
1136
+
1137
+ # For small graphs or when not many visible labels, render all in viewport
1138
+ max_visible_labels = 200 if num_nodes >= 500 else 1000
1139
+
1140
+ if len(labels_to_render) > max_visible_labels:
1141
+ # Too many labels to render - skip
1142
+ if self.label_items:
1143
+ for label_item in self.label_items.values():
1144
+ self.plot.removeItem(label_item)
1145
+ self.label_items.clear()
1146
+ return
1147
+
1148
+ # Get currently rendered nodes
1149
+ current_node_set = set(self.label_items.keys())
1150
+
1151
+ # Remove labels for nodes no longer in viewport
1152
+ nodes_to_remove = current_node_set - visible_node_set
1153
+ for node in nodes_to_remove:
1154
+ if node in self.label_items:
1155
+ self.plot.removeItem(self.label_items[node])
1156
+ del self.label_items[node]
1157
+
1158
+ # Add labels for new nodes in viewport
1159
+ nodes_to_add = visible_node_set - current_node_set
1160
+ for label_info in labels_to_render:
1161
+ node = label_info['node']
1162
+ if node in nodes_to_add:
1163
+ text_item = pg.TextItem(
1164
+ text=label_info['text'],
1165
+ color=(0, 0, 0),
1166
+ anchor=(0.5, 0.5)
1167
+ )
1168
+ text_item.setPos(label_info['pos'][0], label_info['pos'][1])
1169
+ text_item.setZValue(20)
1170
+ self.plot.addItem(text_item)
1171
+ self.label_items[node] = text_item
1172
+
1173
+ def _render_labels(self):
1174
+ """Render node labels if enabled (legacy method - kept for compatibility)"""
1175
+ # Use the smart rendering instead
1176
+ if self.label_data:
1177
+ self._update_labels_for_zoom()
1178
+
1179
+ def _render_edges(self, edges):
1180
+ """Render edges with optional weight-based thickness (OPTIMIZED: single combined line)"""
1181
+ # Remove old edges
1182
+ for item in self.edge_items:
1183
+ self.plot.removeItem(item)
1184
+ self.edge_items.clear()
1185
+
1186
+ if not edges:
1187
+ return
1188
+
1189
+ # Get weight range for normalization if using weights
1190
+ if self.weight:
1191
+ weights = [w for _, _, w in edges]
1192
+ if len(weights) > 0:
1193
+ min_weight = min(weights)
1194
+ max_weight = max(weights)
1195
+ weight_range = max_weight - min_weight if max_weight > min_weight else 1
1196
+ else:
1197
+ weight_range = 1
1198
+ min_weight = 0
1199
+
1200
+ # Combine all edges into single line with NaN separators
1201
+ x_combined = []
1202
+ y_combined = []
1203
+ thicknesses = []
1204
+
1205
+ for x, y, weight in edges:
1206
+ if self.weight and 'weight_range' in locals() and weight_range > 0:
1207
+ # Normalize weight to thickness range (0.5 - 3.0)
1208
+ normalized = (weight - min_weight) / weight_range
1209
+ thickness = 0.5 + normalized * 2.5
1210
+ else:
1211
+ thickness = 1.0
1212
+
1213
+ thicknesses.append(thickness)
1214
+
1215
+ # Add edge coordinates with NaN separator
1216
+ x_combined.extend([x[0], x[1], np.nan])
1217
+ y_combined.extend([y[0], y[1], np.nan])
1218
+
1219
+ # Use average thickness
1220
+ avg_thickness = np.mean(thicknesses) if thicknesses else 1.0
1221
+
1222
+ # Create single line with all edges
1223
+ edge_line = PlotCurveItem(
1224
+ x=np.array(x_combined),
1225
+ y=np.array(y_combined),
1226
+ pen=pg.mkPen(color=(150, 150, 150, 100), width=avg_thickness),
1227
+ connect='finite'
1228
+ )
1229
+ self.plot.addItem(edge_line)
1230
+ self.edge_items.append(edge_line)
1231
+
1232
+ # Ensure nodes are on top
1233
+ self.scatter.setZValue(10)
1234
+
1235
+ def _hex_to_rgb(self, hex_color):
1236
+ """Convert hex color to RGB tuple"""
1237
+ hex_color = hex_color.lstrip('#')
1238
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
1239
+
1240
+ def _on_plot_clicked(self, ev):
1241
+ """Handle clicks on the plot background"""
1242
+ if not self.selection_mode:
1243
+ return
1244
+
1245
+ # Only handle left button clicks
1246
+ if ev.button() != Qt.MouseButton.LeftButton:
1247
+ return
1248
+
1249
+ # Get the position in scene coordinates
1250
+ scene_pos = ev.scenePos()
1251
+
1252
+ # Check if click was on a node (scatter will handle it)
1253
+ items = self.plot.scene().items(scene_pos)
1254
+ for item in items:
1255
+ if item == self.scatter:
1256
+ # Click was on scatter plot, let node handler deal with it
1257
+ return
1258
+
1259
+ # Click was on background
1260
+ modifiers = ev.modifiers()
1261
+ ctrl_pressed = modifiers & Qt.KeyboardModifier.ControlModifier
1262
+
1263
+ if not ctrl_pressed:
1264
+ # Deselect all nodes
1265
+ self.selected_nodes.clear()
1266
+ self._render_nodes()
1267
+ self.node_selected.emit([])
1268
+
1269
+ # Ctrl+Click on background does nothing (as requested)
1270
+
1271
+ def _on_mouse_moved(self, pos):
1272
+ """Handle mouse movement for area selection"""
1273
+ if self.is_area_selecting and self.selection_rect:
1274
+ # Update selection rectangle
1275
+ view_pos = self.plot.vb.mapSceneToView(pos)
1276
+ start_pos = self.selection_start_pos
1277
+
1278
+ # Update rectangle size
1279
+ width = view_pos.x() - start_pos.x()
1280
+ height = view_pos.y() - start_pos.y()
1281
+
1282
+ self.selection_rect.setSize([width, height])
1283
+
1284
+ def _start_area_selection(self, scene_pos):
1285
+ """Start area selection with rectangle"""
1286
+ self.is_area_selecting = True
1287
+
1288
+ # Convert to view coordinates
1289
+ view_pos = self.plot.vb.mapSceneToView(scene_pos)
1290
+ self.selection_start_pos = view_pos
1291
+
1292
+ # Create selection rectangle
1293
+ if self.selection_rect:
1294
+ self.plot.removeItem(self.selection_rect)
1295
+
1296
+ # Create ROI for selection area
1297
+ self.selection_rect = pg.ROI(
1298
+ [view_pos.x(), view_pos.y()],
1299
+ [0, 0],
1300
+ pen=pg.mkPen('b', width=2, style=Qt.PenStyle.DashLine),
1301
+ movable=False,
1302
+ resizable=False
1303
+ )
1304
+ self.selection_rect.setAcceptedMouseButtons(Qt.MouseButton.NoButton)
1305
+ self.plot.addItem(self.selection_rect)
1306
+
1307
+ def _finish_area_selection(self, ev):
1308
+ """Finish area selection and select nodes in rectangle"""
1309
+ if not self.is_area_selecting or not self.selection_rect:
1310
+ return
1311
+
1312
+ # Get rectangle bounds
1313
+ rect_pos = self.selection_rect.pos()
1314
+ rect_size = self.selection_rect.size()
1315
+
1316
+ x_min = rect_pos[0]
1317
+ y_min = rect_pos[1]
1318
+ x_max = x_min + rect_size[0]
1319
+ y_max = y_min + rect_size[1]
1320
+
1321
+ # Normalize bounds (in case user dragged backwards)
1322
+ if x_min > x_max:
1323
+ x_min, x_max = x_max, x_min
1324
+ if y_min > y_max:
1325
+ y_min, y_max = y_max, y_min
1326
+
1327
+ # Remove selection rectangle
1328
+ if self.selection_rect:
1329
+ self.plot.removeItem(self.selection_rect)
1330
+ self.selection_rect = None
1331
+
1332
+ self.is_area_selecting = False
1333
+ self.selection_start_pos = None
1334
+
1335
+ # ZOOM MODE: Zoom to rectangle
1336
+ if self.zoom_mode:
1337
+ # Add small padding (5%)
1338
+ x_range = x_max - x_min
1339
+ y_range = y_max - y_min
1340
+ padding = 0.05
1341
+
1342
+ self.plot.setXRange(x_min - padding * x_range,
1343
+ x_max + padding * x_range, padding=0)
1344
+ self.plot.setYRange(y_min - padding * y_range,
1345
+ y_max + padding * y_range, padding=0)
1346
+ return
1347
+
1348
+ # SELECTION MODE: Select nodes in rectangle
1349
+ if self.selection_mode:
1350
+ # Find nodes in rectangle
1351
+ selected_in_rect = []
1352
+ for node, pos in self.node_positions.items():
1353
+ if x_min <= pos[0] <= x_max and y_min <= pos[1] <= y_max:
1354
+ selected_in_rect.append(node)
1355
+
1356
+ # Add to selection
1357
+ modifiers = ev.modifiers()
1358
+ ctrl_pressed = modifiers & Qt.KeyboardModifier.ControlModifier
1359
+
1360
+ if not ctrl_pressed:
1361
+ self.selected_nodes = set()
1362
+ self.selected_nodes.update(selected_in_rect)
1363
+ self.push_selection()
1364
+
1365
+ # Update visual representation
1366
+ self._render_nodes()
1367
+
1368
+ # Emit signal
1369
+ self.node_selected.emit(list(self.selected_nodes))
1370
+
1371
+ def _toggle_selection_mode(self):
1372
+ """Toggle selection mode"""
1373
+ self.selection_mode = self.select_btn.isChecked()
1374
+
1375
+ if self.selection_mode:
1376
+ self.pan_btn.setChecked(False)
1377
+ self.zoom_btn.setChecked(False)
1378
+ self.zoom_mode = False
1379
+ # Disable panning, but allow wheel events to be handled manually
1380
+ self.plot.setCursor(Qt.CursorShape.ArrowCursor)
1381
+ self.plot.vb.setMenuEnabled(False)
1382
+ self.plot.setMouseEnabled(x=False, y=False)
1383
+ else:
1384
+ # If nothing else is checked, check pan by default
1385
+ if not self.pan_btn.isChecked() and not self.zoom_btn.isChecked():
1386
+ self.pan_btn.click()
1387
+
1388
+ def _toggle_pan_mode(self):
1389
+ """Toggle pan mode"""
1390
+ if self.pan_btn.isChecked():
1391
+ self.select_btn.setChecked(False)
1392
+ self.zoom_btn.setChecked(False)
1393
+ self.selection_mode = False
1394
+ self.zoom_mode = False
1395
+ # Enable panning
1396
+ self.plot.vb.setMenuEnabled(True)
1397
+ self.plot.setCursor(Qt.CursorShape.OpenHandCursor)
1398
+ self.plot.setMouseEnabled(x=True, y=True)
1399
+ else:
1400
+ # Disable panning
1401
+ if not self.select_btn.isChecked() and not self.zoom_btn.isChecked():
1402
+ self.select_btn.click()
1403
+
1404
+ def _toggle_zoom_mode(self):
1405
+ """Toggle zoom mode"""
1406
+ self.zoom_mode = self.zoom_btn.isChecked()
1407
+
1408
+ if self.zoom_mode:
1409
+ self.select_btn.setChecked(False)
1410
+ self.pan_btn.setChecked(False)
1411
+ self.selection_mode = False
1412
+ # Disable default panning for zoom mode
1413
+ self.plot.setCursor(Qt.CursorShape.CrossCursor)
1414
+ self.plot.vb.setMenuEnabled(False)
1415
+ self.plot.setMouseEnabled(x=False, y=False)
1416
+ else:
1417
+ # If nothing else is checked, check pan by default
1418
+ if not self.pan_btn.isChecked() and not self.select_btn.isChecked():
1419
+ self.select_btn.click()
1420
+
1421
+ def eventFilter(self, obj, event):
1422
+ """Filter events for custom mouse handling"""
1423
+ from PyQt6.QtCore import QEvent
1424
+ from PyQt6.QtGui import QMouseEvent
1425
+
1426
+ # Only handle events for the graphics scene
1427
+ if obj != self.plot.scene():
1428
+ return super().eventFilter(obj, event)
1429
+
1430
+ if event.type() == QEvent.Type.GraphicsSceneMousePress:
1431
+ # Handle middle mouse button for temporary panning in selection mode
1432
+ if event.button() == Qt.MouseButton.MiddleButton:
1433
+ if self.selection_mode or self.zoom_mode:
1434
+ self._start_temp_pan()
1435
+ return False # Let the event propagate for panning
1436
+
1437
+ # SELECTION MODE: Handle left button for area selection
1438
+ elif event.button() == Qt.MouseButton.LeftButton and (self.selection_mode or self.zoom_mode):
1439
+ # Store position and start timer for long press detection
1440
+ self.last_mouse_pos = event.scenePos()
1441
+ if not self.click_timer:
1442
+ self.click_timer = QTimer()
1443
+ self.click_timer.setSingleShot(True)
1444
+ self.click_timer.timeout.connect(self._on_long_press)
1445
+ self.click_timer.start(200) # 200ms threshold for area selection
1446
+
1447
+ elif event.type() == QEvent.Type.GraphicsSceneMouseMove:
1448
+ # Check if we should start area selection
1449
+ if (self.selection_mode or self.zoom_mode) and self.click_timer and self.click_timer.isActive():
1450
+ if self.last_mouse_pos:
1451
+ # Check if mouse moved significantly
1452
+ current_pos = event.scenePos()
1453
+ delta_x = abs(current_pos.x() - self.last_mouse_pos.x())
1454
+ delta_y = abs(current_pos.y() - self.last_mouse_pos.y())
1455
+
1456
+ if delta_x > 10 or delta_y > 10: # Moved significantly
1457
+ self.click_timer.stop()
1458
+ if not self.is_area_selecting:
1459
+ self._start_area_selection(self.last_mouse_pos)
1460
+
1461
+
1462
+ elif event.type() == QEvent.Type.GraphicsSceneMouseRelease:
1463
+ # Handle middle mouse release
1464
+ if event.button() == Qt.MouseButton.MiddleButton:
1465
+ if self.temp_pan_active:
1466
+ self._end_temp_pan()
1467
+ return False # Let event propagate
1468
+
1469
+ # Handle left button release in selection mode
1470
+ elif event.button() == Qt.MouseButton.LeftButton and (self.selection_mode or self.zoom_mode):
1471
+ if self.click_timer and self.click_timer.isActive():
1472
+ self.click_timer.stop()
1473
+
1474
+ if self.is_area_selecting:
1475
+ self._finish_area_selection(event)
1476
+ return True # Consume event
1477
+ elif event.button() == Qt.MouseButton.RightButton and self.selection_mode:
1478
+ mouse_point = self.plot.getViewBox().mapSceneToView(event.scenePos())
1479
+ x, y = mouse_point.x(), mouse_point.y()
1480
+ self.create_context_menu(event)
1481
+
1482
+ # ZOOM MODE: Handle left/right click for zoom
1483
+ if self.zoom_mode:
1484
+ if event.button() == Qt.MouseButton.LeftButton:
1485
+ # Zoom in
1486
+ self._zoom_at_point(event.scenePos(), 2)
1487
+ return True
1488
+ elif event.button() == Qt.MouseButton.RightButton:
1489
+ # Zoom out
1490
+ self._zoom_at_point(event.scenePos(), 0.5)
1491
+ return True
1492
+
1493
+ elif event.type() == QEvent.Type.GraphicsSceneWheel:
1494
+ # Handle wheel events in selection mode
1495
+ if self.selection_mode or self.zoom_mode:
1496
+ self._handle_wheel_in_selection(event)
1497
+ return False # Let event propagate for actual zooming
1498
+
1499
+ return super().eventFilter(obj, event)
1500
+
1501
+ def _on_long_press(self):
1502
+ """Handle long press for area selection"""
1503
+ if (self.selection_mode or self.zoom_mode) and self.last_mouse_pos:
1504
+ # Start area selection at stored mouse position
1505
+ self._start_area_selection(self.last_mouse_pos)
1506
+
1507
+ def _start_temp_pan(self):
1508
+ """Start temporary panning mode with middle mouse"""
1509
+ self.temp_pan_active = True
1510
+ # Temporarily enable panning
1511
+ self.plot.setMouseEnabled(x=True, y=True)
1512
+
1513
+ def _end_temp_pan(self):
1514
+ """End temporary panning mode"""
1515
+ self.temp_pan_active = False
1516
+ # Disable mouse if we're in selection mode
1517
+ if self.selection_mode:
1518
+ self.plot.setMouseEnabled(x=False, y=False)
1519
+
1520
+ def _handle_wheel_in_selection(self, event):
1521
+ """Handle wheel events in selection mode - temporarily enable pan for zoom"""
1522
+ # Temporarily enable mouse for zooming
1523
+ if not self.was_in_selection_before_wheel:
1524
+ self.was_in_selection_before_wheel = True
1525
+ self.plot.setMouseEnabled(x=True, y=True)
1526
+
1527
+ # Reset or create timer
1528
+ if not self.wheel_timer:
1529
+ self.wheel_timer = QTimer()
1530
+ self.wheel_timer.setSingleShot(True)
1531
+ self.wheel_timer.timeout.connect(self._end_wheel_zoom)
1532
+
1533
+ # Restart timer (500ms after last wheel event)
1534
+ self.wheel_timer.start(500)
1535
+
1536
+ def _end_wheel_zoom(self):
1537
+ """End wheel zoom and return to selection mode"""
1538
+ if self.was_in_selection_before_wheel and self.selection_mode:
1539
+ self.plot.setMouseEnabled(x=False, y=False)
1540
+ self.was_in_selection_before_wheel = False
1541
+
1542
+ def _zoom_at_point(self, scene_pos, scale_factor):
1543
+ """Zoom in or out at a specific point"""
1544
+ # Convert scene position to view coordinates
1545
+ view_pos = self.plot.vb.mapSceneToView(scene_pos)
1546
+
1547
+ # Get current view range
1548
+ view_range = self.plot.viewRange()
1549
+ x_range = view_range[0]
1550
+ y_range = view_range[1]
1551
+
1552
+ # Calculate current center and size
1553
+ x_center = (x_range[0] + x_range[1]) / 2
1554
+ y_center = (y_range[0] + y_range[1]) / 2
1555
+ x_size = x_range[1] - x_range[0]
1556
+ y_size = y_range[1] - y_range[0]
1557
+
1558
+ # Calculate new size
1559
+ new_x_size = x_size / scale_factor
1560
+ new_y_size = y_size / scale_factor
1561
+
1562
+ # Calculate offset to zoom toward the point
1563
+ x_offset = (view_pos.x() - x_center) * (1 - 1/scale_factor)
1564
+ y_offset = (view_pos.y() - y_center) * (1 - 1/scale_factor)
1565
+
1566
+ # Set new range
1567
+ new_x_center = x_center + x_offset
1568
+ new_y_center = y_center + y_offset
1569
+
1570
+ self.plot.setXRange(new_x_center - new_x_size/2, new_x_center + new_x_size/2, padding=0)
1571
+ self.plot.setYRange(new_y_center - new_y_size/2, new_y_center + new_y_size/2, padding=0)
1572
+
1573
+ def _reset_view(self):
1574
+ """Reset view to show entire graph"""
1575
+ if not self.node_positions:
1576
+ return
1577
+
1578
+ nodes = list(self.node_positions.keys())
1579
+ if not nodes:
1580
+ return
1581
+
1582
+ pos_array = np.array([self.node_positions[n] for n in nodes])
1583
+
1584
+ # Get bounds
1585
+ x_min, y_min = pos_array.min(axis=0)
1586
+ x_max, y_max = pos_array.max(axis=0)
1587
+
1588
+ # Add padding
1589
+ padding = 0.1
1590
+ x_range = x_max - x_min
1591
+ y_range = y_max - y_min
1592
+
1593
+ self.plot.setXRange(x_min - padding * x_range,
1594
+ x_max + padding * x_range, padding=0)
1595
+ self.plot.setYRange(y_min - padding * y_range,
1596
+ y_max + padding * y_range, padding=0)
1597
+
1598
+ def _clear_graph(self):
1599
+ """Clear the graph visualization"""
1600
+ if self.load_thread is not None and self.load_thread.isRunning():
1601
+ self.load_thread.finished.disconnect()
1602
+ self.load_thread.terminate() # Forcefully kill the thread
1603
+ self.load_thread.wait() # Wait for it to fully terminate
1604
+ self.load_thread = None # Clear the reference
1605
+
1606
+ # Remove loading indicator if it exists
1607
+ if hasattr(self, 'loading_text') and self.loading_text is not None:
1608
+ self.plot.removeItem(self.loading_text)
1609
+ self.loading_text = None
1610
+
1611
+ # Clear scatter plot
1612
+ self.scatter.clear()
1613
+
1614
+ # Clear edges
1615
+ for item in self.edge_items:
1616
+ self.plot.removeItem(item)
1617
+ self.edge_items.clear()
1618
+
1619
+ # Force clear all labels - be aggressive
1620
+ # First clear from our tracking dict
1621
+ if hasattr(self, 'label_items') and self.label_items:
1622
+ for label_item in list(self.label_items.values()):
1623
+ try:
1624
+ self.plot.removeItem(label_item)
1625
+ except:
1626
+ pass
1627
+ self.label_items.clear()
1628
+
1629
+ #Remove legend
1630
+ try:
1631
+ for i in reversed(range(self.legend_layout.count())):
1632
+ self.legend_layout.itemAt(i).widget().setParent(None)
1633
+ except:
1634
+ pass
1635
+
1636
+ # remove ALL TextItems from the plot
1637
+ # This catches any labels that might not be tracked properly
1638
+ items_to_remove = []
1639
+ for item in self.plot.items:
1640
+ if isinstance(item, pg.TextItem):
1641
+ items_to_remove.append(item)
1642
+ for item in items_to_remove:
1643
+ self.plot.removeItem(item)
1644
+
1645
+ # Clear selection rectangle if exists
1646
+ if self.selection_rect:
1647
+ self.plot.removeItem(self.selection_rect)
1648
+ self.selection_rect = None
1649
+
1650
+ # Clear data
1651
+ self.node_positions.clear()
1652
+ self.node_items.clear()
1653
+ self.selected_nodes.clear()
1654
+ self.rendered = False
1655
+ if hasattr(self, 'label_data'):
1656
+ self.label_data.clear()
1657
+
1658
+ # Clear cache
1659
+ self.cached_spots.clear()
1660
+ self.cached_node_to_index.clear()
1661
+ self.cached_brushes.clear()
1662
+ self.last_selected_set.clear()
1663
+ self.cached_sizes_for_lod.clear()
1664
+
1665
+ if self.graph is None or len(self.graph.nodes()) == 0:
1666
+ # Show loading indicator
1667
+ self.loading_text = pg.TextItem(
1668
+ text="No network detected",
1669
+ color=(100, 100, 100),
1670
+ anchor=(0.5, 0.5)
1671
+ )
1672
+ else:
1673
+ # Show loading indicator
1674
+ self.loading_text = pg.TextItem(
1675
+ text="Press 🔄 to load your graph",
1676
+ color=(100, 100, 100),
1677
+ anchor=(0.5, 0.5)
1678
+ )
1679
+
1680
+ self.loading_text.setPos(0, 0) # Center of view
1681
+ self.plot.addItem(self.loading_text)
1682
+
1683
+ def select_nodes(self, nodes, add_to_selection=False):
1684
+ """
1685
+ Programmatically select nodes.
1686
+
1687
+ Parameters:
1688
+ -----------
1689
+ nodes : list
1690
+ List of node IDs to select
1691
+ add_to_selection : bool
1692
+ If True, add to existing selection. If False, replace selection.
1693
+ """
1694
+ if not add_to_selection:
1695
+ self.selected_nodes.clear()
1696
+
1697
+ # Add valid nodes to selection
1698
+ for node in nodes:
1699
+ if node in self.node_items:
1700
+ self.selected_nodes.add(node)
1701
+
1702
+ # Update visual representation
1703
+ self._render_nodes()
1704
+
1705
+ # Emit signal
1706
+ self.node_selected.emit(list(self.selected_nodes))
1707
+
1708
+ def clear_selection(self):
1709
+ """Clear all selected nodes"""
1710
+ self.selected_nodes.clear()
1711
+ self._render_nodes()
1712
+ self.node_selected.emit([])
1713
+
1714
+ def _on_node_clicked(self, scatter, points, ev):
1715
+ """Handle node click events"""
1716
+ if not self.selection_mode or len(points) == 0:
1717
+ return
1718
+
1719
+ # Get clicked node
1720
+ point = points[0]
1721
+ clicked_node = point.data()
1722
+
1723
+ # Check if Ctrl is pressed
1724
+ modifiers = ev.modifiers()
1725
+ ctrl_pressed = modifiers & Qt.KeyboardModifier.ControlModifier
1726
+
1727
+ if ctrl_pressed:
1728
+ # Toggle selection for this node
1729
+ if clicked_node in self.selected_nodes:
1730
+ self.selected_nodes.remove(clicked_node)
1731
+ else:
1732
+ self.selected_nodes.add(clicked_node)
1733
+ else:
1734
+ # Clear previous selection and select only this node
1735
+ self.selected_nodes.clear()
1736
+ self.selected_nodes.add(clicked_node)
1737
+ self.push_selection()
1738
+
1739
+ # Update visual representation
1740
+ self._render_nodes()
1741
+
1742
+ # Emit signal with all selected nodes
1743
+ self.node_selected.emit(list(self.selected_nodes))
1744
+
1745
+ def push_selection(self):
1746
+ self.parent_window.clicked_values['nodes'] = list(self.selected_nodes)
1747
+ self.parent_window.evaluate_mini(subgraph_push = True)
1748
+ self.parent_window.handle_info('node')
1749
+
1750
+ def get_selected_nodes(self):
1751
+ """Get the list of currently selected nodes"""
1752
+ return list(self.selected_nodes)
1753
+
1754
+ def get_selected_node(self):
1755
+ """
1756
+ Get a single selected node (for backwards compatibility).
1757
+ Returns the first selected node or None.
1758
+ """
1759
+ if self.selected_nodes:
1760
+ return next(iter(self.selected_nodes))
1761
+ return None
1762
+
1763
+
1764
+ def handle_find_action(self):
1765
+ try:
1766
+ val = self.parent_window.clicked_values['nodes'][-1]
1767
+ self.parent_window.handle_info(sort = 'node')
1768
+ if val in self.centroids:
1769
+ centroid = self.centroids[val]
1770
+ self.parent_window.set_active_channel(0)
1771
+ # Toggle on the nodes channel if it's not already visible
1772
+ if not self.parent_window.channel_visible[0]:
1773
+ self.parent_window.channel_buttons[0].setChecked(True)
1774
+ self.parent_window.toggle_channel(0)
1775
+ # Navigate to the Z-slice
1776
+ self.parent_window.slice_slider.setValue(int(centroid[0]))
1777
+ print(f"Found node {val} at [Z,Y,X] -> {centroid}")
1778
+ self.push_selection()
1779
+ except:
1780
+ pass
1781
+
1782
+
1783
+ def save_table_as(self, file_type):
1784
+ """Save the table data as either CSV or Excel file."""
1785
+
1786
+ if self != self.parent_window.selection_graph_widget:
1787
+ table_name = "Network"
1788
+ df = self.parent_window.network_table.model()._data
1789
+ else:
1790
+ df = self.parent_window.selection_table.model()._data
1791
+ table_name = "Selection"
1792
+
1793
+ # Get save file name
1794
+ file_filter = ("CSV Files (*.csv)" if file_type == 'csv' else
1795
+ "Excel Files (*.xlsx)" if file_type == 'xlsx' else
1796
+ "Gephi Graph (*.gexf)" if file_type == 'gexf' else
1797
+ "GraphML (*.graphml)" if file_type == 'graphml' else
1798
+ "Pajek Network (*.net)")
1799
+
1800
+ filename, _ = QFileDialog.getSaveFileName(
1801
+ self,
1802
+ f"Save {table_name} Table As",
1803
+ "",
1804
+ file_filter
1805
+ )
1806
+
1807
+ if filename:
1808
+ try:
1809
+ if file_type == 'csv':
1810
+ # If user didn't type extension, add .csv
1811
+ if not filename.endswith('.csv'):
1812
+ filename += '.csv'
1813
+ df.to_csv(filename, index=False)
1814
+ elif file_type == 'xlsx':
1815
+ # If user didn't type extension, add .xlsx
1816
+ if not filename.endswith('.xlsx'):
1817
+ filename += '.xlsx'
1818
+ df.to_excel(filename, index=False)
1819
+ elif file_type == 'gexf':
1820
+ # If user didn't type extension, add .gexf
1821
+ if not filename.endswith('.gexf'):
1822
+ filename += '.gexf'
1823
+ #for node in my_network.network.nodes():
1824
+ #my_network.network.nodes[node]['label'] = str(node)
1825
+ nx.write_gexf(self.graph, filename, encoding='utf-8', prettyprint=True)
1826
+ elif file_type == 'graphml':
1827
+ # If user didn't type extension, add .graphml
1828
+ if not filename.endswith('.graphml'):
1829
+ filename += '.graphml'
1830
+ nx.write_graphml(self.graph, filename)
1831
+ elif file_type == 'net':
1832
+ # If user didn't type extension, add .net
1833
+ if not filename.endswith('.net'):
1834
+ filename += '.net'
1835
+ nx.write_pajek(self.graph, filename)
1836
+
1837
+ QMessageBox.information(
1838
+ self,
1839
+ "Success",
1840
+ f"{table_name} table successfully saved to {filename}"
1841
+ )
1842
+ except Exception as e:
1843
+ QMessageBox.critical(
1844
+ self,
1845
+ "Error",
1846
+ f"Failed to save file: {str(e)}"
1847
+ )
1848
+
1849
+
1850
+ def create_context_menu(self, event):
1851
+ # Get the index at the clicked position
1852
+ # Create context menu
1853
+ context_menu = QMenu(self)
1854
+
1855
+ find_action = context_menu.addAction("Find Node")
1856
+
1857
+ find_action.triggered.connect(self.handle_find_action)
1858
+ neigh_action = context_menu.addAction("Show Neighbors")
1859
+ neigh_action.triggered.connect(self.parent_window.handle_show_neighbors)
1860
+ com_action = context_menu.addAction("Show Community")
1861
+ com_action.triggered.connect(self.parent_window.handle_show_communities)
1862
+ comp_action = context_menu.addAction("Show Connected Component")
1863
+ comp_action.triggered.connect(self.parent_window.handle_show_component)
1864
+ # Add separator
1865
+ context_menu.addSeparator()
1866
+
1867
+ # Add Save As menu
1868
+ save_menu = context_menu.addMenu("Save As")
1869
+ save_csv = save_menu.addAction("CSV")
1870
+ save_excel = save_menu.addAction("Excel")
1871
+ save_gephi = save_menu.addAction("Gephi")
1872
+ save_graphml = save_menu.addAction("GraphML")
1873
+ save_pajek = save_menu.addAction("Pajek")
1874
+
1875
+ # Connect the actions - ensure we're saving the active table
1876
+ save_csv.triggered.connect(lambda: self.save_table_as('csv'))
1877
+ save_excel.triggered.connect(lambda: self.save_table_as('xlsx'))
1878
+ save_gephi.triggered.connect(lambda: self.save_table_as('gexf'))
1879
+ save_graphml.triggered.connect(lambda: self.save_table_as('graphml'))
1880
+ save_pajek.triggered.connect(lambda: self.save_table_as('net'))
1881
+
1882
+
1883
+ if self == self.parent_window.selection_graph_widget:
1884
+ set_action = context_menu.addAction("Swap with network table (also sets internal network properties - may affect related functions)")
1885
+ set_action.triggered.connect(self.parent_window.selection_table.set_selection_to_active)
1886
+
1887
+ # Show the menu at cursor position
1888
+ view_widget = self.plot.getViewWidget()
1889
+
1890
+ # Map scene position to view coordinates
1891
+ view_pos = view_widget.mapFromScene(event.scenePos())
1892
+
1893
+ # Map to global screen coordinates
1894
+ global_pos = view_widget.mapToGlobal(view_pos)
1895
+
1896
+ # Show the menu
1897
+ context_menu.exec(global_pos)
1898
+
1899
+ def update_params(self, weight=None, geometric=None, component = None, centroids=None,
1900
+ communities=None, community_dict=None,
1901
+ identities=None, identity_dict=None, labels=None, z_size = None):
1902
+ """Update visualization parameters"""
1903
+ if weight is not None:
1904
+ self.weight = weight
1905
+ if geometric is not None:
1906
+ self.geometric = geometric
1907
+ if component is not None:
1908
+ self.component = component
1909
+ if centroids is not None:
1910
+ self.centroids = centroids
1911
+ if communities is not None:
1912
+ self.communities = communities
1913
+ if community_dict is not None:
1914
+ self.community_dict = community_dict
1915
+ if identities is not None:
1916
+ self.identities = identities
1917
+ if identity_dict is not None:
1918
+ self.identity_dict = identity_dict
1919
+ if labels is not None:
1920
+ self.labels = labels
1921
+ if z_size is not None:
1922
+ self.z_size = z_size
1923
+
1924
+ def _on_view_changed(self):
1925
+ """Handle view range changes for level-of-detail adjustments"""
1926
+ if not self.node_positions or len(self.node_positions) == 0:
1927
+ return
1928
+
1929
+ # Calculate current zoom factor based on view range
1930
+ view_range = self.plot.viewRange()
1931
+ x_range = view_range[0][1] - view_range[0][0]
1932
+ y_range = view_range[1][1] - view_range[1][0]
1933
+
1934
+ # Get initial full graph bounds
1935
+ nodes = list(self.node_positions.keys())
1936
+ pos_array = np.array([self.node_positions[n] for n in nodes])
1937
+
1938
+ if len(pos_array) > 0:
1939
+ full_x_range = pos_array[:, 0].max() - pos_array[:, 0].min()
1940
+ full_y_range = pos_array[:, 1].max() - pos_array[:, 1].min()
1941
+
1942
+ if full_x_range > 0 and full_y_range > 0:
1943
+ # Calculate zoom factor (smaller view range = more zoomed in)
1944
+ zoom_x = full_x_range / x_range if x_range > 0 else 1
1945
+ zoom_y = full_y_range / y_range if y_range > 0 else 1
1946
+ zoom_factor = max(zoom_x, zoom_y)
1947
+
1948
+ # Update if zoom changed significantly (>10% change)
1949
+ zoom_changed = abs(zoom_factor - self.current_zoom_factor) / max(self.current_zoom_factor, 0.01) > 0.1
1950
+ if zoom_changed:
1951
+ self.current_zoom_factor = zoom_factor
1952
+ self._update_lod_rendering()
1953
+ else:
1954
+ # Even if zoom didn't change, update labels for panning
1955
+ # (viewport changed but zoom level stayed the same)
1956
+ if self.labels:
1957
+ self._update_labels_for_zoom()
1958
+
1959
+ def _update_lod_rendering(self):
1960
+ """OPTIMIZED: Update rendering based on current zoom level using cached data"""
1961
+ if not self.cached_spots or not self.cached_sizes_for_lod:
1962
+ return
1963
+
1964
+ # Adjust node sizes based on zoom
1965
+ if self.current_zoom_factor > 1.5:
1966
+ scale_factor = 1.0 + np.log10(self.current_zoom_factor) * 0.3
1967
+ else:
1968
+ scale_factor = 1.0
1969
+
1970
+ # Update node sizes in cached spots
1971
+ for i, base_size in enumerate(self.cached_sizes_for_lod):
1972
+ self.cached_spots[i]['size'] = base_size * scale_factor
1973
+
1974
+ # Update edge visibility based on zoom
1975
+ if self.current_zoom_factor < 0.5:
1976
+ edge_alpha = int(50 * self.current_zoom_factor)
1977
+ elif self.current_zoom_factor > 2:
1978
+ edge_alpha = min(150, int(100 + self.current_zoom_factor * 10))
1979
+ else:
1980
+ edge_alpha = 100
1981
+
1982
+ # Update edge rendering (batched edge items)
1983
+ if self.edge_items:
1984
+ for edge_item in self.edge_items:
1985
+ current_pen = edge_item.opts['pen']
1986
+ if current_pen is not None:
1987
+ width = current_pen.widthF()
1988
+ new_pen = pg.mkPen(color=(150, 150, 150, edge_alpha), width=width)
1989
+ edge_item.setPen(new_pen)
1990
+
1991
+ # Update labels based on zoom level
1992
+ if self.labels:
1993
+ self._update_labels_for_zoom()
1994
+
1995
+ # Re-render nodes with new sizes
1996
+ self.scatter.setData(spots=self.cached_spots)
1997
+
1998
+ def show_in_window(self, title="Network Graph", width=1000, height=800):
1999
+ """Show the graph widget in a separate non-modal window"""
2000
+ from PyQt6.QtWidgets import QMainWindow
2001
+
2002
+ # Create new window
2003
+ self.popup_window = QMainWindow()
2004
+ self.popup_window.setWindowTitle(title)
2005
+ self.popup_window.setGeometry(100, 100, width, height)
2006
+ self.popup_window.setCentralWidget(self)
2007
+
2008
+ # Show non-modal
2009
+ self.popup_window.show()
2010
+
2011
+ return self.popup_window
2012
+
2013
+
2014
+ # Example usage
2015
+ if __name__ == "__main__":
2016
+ from PyQt6.QtWidgets import QApplication, QMainWindow
2017
+ import sys
2018
+
2019
+ class MainWindow(QMainWindow):
2020
+ def __init__(self):
2021
+ super().__init__()
2022
+ self.setWindowTitle("Network Graph Viewer")
2023
+ self.setGeometry(100, 100, 1000, 800)
2024
+
2025
+ # Create a sample graph
2026
+ G = nx.karate_club_graph()
2027
+
2028
+ # Add some weights
2029
+ for u, v in G.edges():
2030
+ G[u][v]['weight'] = np.random.uniform(0.5, 5.0)
2031
+
2032
+ # Create sample community detection
2033
+ communities = nx.community.greedy_modularity_communities(G)
2034
+ community_dict = {}
2035
+ for i, comm in enumerate(communities):
2036
+ for node in comm:
2037
+ community_dict[node] = i
2038
+
2039
+ # Create the widget
2040
+ self.graph_widget = NetworkGraphWidget(
2041
+ parent=self,
2042
+ weight=True,
2043
+ communities=True,
2044
+ community_dict=community_dict,
2045
+ labels=True # Enable labels for testing
2046
+ )
2047
+
2048
+ self.setCentralWidget(self.graph_widget)
2049
+
2050
+ # Set and load the graph
2051
+ self.graph_widget.set_graph(G)
2052
+ self.graph_widget.load_graph()
2053
+
2054
+ # Connect signal
2055
+ self.graph_widget.node_selected.connect(self.on_node_selected)
2056
+
2057
+ def on_node_selected(self, nodes):
2058
+ if nodes:
2059
+ print(f"Selected nodes: {nodes}")
2060
+ else:
2061
+ print("No nodes selected")
2062
+
2063
+ app = QApplication(sys.argv)
2064
+ window = MainWindow()
2065
+ window.show()
2066
+ sys.exit(app.exec())