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.
- nettracer3d/branch_stitcher.py +245 -142
- nettracer3d/nettracer.py +205 -32
- nettracer3d/nettracer_gui.py +1975 -2026
- nettracer3d/network_analysis.py +16 -4
- nettracer3d/network_graph_widget.py +2066 -0
- nettracer3d/painting.py +158 -298
- nettracer3d/simple_network.py +4 -4
- nettracer3d/smart_dilate.py +19 -7
- nettracer3d/tutorial.py +38 -3
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/METADATA +51 -17
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/RECORD +15 -14
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/WHEEL +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/entry_points.txt +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/licenses/LICENSE +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/top_level.txt +0 -0
|
@@ -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())
|