nettracer3d 1.3.1__py3-none-any.whl → 1.3.6__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.

@@ -8,6 +8,8 @@ from PyQt6.QtGui import QColor, QPen, QBrush
8
8
  import pyqtgraph as pg
9
9
  from pyqtgraph import ScatterPlotItem, PlotCurveItem, GraphicsLayoutWidget, ROI
10
10
  import colorsys
11
+ import random
12
+ import copy
11
13
 
12
14
 
13
15
  class GraphLoadThread(QThread):
@@ -15,7 +17,8 @@ class GraphLoadThread(QThread):
15
17
  finished = pyqtSignal(object) # Emits the computed layout data
16
18
 
17
19
  def __init__(self, graph, geometric, component, centroids, communities,
18
- community_dict, identities, identity_dict, weight, z_size):
20
+ community_dict, identities, identity_dict, weight, z_size,
21
+ shell, node_size, edge_size):
19
22
  super().__init__()
20
23
  self.graph = graph
21
24
  self.geometric = geometric
@@ -27,6 +30,9 @@ class GraphLoadThread(QThread):
27
30
  self.identity_dict = identity_dict
28
31
  self.weight = weight
29
32
  self.z_size = z_size
33
+ self.shell = shell
34
+ self.node_size = node_size
35
+ self.edge_size = edge_size
30
36
 
31
37
  def run(self):
32
38
  """Compute layout and colors in background thread"""
@@ -34,11 +40,7 @@ class GraphLoadThread(QThread):
34
40
 
35
41
  # Compute node positions
36
42
  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()
43
+ result['pos'] = self._compute_fast_spring_layout()
42
44
  elif self.geometric:
43
45
  result['pos'] = self._compute_geometric_layout()
44
46
  elif self.component:
@@ -72,14 +74,209 @@ class GraphLoadThread(QThread):
72
74
  return {}
73
75
 
74
76
  # For small graphs, use networkx (overhead is negligible)
75
- if n < 200:
77
+ if n < 500 and not self.shell:
76
78
  return nx.spring_layout(self.graph, seed=42, iterations=50)
77
-
79
+
78
80
  # Use fast vectorized implementation for larger graphs
79
81
  try:
80
- return self._spring_layout_numpy(nodes, n)
82
+ if not self.shell:
83
+ return self._spring_layout_numpy(nodes, n)
84
+ else:
85
+ return self._shell_layout_numpy_super(nodes, n)
81
86
  except Exception as e:
82
- return nx.spring_layout(self.graph, seed=42, iterations=15)
87
+ pass
88
+
89
+ def _shell_layout_numpy_super(self, nodes, n):
90
+ """
91
+ Shell layout with physically separated connected components
92
+ """
93
+ np.random.seed(42)
94
+
95
+ # Find connected components
96
+ components = list(nx.connected_components(self.graph))
97
+
98
+ if len(components) == 1:
99
+ # Single component - compute shell layout directly with numpy
100
+ comp_nodes = nodes
101
+
102
+ if n == 1:
103
+ return {nodes[0]: np.array([0.0, 0.0])}
104
+
105
+ # Create node to index mapping
106
+ node_to_idx = {node: i for i, node in enumerate(nodes)}
107
+
108
+ # Compute degree centrality using numpy
109
+ degrees = np.zeros(n)
110
+ for u, v in self.graph.edges():
111
+ if u in node_to_idx and v in node_to_idx:
112
+ degrees[node_to_idx[u]] += 1
113
+ degrees[node_to_idx[v]] += 1
114
+
115
+ # Find most central node (highest degree)
116
+ central_idx = np.argmax(degrees)
117
+ central_node = nodes[central_idx]
118
+
119
+ # Build adjacency list for BFS
120
+ adj_list = {node: [] for node in nodes}
121
+ for u, v in self.graph.edges():
122
+ if u in node_to_idx and v in node_to_idx:
123
+ adj_list[u].append(v)
124
+ adj_list[v].append(u)
125
+
126
+ # Compute shells using BFS from central node
127
+ visited = set()
128
+ shells = []
129
+ current_shell = [central_node]
130
+ visited.add(central_node)
131
+
132
+ while current_shell:
133
+ shells.append(current_shell[:])
134
+ next_shell = []
135
+ for node in current_shell:
136
+ for neighbor in adj_list[node]:
137
+ if neighbor not in visited:
138
+ visited.add(neighbor)
139
+ next_shell.append(neighbor)
140
+ current_shell = next_shell
141
+
142
+ # Position nodes in concentric circles
143
+ pos = {}
144
+ radius = 1.0
145
+
146
+ for shell_idx, shell in enumerate(shells):
147
+ if shell_idx == 0:
148
+ # Center node at origin
149
+ pos[shell[0]] = np.array([0.0, 0.0])
150
+ else:
151
+ # Arrange nodes in circle at radius * shell_idx
152
+ num_nodes = len(shell)
153
+ angles = np.linspace(0, 2 * np.pi, num_nodes, endpoint=False)
154
+ for i, node in enumerate(shell):
155
+ x = radius * shell_idx * np.cos(angles[i])
156
+ y = radius * shell_idx * np.sin(angles[i])
157
+ pos[node] = np.array([x, y])
158
+
159
+ # Center the layout
160
+ positions = np.array(list(pos.values()))
161
+ positions -= positions.mean(axis=0)
162
+
163
+ return {node: positions[list(pos.keys()).index(node)] for node in nodes}
164
+
165
+ # Multiple components - layout each component independently
166
+ component_layouts = []
167
+ component_bounds = []
168
+
169
+ for component in components:
170
+ comp_nodes = list(component)
171
+ comp_n = len(comp_nodes)
172
+
173
+ # Layout this component
174
+ comp_pos = self._layout_component_shell(comp_nodes, comp_n)
175
+
176
+ # Calculate bounding box
177
+ positions = np.array(list(comp_pos.values()))
178
+ min_coords = positions.min(axis=0)
179
+ max_coords = positions.max(axis=0)
180
+ size = max_coords - min_coords
181
+
182
+ component_layouts.append((comp_nodes, comp_pos))
183
+ component_bounds.append(size)
184
+
185
+ # Arrange components in a grid with spacing
186
+ num_components = len(components)
187
+ grid_cols = int(np.ceil(np.sqrt(num_components)))
188
+
189
+ # Calculate spacing based on largest component
190
+ max_width = max(bounds[0] for bounds in component_bounds)
191
+ max_height = max(bounds[1] for bounds in component_bounds)
192
+ spacing_x = max_width * 1.5 # 50% padding between components
193
+ spacing_y = max_height * 1.5
194
+
195
+ # Place components in grid
196
+ final_positions = {}
197
+ for idx, (comp_nodes, comp_pos) in enumerate(component_layouts):
198
+ grid_x = idx % grid_cols
199
+ grid_y = idx // grid_cols
200
+
201
+ # Calculate offset for this component
202
+ offset = np.array([grid_x * spacing_x, grid_y * spacing_y])
203
+
204
+ # Apply offset to all nodes in component
205
+ for node in comp_nodes:
206
+ final_positions[node] = comp_pos[node] + offset
207
+
208
+ # Center the entire layout
209
+ all_pos = np.array([final_positions[node] for node in nodes])
210
+ all_pos -= all_pos.mean(axis=0)
211
+
212
+ return {node: all_pos[i] for i, node in enumerate(nodes)}
213
+
214
+ def _layout_component_shell(self, nodes, n):
215
+ """
216
+ Shell layout for a single component using numpy for centrality
217
+ """
218
+ if n == 1:
219
+ return {nodes[0]: np.array([0.0, 0.0])}
220
+
221
+ # Create node to index mapping
222
+ node_to_idx = {node: i for i, node in enumerate(nodes)}
223
+
224
+ # Compute degree centrality using numpy
225
+ degrees = np.zeros(n)
226
+ for u, v in self.graph.edges():
227
+ if u in node_to_idx and v in node_to_idx:
228
+ degrees[node_to_idx[u]] += 1
229
+ degrees[node_to_idx[v]] += 1
230
+
231
+ # Find most central node (highest degree)
232
+ central_idx = np.argmax(degrees)
233
+ central_node = nodes[central_idx]
234
+
235
+ # Build adjacency list for BFS
236
+ adj_list = {node: [] for node in nodes}
237
+ for u, v in self.graph.edges():
238
+ if u in node_to_idx and v in node_to_idx:
239
+ adj_list[u].append(v)
240
+ adj_list[v].append(u)
241
+
242
+ # Compute shells using BFS from central node
243
+ visited = set()
244
+ shells = []
245
+ current_shell = [central_node]
246
+ visited.add(central_node)
247
+
248
+ while current_shell:
249
+ shells.append(current_shell[:])
250
+ next_shell = []
251
+ for node in current_shell:
252
+ for neighbor in adj_list[node]:
253
+ if neighbor not in visited:
254
+ visited.add(neighbor)
255
+ next_shell.append(neighbor)
256
+ current_shell = next_shell
257
+
258
+ # Position nodes in concentric circles
259
+ pos = {}
260
+ radius = 1.0
261
+
262
+ for shell_idx, shell in enumerate(shells):
263
+ if shell_idx == 0:
264
+ # Center node at origin
265
+ pos[shell[0]] = np.array([0.0, 0.0])
266
+ else:
267
+ # Arrange nodes in circle at radius * shell_idx
268
+ num_nodes = len(shell)
269
+ angles = np.linspace(0, 2 * np.pi, num_nodes, endpoint=False)
270
+ for i, node in enumerate(shell):
271
+ x = radius * shell_idx * np.cos(angles[i])
272
+ y = radius * shell_idx * np.sin(angles[i])
273
+ pos[node] = np.array([x, y])
274
+
275
+ # Center the layout
276
+ positions = np.array(list(pos.values()))
277
+ positions -= positions.mean(axis=0)
278
+
279
+ return {node: positions[list(pos.keys()).index(node)] for node in nodes}
83
280
 
84
281
  def _spring_layout_numpy_super(self, nodes, n, iterations=50):
85
282
  """
@@ -331,7 +528,7 @@ class GraphLoadThread(QThread):
331
528
  return [{
332
529
  'x': np.array(x_coords),
333
530
  'y': np.array(y_coords),
334
- 'thickness': 1.0
531
+ 'thickness': self.edge_size
335
532
  }]
336
533
 
337
534
  # Weight-based rendering - batch by thickness
@@ -345,8 +542,8 @@ class GraphLoadThread(QThread):
345
542
 
346
543
  # Define thickness bins (e.g., 10 discrete thickness levels)
347
544
  num_bins = 10
348
- thickness_min = 0.5
349
- thickness_max = 3.0 # Maximum thickness cap
545
+ thickness_min = self.edge_size/2
546
+ thickness_max = 3.0 * self.edge_size # Maximum thickness cap
350
547
 
351
548
  # Batch edges by thickness bin
352
549
  edge_batches = {} # {thickness: [(x_coords, y_coords), ...]}
@@ -356,7 +553,7 @@ class GraphLoadThread(QThread):
356
553
  if weight_range > 0:
357
554
  normalized = (weight - min_weight) / weight_range
358
555
  else:
359
- normalized = 0.5
556
+ normalized = self.edge_size/2
360
557
 
361
558
  # Calculate thickness with cap
362
559
  thickness = thickness_min + normalized * (thickness_max - thickness_min)
@@ -421,7 +618,7 @@ class GraphLoadThread(QThread):
421
618
 
422
619
  def _compute_all_node_sizes_vectorized(self, nodes):
423
620
  if not self.geometric or not self.centroids or not self.z_size:
424
- return [10] * len(nodes)
621
+ return [self.node_size] * len(nodes)
425
622
 
426
623
  # GLOBAL z range (matches original behavior)
427
624
  all_z = np.array([
@@ -495,9 +692,7 @@ class GraphLoadThread(QThread):
495
692
 
496
693
  unique_communities = sorted(set(my_dict.values()))
497
694
  community_sizes = Counter(my_dict.values())
498
- sorted_communities = sorted(unique_communities,
499
- key=lambda x: community_sizes[x],
500
- reverse=True)
695
+ sorted_communities = random.Random(42).sample(unique_communities, len(unique_communities))
501
696
  colors_rgb = self._generate_distinct_colors_rgb(len(unique_communities))
502
697
  color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
503
698
  if 0 in unique_communities:
@@ -539,7 +734,8 @@ class NetworkGraphWidget(QWidget):
539
734
 
540
735
  def __init__(self, parent=None, weight=False, geometric=False, component = False,
541
736
  centroids=None, communities=False, community_dict=None,
542
- identities=False, identity_dict=None, labels=False, z_size = False):
737
+ identities=False, identity_dict=None, labels=False, z_size = False,
738
+ shell = False, node_size = 10, black_edges = False, edge_size = 1, popout = False):
543
739
  super().__init__(parent)
544
740
 
545
741
  self.parent_window = parent
@@ -553,6 +749,11 @@ class NetworkGraphWidget(QWidget):
553
749
  self.identity_dict = identity_dict or {}
554
750
  self.labels = labels
555
751
  self.z_size = z_size
752
+ self.shell = shell
753
+ self.node_size = node_size
754
+ self.black_edges = black_edges
755
+ self.edge_size = edge_size
756
+ self.popout = popout
556
757
 
557
758
  # Graph data
558
759
  self.graph = None
@@ -564,6 +765,7 @@ class NetworkGraphWidget(QWidget):
564
765
  self.label_items = {}
565
766
  self.label_data = [] # Store label data for on-demand rendering
566
767
  self.selected_nodes = set()
768
+ self.node_click = False
567
769
  self.rendered = False
568
770
 
569
771
  # CACHING for fast updates
@@ -629,7 +831,6 @@ class NetworkGraphWidget(QWidget):
629
831
  self.plot.addItem(self.loading_text)
630
832
 
631
833
  # Enable mouse tracking for area selection
632
- self.plot.scene().sigMouseClicked.connect(self._on_plot_clicked)
633
834
  self.plot.scene().sigMouseMoved.connect(self._on_mouse_moved)
634
835
 
635
836
  # Disable default mouse interaction - will enable only in pan mode
@@ -644,6 +845,7 @@ class NetworkGraphWidget(QWidget):
644
845
 
645
846
  # Connect click events
646
847
  self.scatter.sigClicked.connect(self._on_node_clicked)
848
+ self.plot.scene().sigMouseClicked.connect(self._on_plot_clicked)
647
849
 
648
850
  # Connect view change for level-of-detail updates
649
851
  self.plot.sigRangeChanged.connect(self._on_view_changed)
@@ -702,10 +904,7 @@ class NetworkGraphWidget(QWidget):
702
904
 
703
905
  unique_identities = sorted(set(self.identity_dict.values()))
704
906
  community_sizes = Counter(self.identity_dict.values())
705
- sorted_communities = sorted(unique_identities,
706
- key=lambda x: community_sizes[x],
707
- reverse=True)
708
-
907
+ sorted_communities = random.Random(42).sample(unique_identities, len(unique_identities))
709
908
  colors_rgb = _generate_distinct_colors_rgb(len(unique_identities))
710
909
  color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
711
910
  if 0 in unique_identities:
@@ -715,10 +914,7 @@ class NetworkGraphWidget(QWidget):
715
914
 
716
915
  unique_identities = sorted(set(self.community_dict.values()))
717
916
  community_sizes = Counter(self.community_dict.values())
718
- sorted_communities = sorted(unique_identities,
719
- key=lambda x: community_sizes[x],
720
- reverse=True)
721
-
917
+ sorted_communities = random.Random(42).sample(unique_identities, len(unique_identities))
722
918
  colors_rgb = _generate_distinct_colors_rgb(len(unique_identities))
723
919
  color_map = {comm: colors_rgb[i] for i, comm in enumerate(sorted_communities)}
724
920
  if 0 in unique_identities:
@@ -852,6 +1048,7 @@ class NetworkGraphWidget(QWidget):
852
1048
  self.clear_btn.setToolTip("Clear Graph")
853
1049
  self.clear_btn.setMaximumSize(32, 32)
854
1050
  self.clear_btn.clicked.connect(self._clear_graph)
1051
+
855
1052
 
856
1053
  # Add buttons to layout
857
1054
  panel_layout.addWidget(self.select_btn)
@@ -861,6 +1058,14 @@ class NetworkGraphWidget(QWidget):
861
1058
  panel_layout.addWidget(self.refresh_btn)
862
1059
  panel_layout.addWidget(self.settings_btn)
863
1060
  panel_layout.addWidget(self.clear_btn)
1061
+
1062
+ if self.popout:
1063
+ self.popout_btn = QPushButton("⤴")
1064
+ self.popout_btn.setToolTip("Full Screen")
1065
+ self.popout_btn.setMaximumSize(32, 32)
1066
+ self.popout_btn.clicked.connect(self._popout_graph)
1067
+ panel_layout.addWidget(self.popout_btn)
1068
+
864
1069
  panel_layout.addStretch()
865
1070
 
866
1071
  panel.setLayout(panel_layout)
@@ -906,6 +1111,7 @@ class NetworkGraphWidget(QWidget):
906
1111
 
907
1112
  # Clear existing visualization
908
1113
  self._clear_graph()
1114
+ self.get_properties()
909
1115
 
910
1116
  if hasattr(self, 'loading_text') and self.loading_text is not None:
911
1117
  self.plot.removeItem(self.loading_text)
@@ -939,7 +1145,8 @@ class NetworkGraphWidget(QWidget):
939
1145
  self.load_thread = GraphLoadThread(
940
1146
  self.graph, self.geometric, self.component, self.centroids,
941
1147
  self.communities, self.community_dict,
942
- self.identities, self.identity_dict, self.weight, self.z_size
1148
+ self.identities, self.identity_dict, self.weight, self.z_size,
1149
+ self.shell, self.node_size, self.edge_size
943
1150
  )
944
1151
  self.load_thread.finished.connect(self._on_graph_loaded)
945
1152
  self.load_thread.start()
@@ -992,6 +1199,7 @@ class NetworkGraphWidget(QWidget):
992
1199
  self.select_nodes(self.parent_window.clicked_values['nodes'])
993
1200
 
994
1201
 
1202
+
995
1203
  def _render_prepared_data(self, result):
996
1204
  """Render pre-computed data (minimal main thread work)"""
997
1205
  # Clear old items
@@ -1002,6 +1210,11 @@ class NetworkGraphWidget(QWidget):
1002
1210
  for label_item in self.label_items.values():
1003
1211
  self.plot.removeItem(label_item)
1004
1212
  self.label_items.clear()
1213
+
1214
+ if self.black_edges:
1215
+ edge_color = (0, 0, 0)
1216
+ else:
1217
+ edge_color = (150, 150, 150, 100)
1005
1218
 
1006
1219
  # Render edges - batched by weight for efficiency
1007
1220
  edge_batches = result['edge_pens']
@@ -1010,7 +1223,7 @@ class NetworkGraphWidget(QWidget):
1010
1223
  edge_line = PlotCurveItem(
1011
1224
  x=batch['x'],
1012
1225
  y=batch['y'],
1013
- pen=pg.mkPen(color=(150, 150, 150, 100), width=batch['thickness']),
1226
+ pen=pg.mkPen(color=edge_color, width=batch['thickness']),
1014
1227
  connect='finite' # Break lines at NaN
1015
1228
  )
1016
1229
  self.plot.addItem(edge_line)
@@ -1176,62 +1389,6 @@ class NetworkGraphWidget(QWidget):
1176
1389
  if self.label_data:
1177
1390
  self._update_labels_for_zoom()
1178
1391
 
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
1392
  def _hex_to_rgb(self, hex_color):
1236
1393
  """Convert hex color to RGB tuple"""
1237
1394
  hex_color = hex_color.lstrip('#')
@@ -1239,7 +1396,8 @@ class NetworkGraphWidget(QWidget):
1239
1396
 
1240
1397
  def _on_plot_clicked(self, ev):
1241
1398
  """Handle clicks on the plot background"""
1242
- if not self.selection_mode:
1399
+ if not self.selection_mode or self.node_click or not self.popout:
1400
+ self.node_click = False
1243
1401
  return
1244
1402
 
1245
1403
  # Only handle left button clicks
@@ -1249,12 +1407,6 @@ class NetworkGraphWidget(QWidget):
1249
1407
  # Get the position in scene coordinates
1250
1408
  scene_pos = ev.scenePos()
1251
1409
 
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
1410
 
1259
1411
  # Click was on background
1260
1412
  modifiers = ev.modifiers()
@@ -1264,8 +1416,8 @@ class NetworkGraphWidget(QWidget):
1264
1416
  # Deselect all nodes
1265
1417
  self.selected_nodes.clear()
1266
1418
  self._render_nodes()
1419
+ self.push_selection()
1267
1420
  self.node_selected.emit([])
1268
-
1269
1421
  # Ctrl+Click on background does nothing (as requested)
1270
1422
 
1271
1423
  def _on_mouse_moved(self, pos):
@@ -1284,6 +1436,7 @@ class NetworkGraphWidget(QWidget):
1284
1436
  def _start_area_selection(self, scene_pos):
1285
1437
  """Start area selection with rectangle"""
1286
1438
  self.is_area_selecting = True
1439
+ self.node_click = False
1287
1440
 
1288
1441
  # Convert to view coordinates
1289
1442
  view_pos = self.plot.vb.mapSceneToView(scene_pos)
@@ -1456,6 +1609,7 @@ class NetworkGraphWidget(QWidget):
1456
1609
  if delta_x > 10 or delta_y > 10: # Moved significantly
1457
1610
  self.click_timer.stop()
1458
1611
  if not self.is_area_selecting:
1612
+ #if not self.was_in_selection_before_wheel or self.zoom_mode:
1459
1613
  self._start_area_selection(self.last_mouse_pos)
1460
1614
 
1461
1615
 
@@ -1514,7 +1668,7 @@ class NetworkGraphWidget(QWidget):
1514
1668
  """End temporary panning mode"""
1515
1669
  self.temp_pan_active = False
1516
1670
  # Disable mouse if we're in selection mode
1517
- if self.selection_mode:
1671
+ if self.selection_mode or self.zoom_mode:
1518
1672
  self.plot.setMouseEnabled(x=False, y=False)
1519
1673
 
1520
1674
  def _handle_wheel_in_selection(self, event):
@@ -1530,12 +1684,12 @@ class NetworkGraphWidget(QWidget):
1530
1684
  self.wheel_timer.setSingleShot(True)
1531
1685
  self.wheel_timer.timeout.connect(self._end_wheel_zoom)
1532
1686
 
1533
- # Restart timer (500ms after last wheel event)
1534
- self.wheel_timer.start(500)
1687
+ # Restart timer
1688
+ self.wheel_timer.start(1)
1535
1689
 
1536
1690
  def _end_wheel_zoom(self):
1537
1691
  """End wheel zoom and return to selection mode"""
1538
- if self.was_in_selection_before_wheel and self.selection_mode:
1692
+ if self.was_in_selection_before_wheel and (self.selection_mode or self.zoom_mode):
1539
1693
  self.plot.setMouseEnabled(x=False, y=False)
1540
1694
  self.was_in_selection_before_wheel = False
1541
1695
 
@@ -1586,7 +1740,10 @@ class NetworkGraphWidget(QWidget):
1586
1740
  x_max, y_max = pos_array.max(axis=0)
1587
1741
 
1588
1742
  # Add padding
1589
- padding = 0.1
1743
+ if self.shell or self.component:
1744
+ padding = 0.75
1745
+ else:
1746
+ padding = 0.1
1590
1747
  x_range = x_max - x_min
1591
1748
  y_range = y_max - y_min
1592
1749
 
@@ -1679,6 +1836,32 @@ class NetworkGraphWidget(QWidget):
1679
1836
 
1680
1837
  self.loading_text.setPos(0, 0) # Center of view
1681
1838
  self.plot.addItem(self.loading_text)
1839
+
1840
+ def _popout_graph(self):
1841
+
1842
+ temp_graph_widget = NetworkGraphWidget(
1843
+ parent=self.parent_window,
1844
+ weight=self.weight,
1845
+ geometric=self.geometric,
1846
+ component = self.component,
1847
+ centroids=self.centroids,
1848
+ communities=self.communities,
1849
+ community_dict=self.community_dict,
1850
+ labels=self.labels,
1851
+ identities = self.identities,
1852
+ identity_dict = self.identity_dict,
1853
+ z_size = self.z_size,
1854
+ shell = self.shell,
1855
+ node_size = self.node_size,
1856
+ black_edges = self.black_edges,
1857
+ edge_size = self.edge_size
1858
+ )
1859
+
1860
+ temp_graph_widget.set_graph(self.graph)
1861
+ temp_graph_widget.show_in_window(title="Network Graph", width=1000, height=800)
1862
+ temp_graph_widget.load_graph()
1863
+ self.parent_window.temp_graph_widgets.append(temp_graph_widget)
1864
+
1682
1865
 
1683
1866
  def select_nodes(self, nodes, add_to_selection=False):
1684
1867
  """
@@ -1734,10 +1917,12 @@ class NetworkGraphWidget(QWidget):
1734
1917
  # Clear previous selection and select only this node
1735
1918
  self.selected_nodes.clear()
1736
1919
  self.selected_nodes.add(clicked_node)
1920
+
1737
1921
  self.push_selection()
1738
1922
 
1739
1923
  # Update visual representation
1740
1924
  self._render_nodes()
1925
+ self.node_click = True
1741
1926
 
1742
1927
  # Emit signal with all selected nodes
1743
1928
  self.node_selected.emit(list(self.selected_nodes))
@@ -1777,6 +1962,8 @@ class NetworkGraphWidget(QWidget):
1777
1962
  print(f"Found node {val} at [Z,Y,X] -> {centroid}")
1778
1963
  self.push_selection()
1779
1964
  except:
1965
+ import traceback
1966
+ traceback.print_exc()
1780
1967
  pass
1781
1968
 
1782
1969
 
@@ -1847,6 +2034,11 @@ class NetworkGraphWidget(QWidget):
1847
2034
  )
1848
2035
 
1849
2036
 
2037
+ def get_properties(self):
2038
+
2039
+ self.parent_window.update_graph_fields()
2040
+
2041
+
1850
2042
  def create_context_menu(self, event):
1851
2043
  # Get the index at the clicked position
1852
2044
  # Create context menu
@@ -1898,7 +2090,7 @@ class NetworkGraphWidget(QWidget):
1898
2090
 
1899
2091
  def update_params(self, weight=None, geometric=None, component = None, centroids=None,
1900
2092
  communities=None, community_dict=None,
1901
- identities=None, identity_dict=None, labels=None, z_size = None):
2093
+ identities=None, identity_dict=None, labels=None, z_size = None, shell = None, node_size = 10):
1902
2094
  """Update visualization parameters"""
1903
2095
  if weight is not None:
1904
2096
  self.weight = weight
@@ -1920,6 +2112,10 @@ class NetworkGraphWidget(QWidget):
1920
2112
  self.labels = labels
1921
2113
  if z_size is not None:
1922
2114
  self.z_size = z_size
2115
+ if shell is not None:
2116
+ self.shell = shell
2117
+ if node_size is not None:
2118
+ self.node_size = node_size
1923
2119
 
1924
2120
  def _on_view_changed(self):
1925
2121
  """Handle view range changes for level-of-detail adjustments"""
@@ -1978,6 +2174,11 @@ class NetworkGraphWidget(QWidget):
1978
2174
  edge_alpha = min(150, int(100 + self.current_zoom_factor * 10))
1979
2175
  else:
1980
2176
  edge_alpha = 100
2177
+
2178
+ if self.black_edges:
2179
+ edge_color = (0, 0, 0)
2180
+ else:
2181
+ edge_color = (150, 150, 150, edge_alpha)
1981
2182
 
1982
2183
  # Update edge rendering (batched edge items)
1983
2184
  if self.edge_items:
@@ -1985,7 +2186,7 @@ class NetworkGraphWidget(QWidget):
1985
2186
  current_pen = edge_item.opts['pen']
1986
2187
  if current_pen is not None:
1987
2188
  width = current_pen.widthF()
1988
- new_pen = pg.mkPen(color=(150, 150, 150, edge_alpha), width=width)
2189
+ new_pen = pg.mkPen(color=edge_color, width=width)
1989
2190
  edge_item.setPen(new_pen)
1990
2191
 
1991
2192
  # Update labels based on zoom level
nettracer3d/segmenter.py CHANGED
@@ -1380,7 +1380,7 @@ class InteractiveSegmenter:
1380
1380
  return chunks
1381
1381
 
1382
1382
  def train_batch(self, foreground_array, speed=True, use_gpu=False, use_two=False, mem_lock=False, saving=False):
1383
- """Updated train_batch with chunked 2D processing"""
1383
+ """Train model for batch of arrays"""
1384
1384
 
1385
1385
  if not saving:
1386
1386
  print("Training model...")