nettracer3d 0.7.6__py3-none-any.whl → 0.7.8__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/community_extractor.py +13 -29
- nettracer3d/excelotron.py +1719 -0
- nettracer3d/modularity.py +6 -9
- nettracer3d/neighborhoods.py +354 -0
- nettracer3d/nettracer.py +400 -13
- nettracer3d/nettracer_gui.py +1120 -229
- nettracer3d/proximity.py +89 -9
- nettracer3d/smart_dilate.py +20 -15
- {nettracer3d-0.7.6.dist-info → nettracer3d-0.7.8.dist-info}/METADATA +11 -2
- nettracer3d-0.7.8.dist-info/RECORD +23 -0
- {nettracer3d-0.7.6.dist-info → nettracer3d-0.7.8.dist-info}/WHEEL +1 -1
- nettracer3d-0.7.6.dist-info/RECORD +0 -21
- {nettracer3d-0.7.6.dist-info → nettracer3d-0.7.8.dist-info}/entry_points.txt +0 -0
- {nettracer3d-0.7.6.dist-info → nettracer3d-0.7.8.dist-info}/licenses/LICENSE +0 -0
- {nettracer3d-0.7.6.dist-info → nettracer3d-0.7.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PyQt6.QtWidgets import (QApplication, QMainWindow, QHBoxLayout, QVBoxLayout,
|
|
5
|
+
QWidget, QTableWidget, QTableWidgetItem, QPushButton,
|
|
6
|
+
QLabel, QLineEdit, QScrollArea, QFrame, QMessageBox,
|
|
7
|
+
QHeaderView, QAbstractItemView, QSplitter, QTabWidget)
|
|
8
|
+
from PyQt6.QtCore import Qt, QMimeData, pyqtSignal
|
|
9
|
+
from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QDrag, QPainter, QPixmap
|
|
10
|
+
import os
|
|
11
|
+
from PyQt6.QtWidgets import QComboBox
|
|
12
|
+
from ast import literal_eval
|
|
13
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
14
|
+
|
|
15
|
+
class DraggableTableWidget(QTableWidget):
|
|
16
|
+
"""Custom table widget that supports drag and drop operations"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, parent=None):
|
|
19
|
+
super().__init__(parent)
|
|
20
|
+
self.setDragEnabled(True)
|
|
21
|
+
self.setAcceptDrops(True)
|
|
22
|
+
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
|
23
|
+
self.setDefaultDropAction(Qt.DropAction.MoveAction)
|
|
24
|
+
|
|
25
|
+
def startDrag(self, supportedActions):
|
|
26
|
+
if self.currentColumn() >= 0:
|
|
27
|
+
# Create drag data with column index and header
|
|
28
|
+
drag = QDrag(self)
|
|
29
|
+
mimeData = QMimeData()
|
|
30
|
+
|
|
31
|
+
# Store column index and header text
|
|
32
|
+
col_idx = self.currentColumn()
|
|
33
|
+
header_text = self.horizontalHeaderItem(col_idx).text() if self.horizontalHeaderItem(col_idx) else f"Column_{col_idx}"
|
|
34
|
+
|
|
35
|
+
mimeData.setText(f"excel_column:{col_idx}:{header_text}")
|
|
36
|
+
drag.setMimeData(mimeData)
|
|
37
|
+
|
|
38
|
+
# Create drag pixmap
|
|
39
|
+
pixmap = QPixmap(100, 30)
|
|
40
|
+
pixmap.fill(Qt.GlobalColor.lightGray)
|
|
41
|
+
painter = QPainter(pixmap)
|
|
42
|
+
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, header_text)
|
|
43
|
+
painter.end()
|
|
44
|
+
drag.setPixmap(pixmap)
|
|
45
|
+
|
|
46
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DropZoneWidget(QFrame):
|
|
50
|
+
"""Widget that accepts file drops for Excel/CSV files"""
|
|
51
|
+
file_dropped = pyqtSignal(str)
|
|
52
|
+
|
|
53
|
+
def __init__(self, parent=None):
|
|
54
|
+
super().__init__(parent)
|
|
55
|
+
self.setAcceptDrops(True)
|
|
56
|
+
self.setStyleSheet("""
|
|
57
|
+
QFrame {
|
|
58
|
+
border: 2px dashed #aaa;
|
|
59
|
+
border-radius: 5px;
|
|
60
|
+
background-color: #f9f9f9;
|
|
61
|
+
}
|
|
62
|
+
QFrame:hover {
|
|
63
|
+
border-color: #007acc;
|
|
64
|
+
background-color: #f0f8ff;
|
|
65
|
+
}
|
|
66
|
+
""")
|
|
67
|
+
|
|
68
|
+
layout = QVBoxLayout()
|
|
69
|
+
label = QLabel("Drag Excel (.xlsx) or CSV files here")
|
|
70
|
+
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
71
|
+
label.setStyleSheet("color: #666; font-size: 14px;")
|
|
72
|
+
layout.addWidget(label)
|
|
73
|
+
self.setLayout(layout)
|
|
74
|
+
|
|
75
|
+
def dragEnterEvent(self, event: QDragEnterEvent):
|
|
76
|
+
if event.mimeData().hasUrls():
|
|
77
|
+
urls = event.mimeData().urls()
|
|
78
|
+
if len(urls) == 1:
|
|
79
|
+
file_path = urls[0].toLocalFile()
|
|
80
|
+
if file_path.lower().endswith(('.xlsx', '.csv')):
|
|
81
|
+
event.acceptProposedAction()
|
|
82
|
+
return
|
|
83
|
+
event.ignore()
|
|
84
|
+
|
|
85
|
+
def dropEvent(self, event: QDropEvent):
|
|
86
|
+
if event.mimeData().hasUrls():
|
|
87
|
+
file_path = event.mimeData().urls()[0].toLocalFile()
|
|
88
|
+
self.file_dropped.emit(file_path)
|
|
89
|
+
event.acceptProposedAction()
|
|
90
|
+
|
|
91
|
+
class DictColumnWidget(QFrame):
|
|
92
|
+
"""Widget representing a dictionary column that can accept drops"""
|
|
93
|
+
column_dropped = pyqtSignal(str, int, str) # widget_id, col_idx, col_name
|
|
94
|
+
delete_requested = pyqtSignal(str) # widget_id
|
|
95
|
+
|
|
96
|
+
def __init__(self, widget_id, parent=None):
|
|
97
|
+
super().__init__(parent)
|
|
98
|
+
self.widget_id = widget_id
|
|
99
|
+
self.column_data = None
|
|
100
|
+
self.column_name = None
|
|
101
|
+
self.setAcceptDrops(True)
|
|
102
|
+
self.setFixedHeight(80)
|
|
103
|
+
self.setStyleSheet("""
|
|
104
|
+
QFrame {
|
|
105
|
+
border: 1px solid #ccc;
|
|
106
|
+
border-radius: 3px;
|
|
107
|
+
background-color: white;
|
|
108
|
+
margin: 2px;
|
|
109
|
+
}
|
|
110
|
+
QFrame:hover {
|
|
111
|
+
border-color: #007acc;
|
|
112
|
+
}
|
|
113
|
+
""")
|
|
114
|
+
|
|
115
|
+
layout = QVBoxLayout()
|
|
116
|
+
|
|
117
|
+
# Header input
|
|
118
|
+
self.header_input = QLineEdit()
|
|
119
|
+
self.header_input.setPlaceholderText("Dictionary key name...")
|
|
120
|
+
self.header_input.textChanged.connect(self.on_header_changed)
|
|
121
|
+
layout.addWidget(self.header_input)
|
|
122
|
+
|
|
123
|
+
# Drop zone / content area
|
|
124
|
+
self.content_label = QLabel("Drop column here")
|
|
125
|
+
self.content_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
126
|
+
self.content_label.setStyleSheet("color: #888; font-style: italic;")
|
|
127
|
+
layout.addWidget(self.content_label)
|
|
128
|
+
|
|
129
|
+
# Delete button
|
|
130
|
+
self.delete_btn = QPushButton("×")
|
|
131
|
+
self.delete_btn.setFixedSize(20, 20)
|
|
132
|
+
self.delete_btn.setStyleSheet("""
|
|
133
|
+
QPushButton {
|
|
134
|
+
background-color: #ff4444;
|
|
135
|
+
color: white;
|
|
136
|
+
border: none;
|
|
137
|
+
border-radius: 10px;
|
|
138
|
+
font-weight: bold;
|
|
139
|
+
}
|
|
140
|
+
QPushButton:hover {
|
|
141
|
+
background-color: #cc0000;
|
|
142
|
+
}
|
|
143
|
+
""")
|
|
144
|
+
self.delete_btn.clicked.connect(lambda: self.delete_requested.emit(self.widget_id))
|
|
145
|
+
|
|
146
|
+
# Position delete button in top-right
|
|
147
|
+
self.delete_btn.setParent(self)
|
|
148
|
+
self.delete_btn.move(self.width() - 25, 5)
|
|
149
|
+
|
|
150
|
+
self.setLayout(layout)
|
|
151
|
+
|
|
152
|
+
def resizeEvent(self, event):
|
|
153
|
+
super().resizeEvent(event)
|
|
154
|
+
self.delete_btn.move(self.width() - 25, 5)
|
|
155
|
+
|
|
156
|
+
def on_header_changed(self):
|
|
157
|
+
# Update display when header changes
|
|
158
|
+
if self.column_data is not None:
|
|
159
|
+
self.content_label.setText(f"Column: {self.column_name}\nKey: {self.header_input.text()}")
|
|
160
|
+
|
|
161
|
+
def dragEnterEvent(self, event: QDragEnterEvent):
|
|
162
|
+
if event.mimeData().hasText() and event.mimeData().text().startswith("excel_column:"):
|
|
163
|
+
event.acceptProposedAction()
|
|
164
|
+
else:
|
|
165
|
+
event.ignore()
|
|
166
|
+
|
|
167
|
+
def dropEvent(self, event: QDropEvent):
|
|
168
|
+
text = event.mimeData().text()
|
|
169
|
+
if text.startswith("excel_column:"):
|
|
170
|
+
parts = text.split(":", 2)
|
|
171
|
+
if len(parts) >= 3:
|
|
172
|
+
col_idx = int(parts[1])
|
|
173
|
+
col_name = parts[2]
|
|
174
|
+
self.column_name = col_name
|
|
175
|
+
self.content_label.setText(f"Column: {col_name}\nKey: {self.header_input.text()}")
|
|
176
|
+
self.column_dropped.emit(self.widget_id, col_idx, col_name)
|
|
177
|
+
event.acceptProposedAction()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ClassifierWidget(QFrame):
|
|
181
|
+
"""Widget representing a single classifier with substrings and new ID"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, classifier_id, classifier_group_widget, parent=None):
|
|
184
|
+
super().__init__(parent)
|
|
185
|
+
self.classifier_id = classifier_id
|
|
186
|
+
self.classifier_group_widget = classifier_group_widget # Store reference to parent group
|
|
187
|
+
self.substrings = []
|
|
188
|
+
|
|
189
|
+
self.setStyleSheet("""
|
|
190
|
+
QFrame {
|
|
191
|
+
border: 1px solid #ddd;
|
|
192
|
+
border-radius: 5px;
|
|
193
|
+
background-color: #f8f9fa;
|
|
194
|
+
margin: 2px;
|
|
195
|
+
padding: 5px;
|
|
196
|
+
}
|
|
197
|
+
""")
|
|
198
|
+
|
|
199
|
+
# Header with classifier number and buttons
|
|
200
|
+
# Header with classifier number and buttons
|
|
201
|
+
layout = QVBoxLayout()
|
|
202
|
+
header_layout = QHBoxLayout()
|
|
203
|
+
self.header_label = QLabel(f"Classifier {classifier_id}") # Store reference to label
|
|
204
|
+
self.header_label.setStyleSheet("font-weight: bold; color: #495057;")
|
|
205
|
+
header_layout.addWidget(self.header_label)
|
|
206
|
+
|
|
207
|
+
header_layout.addStretch()
|
|
208
|
+
|
|
209
|
+
# Move up button
|
|
210
|
+
self.up_btn = QPushButton("↑")
|
|
211
|
+
self.up_btn.setFixedSize(20, 20)
|
|
212
|
+
self.up_btn.setStyleSheet("""
|
|
213
|
+
QPushButton {
|
|
214
|
+
background-color: #6c757d;
|
|
215
|
+
color: white;
|
|
216
|
+
border: none;
|
|
217
|
+
border-radius: 10px;
|
|
218
|
+
font-weight: bold;
|
|
219
|
+
font-size: 10px;
|
|
220
|
+
}
|
|
221
|
+
QPushButton:hover {
|
|
222
|
+
background-color: #5a6268;
|
|
223
|
+
}
|
|
224
|
+
""")
|
|
225
|
+
self.up_btn.clicked.connect(self.move_up) # Connect to instance method
|
|
226
|
+
header_layout.addWidget(self.up_btn)
|
|
227
|
+
|
|
228
|
+
# Move down button
|
|
229
|
+
self.down_btn = QPushButton("↓")
|
|
230
|
+
self.down_btn.setFixedSize(20, 20)
|
|
231
|
+
self.down_btn.setStyleSheet("""
|
|
232
|
+
QPushButton {
|
|
233
|
+
background-color: #6c757d;
|
|
234
|
+
color: white;
|
|
235
|
+
border: none;
|
|
236
|
+
border-radius: 10px;
|
|
237
|
+
font-weight: bold;
|
|
238
|
+
font-size: 10px;
|
|
239
|
+
}
|
|
240
|
+
QPushButton:hover {
|
|
241
|
+
background-color: #5a6268;
|
|
242
|
+
}
|
|
243
|
+
""")
|
|
244
|
+
self.down_btn.clicked.connect(self.move_down) # Connect to instance method
|
|
245
|
+
header_layout.addWidget(self.down_btn)
|
|
246
|
+
|
|
247
|
+
# Copy button
|
|
248
|
+
self.copy_btn = QPushButton("⎘")
|
|
249
|
+
self.copy_btn.setFixedSize(20, 20)
|
|
250
|
+
self.copy_btn.setStyleSheet("""
|
|
251
|
+
QPushButton {
|
|
252
|
+
background-color: #28a745;
|
|
253
|
+
color: white;
|
|
254
|
+
border: none;
|
|
255
|
+
border-radius: 10px;
|
|
256
|
+
font-weight: bold;
|
|
257
|
+
font-size: 10px;
|
|
258
|
+
}
|
|
259
|
+
QPushButton:hover {
|
|
260
|
+
background-color: #218838;
|
|
261
|
+
}
|
|
262
|
+
""")
|
|
263
|
+
self.copy_btn.clicked.connect(self.copy_classifier) # Connect to instance method
|
|
264
|
+
header_layout.addWidget(self.copy_btn)
|
|
265
|
+
|
|
266
|
+
self.delete_btn = QPushButton("×")
|
|
267
|
+
self.delete_btn.setFixedSize(20, 20)
|
|
268
|
+
self.delete_btn.setStyleSheet("""
|
|
269
|
+
QPushButton {
|
|
270
|
+
background-color: #dc3545;
|
|
271
|
+
color: white;
|
|
272
|
+
border: none;
|
|
273
|
+
border-radius: 10px;
|
|
274
|
+
font-weight: bold;
|
|
275
|
+
font-size: 10px;
|
|
276
|
+
}
|
|
277
|
+
QPushButton:hover {
|
|
278
|
+
background-color: #c82333;
|
|
279
|
+
}
|
|
280
|
+
""")
|
|
281
|
+
self.delete_btn.clicked.connect(self.delete_requested)
|
|
282
|
+
header_layout.addWidget(self.delete_btn)
|
|
283
|
+
|
|
284
|
+
layout.addLayout(header_layout)
|
|
285
|
+
|
|
286
|
+
# Substring input area
|
|
287
|
+
substring_layout = QHBoxLayout()
|
|
288
|
+
substring_label = QLabel("Substrings:")
|
|
289
|
+
substring_label.setStyleSheet("font-weight: bold; color: #6c757d;")
|
|
290
|
+
substring_layout.addWidget(substring_label)
|
|
291
|
+
|
|
292
|
+
self.substring_input = QLineEdit()
|
|
293
|
+
self.substring_input.setPlaceholderText("Enter substring to match...")
|
|
294
|
+
substring_layout.addWidget(self.substring_input)
|
|
295
|
+
|
|
296
|
+
add_substring_btn = QPushButton("Add")
|
|
297
|
+
add_substring_btn.setStyleSheet("""
|
|
298
|
+
QPushButton {
|
|
299
|
+
background-color: #28a745;
|
|
300
|
+
color: white;
|
|
301
|
+
border: none;
|
|
302
|
+
padding: 5px 10px;
|
|
303
|
+
border-radius: 3px;
|
|
304
|
+
}
|
|
305
|
+
QPushButton:hover {
|
|
306
|
+
background-color: #218838;
|
|
307
|
+
}
|
|
308
|
+
""")
|
|
309
|
+
add_substring_btn.clicked.connect(self.add_substring)
|
|
310
|
+
substring_layout.addWidget(add_substring_btn)
|
|
311
|
+
|
|
312
|
+
layout.addLayout(substring_layout)
|
|
313
|
+
|
|
314
|
+
# Connect Enter key to add substring
|
|
315
|
+
self.substring_input.returnPressed.connect(self.add_substring)
|
|
316
|
+
|
|
317
|
+
# Substrings display
|
|
318
|
+
self.substrings_display = QLabel("Substrings: (none)")
|
|
319
|
+
self.substrings_display.setStyleSheet("color: #6c757d; font-style: italic; margin: 5px 0;")
|
|
320
|
+
self.substrings_display.setWordWrap(True)
|
|
321
|
+
layout.addWidget(self.substrings_display)
|
|
322
|
+
|
|
323
|
+
# New ID input
|
|
324
|
+
new_id_layout = QHBoxLayout()
|
|
325
|
+
new_id_label = QLabel("New ID:")
|
|
326
|
+
new_id_label.setStyleSheet("font-weight: bold; color: #6c757d;")
|
|
327
|
+
new_id_layout.addWidget(new_id_label)
|
|
328
|
+
|
|
329
|
+
self.new_id_input = QLineEdit()
|
|
330
|
+
self.new_id_input.setPlaceholderText("Enter new ID for matches...")
|
|
331
|
+
new_id_layout.addWidget(self.new_id_input)
|
|
332
|
+
|
|
333
|
+
layout.addLayout(new_id_layout)
|
|
334
|
+
|
|
335
|
+
self.setLayout(layout)
|
|
336
|
+
|
|
337
|
+
def add_substring(self):
|
|
338
|
+
substring = self.substring_input.text().strip()
|
|
339
|
+
if substring and substring not in self.substrings:
|
|
340
|
+
self.substrings.append(substring)
|
|
341
|
+
self.update_substrings_display()
|
|
342
|
+
self.substring_input.clear()
|
|
343
|
+
|
|
344
|
+
def update_substrings_display(self):
|
|
345
|
+
if self.substrings:
|
|
346
|
+
# Create clickable labels for each substring
|
|
347
|
+
if hasattr(self, 'substrings_container') and self.substrings_container is not None:
|
|
348
|
+
try:
|
|
349
|
+
self.substrings_container.deleteLater()
|
|
350
|
+
except RuntimeError:
|
|
351
|
+
pass # Object already deleted
|
|
352
|
+
self.substrings_container = None
|
|
353
|
+
|
|
354
|
+
self.substrings_container = QWidget()
|
|
355
|
+
container_layout = QHBoxLayout()
|
|
356
|
+
container_layout.setContentsMargins(0, 0, 0, 0)
|
|
357
|
+
|
|
358
|
+
for i, substring in enumerate(self.substrings):
|
|
359
|
+
substring_widget = QFrame()
|
|
360
|
+
substring_widget.setStyleSheet("""
|
|
361
|
+
QFrame {
|
|
362
|
+
background-color: #e9ecef;
|
|
363
|
+
border: 1px solid #adb5bd;
|
|
364
|
+
border-radius: 3px;
|
|
365
|
+
padding: 2px 5px;
|
|
366
|
+
margin: 1px;
|
|
367
|
+
}
|
|
368
|
+
""")
|
|
369
|
+
|
|
370
|
+
substring_layout = QHBoxLayout()
|
|
371
|
+
substring_layout.setContentsMargins(2, 2, 2, 2)
|
|
372
|
+
|
|
373
|
+
label = QLabel(f'"{substring}"')
|
|
374
|
+
label.setStyleSheet("background: transparent; border: none;")
|
|
375
|
+
substring_layout.addWidget(label)
|
|
376
|
+
|
|
377
|
+
remove_btn = QPushButton("×")
|
|
378
|
+
remove_btn.setFixedSize(16, 16)
|
|
379
|
+
remove_btn.setStyleSheet("""
|
|
380
|
+
QPushButton {
|
|
381
|
+
background-color: #dc3545;
|
|
382
|
+
color: white;
|
|
383
|
+
border: none;
|
|
384
|
+
border-radius: 8px;
|
|
385
|
+
font-size: 8px;
|
|
386
|
+
font-weight: bold;
|
|
387
|
+
}
|
|
388
|
+
QPushButton:hover {
|
|
389
|
+
background-color: #c82333;
|
|
390
|
+
}
|
|
391
|
+
""")
|
|
392
|
+
remove_btn.clicked.connect(lambda checked, idx=i: self.remove_substring(idx))
|
|
393
|
+
substring_layout.addWidget(remove_btn)
|
|
394
|
+
|
|
395
|
+
substring_widget.setLayout(substring_layout)
|
|
396
|
+
container_layout.addWidget(substring_widget)
|
|
397
|
+
|
|
398
|
+
container_layout.addStretch()
|
|
399
|
+
self.substrings_container.setLayout(container_layout)
|
|
400
|
+
|
|
401
|
+
# Replace the old display
|
|
402
|
+
layout = self.layout()
|
|
403
|
+
old_display_index = -1
|
|
404
|
+
for i in range(layout.count()):
|
|
405
|
+
item = layout.itemAt(i)
|
|
406
|
+
if item and item.widget() == getattr(self, 'substrings_display', None):
|
|
407
|
+
old_display_index = i
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if old_display_index >= 0:
|
|
411
|
+
layout.removeWidget(self.substrings_display)
|
|
412
|
+
try:
|
|
413
|
+
self.substrings_display.deleteLater()
|
|
414
|
+
except RuntimeError:
|
|
415
|
+
pass
|
|
416
|
+
layout.insertWidget(old_display_index, self.substrings_container)
|
|
417
|
+
else:
|
|
418
|
+
# Insert after substring input layout (index 2)
|
|
419
|
+
layout.insertWidget(2, self.substrings_container)
|
|
420
|
+
else:
|
|
421
|
+
if hasattr(self, 'substrings_container') and self.substrings_container is not None:
|
|
422
|
+
try:
|
|
423
|
+
self.substrings_container.deleteLater()
|
|
424
|
+
except RuntimeError:
|
|
425
|
+
pass
|
|
426
|
+
self.substrings_container = None
|
|
427
|
+
|
|
428
|
+
self.substrings_display = QLabel("Substrings: (none)")
|
|
429
|
+
self.substrings_display.setStyleSheet("color: #6c757d; font-style: italic; margin: 5px 0;")
|
|
430
|
+
self.substrings_display.setWordWrap(True)
|
|
431
|
+
|
|
432
|
+
layout = self.layout()
|
|
433
|
+
# Find where to insert (after substring input layout)
|
|
434
|
+
insert_index = 2 # After header and substring input
|
|
435
|
+
layout.insertWidget(insert_index, self.substrings_display)
|
|
436
|
+
|
|
437
|
+
def remove_substring(self, index):
|
|
438
|
+
if 0 <= index < len(self.substrings):
|
|
439
|
+
self.substrings.pop(index)
|
|
440
|
+
self.update_substrings_display()
|
|
441
|
+
|
|
442
|
+
def delete_requested(self):
|
|
443
|
+
# Use the stored reference instead of parent()
|
|
444
|
+
self.classifier_group_widget.remove_classifier(self.classifier_id)
|
|
445
|
+
|
|
446
|
+
def matches_identity(self, identity_str):
|
|
447
|
+
"""Check if this classifier matches the given identity string"""
|
|
448
|
+
if not self.substrings: # Empty classifier matches everything
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
identity_str = str(identity_str)
|
|
452
|
+
return all(substring in identity_str for substring in self.substrings)
|
|
453
|
+
|
|
454
|
+
def get_new_id(self):
|
|
455
|
+
"""Get the new ID for this classifier"""
|
|
456
|
+
return self.new_id_input.text().strip()
|
|
457
|
+
|
|
458
|
+
def move_up(self):
|
|
459
|
+
"""Move this classifier up using current classifier_id"""
|
|
460
|
+
self.classifier_group_widget.move_classifier_up(self.classifier_id)
|
|
461
|
+
|
|
462
|
+
def move_down(self):
|
|
463
|
+
"""Move this classifier down using current classifier_id"""
|
|
464
|
+
self.classifier_group_widget.move_classifier_down(self.classifier_id)
|
|
465
|
+
|
|
466
|
+
def copy_classifier(self):
|
|
467
|
+
"""Copy this classifier using current classifier_id"""
|
|
468
|
+
self.classifier_group_widget.copy_classifier(self.classifier_id)
|
|
469
|
+
|
|
470
|
+
def update_header_label(self):
|
|
471
|
+
"""Update the header label text"""
|
|
472
|
+
if hasattr(self, 'header_label'):
|
|
473
|
+
self.header_label.setText(f"Classifier {self.classifier_id}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class ClassifierGroupWidget(QFrame):
|
|
477
|
+
"""Widget containing multiple classifiers for enhanced search functionality"""
|
|
478
|
+
|
|
479
|
+
def __init__(self, identity_remap_widget, parent=None):
|
|
480
|
+
super().__init__(parent)
|
|
481
|
+
self.identity_remap_widget = identity_remap_widget
|
|
482
|
+
self.classifier_counter = 0
|
|
483
|
+
self.classifiers = {} # classifier_id -> ClassifierWidget
|
|
484
|
+
|
|
485
|
+
self.setStyleSheet("""
|
|
486
|
+
QFrame {
|
|
487
|
+
border: 2px solid #007acc;
|
|
488
|
+
border-radius: 5px;
|
|
489
|
+
background-color: #f8f9fa;
|
|
490
|
+
margin: 5px;
|
|
491
|
+
padding: 5px;
|
|
492
|
+
}
|
|
493
|
+
""")
|
|
494
|
+
|
|
495
|
+
layout = QVBoxLayout()
|
|
496
|
+
|
|
497
|
+
# Header
|
|
498
|
+
header_layout = QHBoxLayout()
|
|
499
|
+
header = QLabel("Enhanced Search & Classification")
|
|
500
|
+
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #007acc; margin-bottom: 5px;")
|
|
501
|
+
header_layout.addWidget(header)
|
|
502
|
+
|
|
503
|
+
header_layout.addStretch()
|
|
504
|
+
|
|
505
|
+
# Add classifier button
|
|
506
|
+
add_btn = QPushButton("+ Add Classifier")
|
|
507
|
+
add_btn.setStyleSheet("""
|
|
508
|
+
QPushButton {
|
|
509
|
+
background-color: #007acc;
|
|
510
|
+
color: white;
|
|
511
|
+
border: none;
|
|
512
|
+
padding: 5px 10px;
|
|
513
|
+
border-radius: 3px;
|
|
514
|
+
font-weight: bold;
|
|
515
|
+
}
|
|
516
|
+
QPushButton:hover {
|
|
517
|
+
background-color: #005a9e;
|
|
518
|
+
}
|
|
519
|
+
""")
|
|
520
|
+
add_btn.clicked.connect(self.add_classifier)
|
|
521
|
+
header_layout.addWidget(add_btn)
|
|
522
|
+
|
|
523
|
+
layout.addLayout(header_layout)
|
|
524
|
+
|
|
525
|
+
# Scroll area for classifiers
|
|
526
|
+
scroll = QScrollArea()
|
|
527
|
+
scroll.setWidgetResizable(True)
|
|
528
|
+
scroll.setMinimumHeight(300)
|
|
529
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
530
|
+
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
531
|
+
|
|
532
|
+
# Container for classifiers
|
|
533
|
+
self.container = QWidget()
|
|
534
|
+
self.container_layout = QVBoxLayout()
|
|
535
|
+
self.container_layout.setSpacing(5)
|
|
536
|
+
self.container_layout.addStretch()
|
|
537
|
+
self.container.setLayout(self.container_layout)
|
|
538
|
+
scroll.setWidget(self.container)
|
|
539
|
+
|
|
540
|
+
layout.addWidget(scroll)
|
|
541
|
+
|
|
542
|
+
# Preview button
|
|
543
|
+
preview_btn = QPushButton("🔍 Preview Classification")
|
|
544
|
+
preview_btn.setStyleSheet("""
|
|
545
|
+
QPushButton {
|
|
546
|
+
background-color: #ffc107;
|
|
547
|
+
color: #212529;
|
|
548
|
+
border: none;
|
|
549
|
+
padding: 8px 15px;
|
|
550
|
+
border-radius: 5px;
|
|
551
|
+
font-weight: bold;
|
|
552
|
+
font-size: 13px;
|
|
553
|
+
}
|
|
554
|
+
QPushButton:hover {
|
|
555
|
+
background-color: #e0a800;
|
|
556
|
+
}
|
|
557
|
+
""")
|
|
558
|
+
preview_btn.clicked.connect(self.preview_classification)
|
|
559
|
+
layout.addWidget(preview_btn)
|
|
560
|
+
|
|
561
|
+
self.setLayout(layout)
|
|
562
|
+
|
|
563
|
+
def add_classifier(self):
|
|
564
|
+
self.classifier_counter += 1
|
|
565
|
+
classifier_id = self.classifier_counter
|
|
566
|
+
|
|
567
|
+
# Pass reference to self (ClassifierGroupWidget) as second parameter
|
|
568
|
+
classifier_widget = ClassifierWidget(classifier_id, self, self)
|
|
569
|
+
self.classifiers[classifier_id] = classifier_widget
|
|
570
|
+
|
|
571
|
+
# Insert before the stretch
|
|
572
|
+
self.container_layout.insertWidget(self.container_layout.count() - 1, classifier_widget)
|
|
573
|
+
|
|
574
|
+
def remove_classifier(self, classifier_id):
|
|
575
|
+
if classifier_id in self.classifiers:
|
|
576
|
+
widget = self.classifiers[classifier_id]
|
|
577
|
+
self.container_layout.removeWidget(widget)
|
|
578
|
+
widget.deleteLater()
|
|
579
|
+
del self.classifiers[classifier_id]
|
|
580
|
+
self.renumber_classifiers()
|
|
581
|
+
|
|
582
|
+
def preview_classification(self):
|
|
583
|
+
"""Apply classification rules to the identity remapping widget"""
|
|
584
|
+
if not hasattr(self.identity_remap_widget, 'identity_mappings'):
|
|
585
|
+
QMessageBox.warning(self, "Warning", "No identity data loaded yet.")
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
if not self.classifiers:
|
|
589
|
+
QMessageBox.warning(self, "Warning", "No classifiers defined.")
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
# Apply classifiers in order (hierarchical)
|
|
593
|
+
classifier_ids = sorted(self.classifiers.keys()) # MOVE THIS LINE UP HERE
|
|
594
|
+
|
|
595
|
+
# Get all original identities
|
|
596
|
+
original_identities = list(self.identity_remap_widget.identity_mappings.keys())
|
|
597
|
+
matched_identities = set()
|
|
598
|
+
classifier_usage = {classifier_id: 0 for classifier_id in classifier_ids} # Now classifier_ids is defined
|
|
599
|
+
|
|
600
|
+
for identity in original_identities:
|
|
601
|
+
identity_str = str(identity)
|
|
602
|
+
|
|
603
|
+
# Check classifiers in order
|
|
604
|
+
for classifier_id in classifier_ids:
|
|
605
|
+
classifier = self.classifiers[classifier_id]
|
|
606
|
+
|
|
607
|
+
if classifier.matches_identity(identity_str):
|
|
608
|
+
# This classifier matches
|
|
609
|
+
matched_identities.add(identity)
|
|
610
|
+
classifier_usage[classifier_id] += 1 # Track usage
|
|
611
|
+
|
|
612
|
+
# Set the new ID if provided
|
|
613
|
+
new_id = classifier.get_new_id()
|
|
614
|
+
if new_id and identity in self.identity_remap_widget.identity_mappings:
|
|
615
|
+
self.identity_remap_widget.identity_mappings[identity]['new_edit'].setText(new_id)
|
|
616
|
+
|
|
617
|
+
# Move to next identity (hierarchical - first match wins)
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
# Remove identities that didn't match any classifier
|
|
621
|
+
unmatched_identities = set(original_identities) - matched_identities
|
|
622
|
+
for identity in unmatched_identities:
|
|
623
|
+
self.identity_remap_widget.remove_identity(identity)
|
|
624
|
+
|
|
625
|
+
# Show results
|
|
626
|
+
matched_count = len(matched_identities)
|
|
627
|
+
removed_count = len(unmatched_identities)
|
|
628
|
+
|
|
629
|
+
# Create usage report
|
|
630
|
+
usage_report = ""
|
|
631
|
+
for classifier_id in classifier_ids:
|
|
632
|
+
classifier = self.classifiers[classifier_id]
|
|
633
|
+
count = classifier_usage[classifier_id]
|
|
634
|
+
new_id = classifier.get_new_id() or "(no new ID set)"
|
|
635
|
+
substrings = classifier.substrings or ["(empty - matches all)"]
|
|
636
|
+
usage_report += f" Classifier {classifier_id}: {count} matches → '{new_id}'\n"
|
|
637
|
+
usage_report += f" Substrings: {substrings}\n"
|
|
638
|
+
|
|
639
|
+
QMessageBox.information(
|
|
640
|
+
self,
|
|
641
|
+
"Classification Preview Applied",
|
|
642
|
+
f"Classification complete!\n\n"
|
|
643
|
+
f"• Matched identities: {matched_count}\n"
|
|
644
|
+
f"• Removed identities: {removed_count}\n"
|
|
645
|
+
f"• Total classifiers used: {len(self.classifiers)}\n\n"
|
|
646
|
+
f"Classifier Usage:\n{usage_report}\n"
|
|
647
|
+
f"Check the Identity Remapping widget to see the results."
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def copy_classifier(self, classifier_id):
|
|
651
|
+
if classifier_id in self.classifiers:
|
|
652
|
+
original = self.classifiers[classifier_id]
|
|
653
|
+
|
|
654
|
+
# Create new classifier
|
|
655
|
+
self.classifier_counter += 1
|
|
656
|
+
new_classifier_id = self.classifier_counter
|
|
657
|
+
|
|
658
|
+
new_classifier = ClassifierWidget(new_classifier_id, self, self)
|
|
659
|
+
|
|
660
|
+
# Copy data
|
|
661
|
+
new_classifier.substrings = original.substrings.copy()
|
|
662
|
+
new_classifier.new_id_input.setText(original.new_id_input.text())
|
|
663
|
+
new_classifier.update_substrings_display()
|
|
664
|
+
|
|
665
|
+
self.classifiers[new_classifier_id] = new_classifier
|
|
666
|
+
|
|
667
|
+
# Insert after the original
|
|
668
|
+
original_index = self.get_classifier_index(classifier_id)
|
|
669
|
+
self.container_layout.insertWidget(original_index + 1, new_classifier)
|
|
670
|
+
|
|
671
|
+
self.renumber_classifiers()
|
|
672
|
+
|
|
673
|
+
def move_classifier_up(self, classifier_id):
|
|
674
|
+
current_index = self.get_classifier_index(classifier_id)
|
|
675
|
+
if current_index > 0:
|
|
676
|
+
self.swap_classifiers(current_index, current_index - 1)
|
|
677
|
+
|
|
678
|
+
def move_classifier_down(self, classifier_id):
|
|
679
|
+
current_index = self.get_classifier_index(classifier_id)
|
|
680
|
+
classifier_count = len(self.classifiers)
|
|
681
|
+
if current_index < classifier_count - 1:
|
|
682
|
+
self.swap_classifiers(current_index, current_index + 1)
|
|
683
|
+
|
|
684
|
+
def get_classifier_index(self, classifier_id):
|
|
685
|
+
"""Get the current layout index of a classifier by its ID"""
|
|
686
|
+
for i in range(self.container_layout.count() - 1): # -1 for stretch
|
|
687
|
+
widget = self.container_layout.itemAt(i).widget()
|
|
688
|
+
if (widget is not None and
|
|
689
|
+
hasattr(widget, 'classifier_id') and
|
|
690
|
+
widget.classifier_id == classifier_id):
|
|
691
|
+
return i
|
|
692
|
+
return -1
|
|
693
|
+
|
|
694
|
+
def swap_classifiers(self, index1, index2):
|
|
695
|
+
# Get widgets at the positions
|
|
696
|
+
widget1 = self.container_layout.itemAt(index1).widget()
|
|
697
|
+
widget2 = self.container_layout.itemAt(index2).widget()
|
|
698
|
+
|
|
699
|
+
if not (hasattr(widget1, 'classifier_id') and hasattr(widget2, 'classifier_id')):
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
# Remove widgets in reverse order to maintain indices
|
|
703
|
+
if index1 > index2:
|
|
704
|
+
self.container_layout.removeWidget(widget1) # Remove higher index first
|
|
705
|
+
self.container_layout.removeWidget(widget2)
|
|
706
|
+
# Now reinsert: widget1 goes to index2, widget2 goes to index1
|
|
707
|
+
self.container_layout.insertWidget(index2, widget1)
|
|
708
|
+
self.container_layout.insertWidget(index1, widget2)
|
|
709
|
+
else:
|
|
710
|
+
self.container_layout.removeWidget(widget2) # Remove higher index first
|
|
711
|
+
self.container_layout.removeWidget(widget1)
|
|
712
|
+
# Now reinsert: widget1 goes to index2, widget2 goes to index1
|
|
713
|
+
self.container_layout.insertWidget(index1, widget2)
|
|
714
|
+
self.container_layout.insertWidget(index2, widget1)
|
|
715
|
+
|
|
716
|
+
# Renumber all classifiers to maintain correct order and references
|
|
717
|
+
self.renumber_classifiers()
|
|
718
|
+
|
|
719
|
+
def renumber_classifiers(self):
|
|
720
|
+
# Create new dictionary to avoid issues during iteration
|
|
721
|
+
new_classifiers = {}
|
|
722
|
+
|
|
723
|
+
# Renumber all classifiers to maintain order
|
|
724
|
+
for i in range(self.container_layout.count() - 1): # -1 for stretch
|
|
725
|
+
widget = self.container_layout.itemAt(i).widget()
|
|
726
|
+
if hasattr(widget, 'classifier_id'):
|
|
727
|
+
old_id = widget.classifier_id
|
|
728
|
+
new_id = i + 1
|
|
729
|
+
|
|
730
|
+
# Update the widget's ID
|
|
731
|
+
widget.classifier_id = new_id
|
|
732
|
+
|
|
733
|
+
# Update the header label using the new method
|
|
734
|
+
widget.update_header_label()
|
|
735
|
+
|
|
736
|
+
# Add to new dictionary with new ID
|
|
737
|
+
new_classifiers[new_id] = widget
|
|
738
|
+
|
|
739
|
+
# Replace the old dictionary
|
|
740
|
+
self.classifiers = new_classifiers
|
|
741
|
+
|
|
742
|
+
# Update counter to the highest number
|
|
743
|
+
self.classifier_counter = len(self.classifiers)
|
|
744
|
+
|
|
745
|
+
class IdentityRemapWidget(QFrame):
|
|
746
|
+
"""Widget for remapping node identities"""
|
|
747
|
+
|
|
748
|
+
def __init__(self, parent=None):
|
|
749
|
+
super().__init__(parent)
|
|
750
|
+
self.setStyleSheet("""
|
|
751
|
+
QFrame {
|
|
752
|
+
border: 2px solid #007acc;
|
|
753
|
+
border-radius: 5px;
|
|
754
|
+
background-color: #f8f9fa;
|
|
755
|
+
margin: 5px;
|
|
756
|
+
padding: 5px;
|
|
757
|
+
}
|
|
758
|
+
""")
|
|
759
|
+
|
|
760
|
+
layout = QVBoxLayout()
|
|
761
|
+
|
|
762
|
+
# Header
|
|
763
|
+
header = QLabel("Identity Remapping & Filtering")
|
|
764
|
+
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #007acc; margin-bottom: 5px;")
|
|
765
|
+
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
766
|
+
layout.addWidget(header)
|
|
767
|
+
|
|
768
|
+
# Create scroll area for the mapping table
|
|
769
|
+
scroll = QScrollArea()
|
|
770
|
+
scroll.setWidgetResizable(True)
|
|
771
|
+
scroll.setMinimumHeight(250)
|
|
772
|
+
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
773
|
+
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
774
|
+
|
|
775
|
+
# Container for mapping rows
|
|
776
|
+
self.container = QWidget()
|
|
777
|
+
self.container_layout = QVBoxLayout()
|
|
778
|
+
self.container_layout.setSpacing(2)
|
|
779
|
+
self.container.setLayout(self.container_layout)
|
|
780
|
+
scroll.setWidget(self.container)
|
|
781
|
+
|
|
782
|
+
layout.addWidget(scroll)
|
|
783
|
+
self.setLayout(layout)
|
|
784
|
+
|
|
785
|
+
# Store mapping data
|
|
786
|
+
self.identity_mappings = {} # original_id -> {'new_edit': QLineEdit, 'row_widget': QWidget}
|
|
787
|
+
self.removed_identities = set() # Track removed identities
|
|
788
|
+
|
|
789
|
+
def populate_identities(self, identities):
|
|
790
|
+
"""Populate the left column with unique identities from the data"""
|
|
791
|
+
# Clear existing mappings
|
|
792
|
+
for i in reversed(range(self.container_layout.count())):
|
|
793
|
+
item = self.container_layout.itemAt(i)
|
|
794
|
+
if item and item.widget():
|
|
795
|
+
item.widget().deleteLater()
|
|
796
|
+
|
|
797
|
+
self.identity_mappings.clear()
|
|
798
|
+
self.removed_identities.clear()
|
|
799
|
+
|
|
800
|
+
# Get unique identities
|
|
801
|
+
unique_identities = sorted(list(set(identities)))
|
|
802
|
+
|
|
803
|
+
# Create header row
|
|
804
|
+
header_layout = QHBoxLayout()
|
|
805
|
+
orig_label = QLabel("Original ID")
|
|
806
|
+
orig_label.setStyleSheet("font-weight: bold; padding: 5px;")
|
|
807
|
+
new_label = QLabel("New ID (leave blank to keep original)")
|
|
808
|
+
new_label.setStyleSheet("font-weight: bold; padding: 5px;")
|
|
809
|
+
delete_label = QLabel("Remove")
|
|
810
|
+
delete_label.setStyleSheet("font-weight: bold; padding: 5px; text-align: center;")
|
|
811
|
+
delete_label.setFixedWidth(60)
|
|
812
|
+
|
|
813
|
+
header_layout.addWidget(orig_label, 2)
|
|
814
|
+
header_layout.addWidget(new_label, 3)
|
|
815
|
+
header_layout.addWidget(delete_label, 1)
|
|
816
|
+
|
|
817
|
+
header_widget = QWidget()
|
|
818
|
+
header_widget.setLayout(header_layout)
|
|
819
|
+
self.container_layout.addWidget(header_widget)
|
|
820
|
+
|
|
821
|
+
# Create mapping rows
|
|
822
|
+
for identity in unique_identities:
|
|
823
|
+
row_layout = QHBoxLayout()
|
|
824
|
+
|
|
825
|
+
# Original identity (read-only)
|
|
826
|
+
orig_edit = QLineEdit(str(identity))
|
|
827
|
+
orig_edit.setReadOnly(True)
|
|
828
|
+
orig_edit.setStyleSheet("background-color: #e9ecef; border: 1px solid #ced4da;")
|
|
829
|
+
orig_edit.setMinimumWidth(120) # Set initial minimum width
|
|
830
|
+
|
|
831
|
+
# New identity (editable)
|
|
832
|
+
new_edit = QLineEdit()
|
|
833
|
+
new_edit.setPlaceholderText(f"Enter new ID for {identity}")
|
|
834
|
+
new_edit.setStyleSheet("border: 1px solid #ced4da;")
|
|
835
|
+
new_edit.setMinimumWidth(180) # Set initial minimum width
|
|
836
|
+
|
|
837
|
+
# Delete button
|
|
838
|
+
delete_btn = QPushButton("×")
|
|
839
|
+
delete_btn.setFixedSize(25, 25)
|
|
840
|
+
delete_btn.setStyleSheet("""
|
|
841
|
+
QPushButton {
|
|
842
|
+
background-color: #dc3545;
|
|
843
|
+
color: white;
|
|
844
|
+
border: none;
|
|
845
|
+
border-radius: 12px;
|
|
846
|
+
font-weight: bold;
|
|
847
|
+
font-size: 12px;
|
|
848
|
+
}
|
|
849
|
+
QPushButton:hover {
|
|
850
|
+
background-color: #c82333;
|
|
851
|
+
}
|
|
852
|
+
""")
|
|
853
|
+
|
|
854
|
+
row_layout.addWidget(orig_edit, 2)
|
|
855
|
+
row_layout.addWidget(new_edit, 3)
|
|
856
|
+
row_layout.addWidget(delete_btn, 1)
|
|
857
|
+
|
|
858
|
+
row_widget = QWidget()
|
|
859
|
+
row_widget.setLayout(row_layout)
|
|
860
|
+
self.container_layout.addWidget(row_widget)
|
|
861
|
+
|
|
862
|
+
# Store the mapping
|
|
863
|
+
self.identity_mappings[identity] = {
|
|
864
|
+
'new_edit': new_edit,
|
|
865
|
+
'row_widget': row_widget
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
# Connect delete button
|
|
869
|
+
delete_btn.clicked.connect(lambda checked, id=identity: self.remove_identity(id))
|
|
870
|
+
|
|
871
|
+
def remove_identity(self, identity):
|
|
872
|
+
"""Remove an identity from the mapping widget"""
|
|
873
|
+
if identity in self.identity_mappings:
|
|
874
|
+
# Add to removed set
|
|
875
|
+
self.removed_identities.add(identity)
|
|
876
|
+
|
|
877
|
+
# Remove the widget
|
|
878
|
+
row_widget = self.identity_mappings[identity]['row_widget']
|
|
879
|
+
self.container_layout.removeWidget(row_widget)
|
|
880
|
+
row_widget.deleteLater()
|
|
881
|
+
|
|
882
|
+
# Remove from mappings
|
|
883
|
+
del self.identity_mappings[identity]
|
|
884
|
+
|
|
885
|
+
def get_remapped_identities(self, original_identities):
|
|
886
|
+
"""Return the remapped identities based on user input, filtering out removed ones"""
|
|
887
|
+
remapped = []
|
|
888
|
+
for orig_id in original_identities:
|
|
889
|
+
# Skip if identity was removed
|
|
890
|
+
if orig_id in self.removed_identities:
|
|
891
|
+
continue
|
|
892
|
+
|
|
893
|
+
if orig_id in self.identity_mappings:
|
|
894
|
+
new_id = self.identity_mappings[orig_id]['new_edit'].text().strip()
|
|
895
|
+
if new_id: # If user entered a new ID
|
|
896
|
+
remapped.append(new_id)
|
|
897
|
+
else: # If blank, keep original
|
|
898
|
+
remapped.append(orig_id)
|
|
899
|
+
else:
|
|
900
|
+
# If not in mappings but not removed, keep original
|
|
901
|
+
if orig_id not in self.removed_identities:
|
|
902
|
+
remapped.append(orig_id)
|
|
903
|
+
return remapped
|
|
904
|
+
|
|
905
|
+
def get_filtered_indices(self, original_identities):
|
|
906
|
+
"""Return indices of identities that should be kept (not removed)"""
|
|
907
|
+
kept_indices = []
|
|
908
|
+
for i, orig_id in enumerate(original_identities):
|
|
909
|
+
if orig_id not in self.removed_identities:
|
|
910
|
+
kept_indices.append(i)
|
|
911
|
+
return kept_indices
|
|
912
|
+
|
|
913
|
+
def update_font_sizes(self, scale_factor):
|
|
914
|
+
"""Update widget sizes based on scale but keep font sizes constant"""
|
|
915
|
+
# Don't change font sizes - just let the widgets resize naturally
|
|
916
|
+
# The horizontal layout will automatically make the text fields wider
|
|
917
|
+
# when the parent widget gets larger
|
|
918
|
+
|
|
919
|
+
# Set minimum widths based on scale factor to ensure readability
|
|
920
|
+
min_orig_width = max(80, int(120 * scale_factor))
|
|
921
|
+
min_new_width = max(120, int(180 * scale_factor))
|
|
922
|
+
|
|
923
|
+
# Update all line edits in the mapping
|
|
924
|
+
for identity_data in self.identity_mappings.values():
|
|
925
|
+
new_edit = identity_data['new_edit']
|
|
926
|
+
|
|
927
|
+
# Set minimum widths but don't change font
|
|
928
|
+
new_edit.setMinimumWidth(min_new_width)
|
|
929
|
+
|
|
930
|
+
# Also update the original ID field
|
|
931
|
+
row_widget = identity_data['row_widget']
|
|
932
|
+
layout = row_widget.layout()
|
|
933
|
+
if layout and layout.itemAt(0):
|
|
934
|
+
orig_edit = layout.itemAt(0).widget()
|
|
935
|
+
if isinstance(orig_edit, QLineEdit):
|
|
936
|
+
orig_edit.setMinimumWidth(min_orig_width)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class TabbedIdentityWidget(QFrame):
|
|
940
|
+
"""Widget that contains both identity remapping and classifier widgets with tabs"""
|
|
941
|
+
|
|
942
|
+
def __init__(self, parent=None):
|
|
943
|
+
super().__init__(parent)
|
|
944
|
+
|
|
945
|
+
layout = QVBoxLayout()
|
|
946
|
+
|
|
947
|
+
# Tab buttons
|
|
948
|
+
tab_layout = QHBoxLayout()
|
|
949
|
+
|
|
950
|
+
self.remap_tab_btn = QPushButton("Identity Remapping")
|
|
951
|
+
self.remap_tab_btn.setCheckable(True)
|
|
952
|
+
self.remap_tab_btn.setChecked(True)
|
|
953
|
+
self.remap_tab_btn.setStyleSheet("""
|
|
954
|
+
QPushButton {
|
|
955
|
+
background-color: #007acc;
|
|
956
|
+
color: white;
|
|
957
|
+
border: none;
|
|
958
|
+
padding: 8px 15px;
|
|
959
|
+
border-radius: 5px 5px 0 0;
|
|
960
|
+
font-weight: bold;
|
|
961
|
+
}
|
|
962
|
+
QPushButton:hover {
|
|
963
|
+
background-color: #005a9e;
|
|
964
|
+
}
|
|
965
|
+
QPushButton:checked {
|
|
966
|
+
background-color: #004d7a;
|
|
967
|
+
}
|
|
968
|
+
""")
|
|
969
|
+
self.remap_tab_btn.clicked.connect(self.show_remap_tab)
|
|
970
|
+
|
|
971
|
+
self.classifier_tab_btn = QPushButton("Enhanced Search")
|
|
972
|
+
self.classifier_tab_btn.setCheckable(True)
|
|
973
|
+
self.classifier_tab_btn.setStyleSheet("""
|
|
974
|
+
QPushButton {
|
|
975
|
+
background-color: #6c757d;
|
|
976
|
+
color: white;
|
|
977
|
+
border: none;
|
|
978
|
+
padding: 8px 15px;
|
|
979
|
+
border-radius: 5px 5px 0 0;
|
|
980
|
+
font-weight: bold;
|
|
981
|
+
}
|
|
982
|
+
QPushButton:hover {
|
|
983
|
+
background-color: #5a6268;
|
|
984
|
+
}
|
|
985
|
+
QPushButton:checked {
|
|
986
|
+
background-color: #007acc;
|
|
987
|
+
}
|
|
988
|
+
""")
|
|
989
|
+
self.classifier_tab_btn.clicked.connect(self.show_classifier_tab)
|
|
990
|
+
|
|
991
|
+
tab_layout.addWidget(self.remap_tab_btn)
|
|
992
|
+
tab_layout.addWidget(self.classifier_tab_btn)
|
|
993
|
+
tab_layout.addStretch()
|
|
994
|
+
|
|
995
|
+
layout.addLayout(tab_layout)
|
|
996
|
+
|
|
997
|
+
# Save/Load buttons
|
|
998
|
+
save_load_layout = QHBoxLayout()
|
|
999
|
+
|
|
1000
|
+
save_btn = QPushButton("💾 Save Config")
|
|
1001
|
+
save_btn.setStyleSheet("""
|
|
1002
|
+
QPushButton {
|
|
1003
|
+
background-color: #17a2b8;
|
|
1004
|
+
color: white;
|
|
1005
|
+
border: none;
|
|
1006
|
+
padding: 5px 10px;
|
|
1007
|
+
border-radius: 3px;
|
|
1008
|
+
font-weight: bold;
|
|
1009
|
+
}
|
|
1010
|
+
QPushButton:hover {
|
|
1011
|
+
background-color: #138496;
|
|
1012
|
+
}
|
|
1013
|
+
""")
|
|
1014
|
+
save_btn.clicked.connect(self.save_configuration)
|
|
1015
|
+
|
|
1016
|
+
load_btn = QPushButton("📁 Load Config")
|
|
1017
|
+
load_btn.setStyleSheet("""
|
|
1018
|
+
QPushButton {
|
|
1019
|
+
background-color: #6f42c1;
|
|
1020
|
+
color: white;
|
|
1021
|
+
border: none;
|
|
1022
|
+
padding: 5px 10px;
|
|
1023
|
+
border-radius: 3px;
|
|
1024
|
+
font-weight: bold;
|
|
1025
|
+
}
|
|
1026
|
+
QPushButton:hover {
|
|
1027
|
+
background-color: #5a2d91;
|
|
1028
|
+
}
|
|
1029
|
+
""")
|
|
1030
|
+
load_btn.clicked.connect(self.load_configuration)
|
|
1031
|
+
|
|
1032
|
+
save_load_layout.addWidget(save_btn)
|
|
1033
|
+
save_load_layout.addWidget(load_btn)
|
|
1034
|
+
save_load_layout.addStretch()
|
|
1035
|
+
|
|
1036
|
+
layout.addLayout(save_load_layout)
|
|
1037
|
+
|
|
1038
|
+
# Create both widgets
|
|
1039
|
+
self.identity_remap_widget = IdentityRemapWidget()
|
|
1040
|
+
self.classifier_group_widget = ClassifierGroupWidget(self.identity_remap_widget)
|
|
1041
|
+
|
|
1042
|
+
# Initially hide classifier widget
|
|
1043
|
+
self.classifier_group_widget.hide()
|
|
1044
|
+
|
|
1045
|
+
layout.addWidget(self.identity_remap_widget)
|
|
1046
|
+
layout.addWidget(self.classifier_group_widget)
|
|
1047
|
+
|
|
1048
|
+
self.setLayout(layout)
|
|
1049
|
+
|
|
1050
|
+
def show_remap_tab(self):
|
|
1051
|
+
self.remap_tab_btn.setChecked(True)
|
|
1052
|
+
self.classifier_tab_btn.setChecked(False)
|
|
1053
|
+
|
|
1054
|
+
self.identity_remap_widget.show()
|
|
1055
|
+
self.classifier_group_widget.hide()
|
|
1056
|
+
|
|
1057
|
+
# Update button styles
|
|
1058
|
+
self.remap_tab_btn.setStyleSheet("""
|
|
1059
|
+
QPushButton {
|
|
1060
|
+
background-color: #007acc;
|
|
1061
|
+
color: white;
|
|
1062
|
+
border: none;
|
|
1063
|
+
padding: 8px 15px;
|
|
1064
|
+
border-radius: 5px 5px 0 0;
|
|
1065
|
+
font-weight: bold;
|
|
1066
|
+
}
|
|
1067
|
+
QPushButton:hover {
|
|
1068
|
+
background-color: #005a9e;
|
|
1069
|
+
}
|
|
1070
|
+
QPushButton:checked {
|
|
1071
|
+
background-color: #004d7a;
|
|
1072
|
+
}
|
|
1073
|
+
""")
|
|
1074
|
+
|
|
1075
|
+
self.classifier_tab_btn.setStyleSheet("""
|
|
1076
|
+
QPushButton {
|
|
1077
|
+
background-color: #6c757d;
|
|
1078
|
+
color: white;
|
|
1079
|
+
border: none;
|
|
1080
|
+
padding: 8px 15px;
|
|
1081
|
+
border-radius: 5px 5px 0 0;
|
|
1082
|
+
font-weight: bold;
|
|
1083
|
+
}
|
|
1084
|
+
QPushButton:hover {
|
|
1085
|
+
background-color: #5a6268;
|
|
1086
|
+
}
|
|
1087
|
+
QPushButton:checked {
|
|
1088
|
+
background-color: #007acc;
|
|
1089
|
+
}
|
|
1090
|
+
""")
|
|
1091
|
+
|
|
1092
|
+
def show_classifier_tab(self):
|
|
1093
|
+
self.remap_tab_btn.setChecked(False)
|
|
1094
|
+
self.classifier_tab_btn.setChecked(True)
|
|
1095
|
+
|
|
1096
|
+
self.identity_remap_widget.hide()
|
|
1097
|
+
self.classifier_group_widget.show()
|
|
1098
|
+
|
|
1099
|
+
# Update button styles
|
|
1100
|
+
self.classifier_tab_btn.setStyleSheet("""
|
|
1101
|
+
QPushButton {
|
|
1102
|
+
background-color: #007acc;
|
|
1103
|
+
color: white;
|
|
1104
|
+
border: none;
|
|
1105
|
+
padding: 8px 15px;
|
|
1106
|
+
border-radius: 5px 5px 0 0;
|
|
1107
|
+
font-weight: bold;
|
|
1108
|
+
}
|
|
1109
|
+
QPushButton:hover {
|
|
1110
|
+
background-color: #005a9e;
|
|
1111
|
+
}
|
|
1112
|
+
QPushButton:checked {
|
|
1113
|
+
background-color: #004d7a;
|
|
1114
|
+
}
|
|
1115
|
+
""")
|
|
1116
|
+
|
|
1117
|
+
self.remap_tab_btn.setStyleSheet("""
|
|
1118
|
+
QPushButton {
|
|
1119
|
+
background-color: #6c757d;
|
|
1120
|
+
color: white;
|
|
1121
|
+
border: none;
|
|
1122
|
+
padding: 8px 15px;
|
|
1123
|
+
border-radius: 5px 5px 0 0;
|
|
1124
|
+
font-weight: bold;
|
|
1125
|
+
}
|
|
1126
|
+
QPushButton:hover {
|
|
1127
|
+
background-color: #5a6268;
|
|
1128
|
+
}
|
|
1129
|
+
QPushButton:checked {
|
|
1130
|
+
background-color: #007acc;
|
|
1131
|
+
}
|
|
1132
|
+
""")
|
|
1133
|
+
|
|
1134
|
+
def populate_identities(self, identities):
|
|
1135
|
+
"""Delegate to the identity remap widget"""
|
|
1136
|
+
self.identity_remap_widget.populate_identities(identities)
|
|
1137
|
+
|
|
1138
|
+
def get_remapped_identities(self, original_identities):
|
|
1139
|
+
"""Delegate to the identity remap widget"""
|
|
1140
|
+
return self.identity_remap_widget.get_remapped_identities(original_identities)
|
|
1141
|
+
|
|
1142
|
+
def get_filtered_indices(self, original_identities):
|
|
1143
|
+
"""Delegate to the identity remap widget"""
|
|
1144
|
+
return self.identity_remap_widget.get_filtered_indices(original_identities)
|
|
1145
|
+
|
|
1146
|
+
def update_font_sizes(self, scale_factor):
|
|
1147
|
+
"""Delegate to the identity remap widget"""
|
|
1148
|
+
self.identity_remap_widget.update_font_sizes(scale_factor)
|
|
1149
|
+
|
|
1150
|
+
def save_configuration(self):
|
|
1151
|
+
from PyQt6.QtWidgets import QFileDialog
|
|
1152
|
+
import json
|
|
1153
|
+
|
|
1154
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1155
|
+
self,
|
|
1156
|
+
"Save Identity Configuration",
|
|
1157
|
+
"",
|
|
1158
|
+
"JSON Files (*.json)"
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
if file_path:
|
|
1162
|
+
config = {
|
|
1163
|
+
'identity_mappings': {},
|
|
1164
|
+
'removed_identities': list(self.identity_remap_widget.removed_identities),
|
|
1165
|
+
'classifiers': []
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
# Save identity mappings
|
|
1169
|
+
for orig_id, data in self.identity_remap_widget.identity_mappings.items():
|
|
1170
|
+
config['identity_mappings'][str(orig_id)] = {
|
|
1171
|
+
'new_id': data['new_edit'].text()
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
# Save classifiers in order
|
|
1175
|
+
for i in range(self.classifier_group_widget.container_layout.count() - 1):
|
|
1176
|
+
widget = self.classifier_group_widget.container_layout.itemAt(i).widget()
|
|
1177
|
+
if hasattr(widget, 'classifier_id'):
|
|
1178
|
+
classifier_config = {
|
|
1179
|
+
'id': widget.classifier_id,
|
|
1180
|
+
'substrings': widget.substrings,
|
|
1181
|
+
'new_id': widget.get_new_id()
|
|
1182
|
+
}
|
|
1183
|
+
config['classifiers'].append(classifier_config)
|
|
1184
|
+
|
|
1185
|
+
try:
|
|
1186
|
+
with open(file_path, 'w') as f:
|
|
1187
|
+
json.dump(config, f, indent=2)
|
|
1188
|
+
QMessageBox.information(self, "Success", "Configuration saved successfully!")
|
|
1189
|
+
except Exception as e:
|
|
1190
|
+
QMessageBox.critical(self, "Error", f"Failed to save configuration: {str(e)}")
|
|
1191
|
+
|
|
1192
|
+
def load_configuration(self):
|
|
1193
|
+
from PyQt6.QtWidgets import QFileDialog
|
|
1194
|
+
import json
|
|
1195
|
+
|
|
1196
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1197
|
+
self,
|
|
1198
|
+
"Load Identity Configuration",
|
|
1199
|
+
"",
|
|
1200
|
+
"JSON Files (*.json)"
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
if file_path:
|
|
1204
|
+
try:
|
|
1205
|
+
with open(file_path, 'r') as f:
|
|
1206
|
+
config = json.load(f)
|
|
1207
|
+
|
|
1208
|
+
# Clear existing classifiers
|
|
1209
|
+
for classifier_id in list(self.classifier_group_widget.classifiers.keys()):
|
|
1210
|
+
self.classifier_group_widget.remove_classifier(classifier_id)
|
|
1211
|
+
|
|
1212
|
+
# Load identity mappings
|
|
1213
|
+
for orig_id_str, data in config.get('identity_mappings', {}).items():
|
|
1214
|
+
# Convert string back to original type if needed
|
|
1215
|
+
orig_id = orig_id_str
|
|
1216
|
+
if orig_id in self.identity_remap_widget.identity_mappings:
|
|
1217
|
+
self.identity_remap_widget.identity_mappings[orig_id]['new_edit'].setText(data['new_id'])
|
|
1218
|
+
|
|
1219
|
+
# Load removed identities
|
|
1220
|
+
for removed_id in config.get('removed_identities', []):
|
|
1221
|
+
if removed_id in self.identity_remap_widget.identity_mappings:
|
|
1222
|
+
self.identity_remap_widget.remove_identity(removed_id)
|
|
1223
|
+
|
|
1224
|
+
# Load classifiers
|
|
1225
|
+
for classifier_config in config.get('classifiers', []):
|
|
1226
|
+
self.classifier_group_widget.add_classifier()
|
|
1227
|
+
# Get the last added classifier
|
|
1228
|
+
last_classifier = list(self.classifier_group_widget.classifiers.values())[-1]
|
|
1229
|
+
last_classifier.substrings = classifier_config['substrings']
|
|
1230
|
+
last_classifier.new_id_input.setText(classifier_config['new_id'])
|
|
1231
|
+
last_classifier.update_substrings_display()
|
|
1232
|
+
|
|
1233
|
+
QMessageBox.information(self, "Success", "Configuration loaded successfully!")
|
|
1234
|
+
|
|
1235
|
+
except Exception as e:
|
|
1236
|
+
QMessageBox.critical(self, "Error", f"Failed to load configuration: {str(e)}")
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
class ExcelToDictGUI(QMainWindow):
|
|
1240
|
+
# Add this signal
|
|
1241
|
+
data_exported = pyqtSignal(dict, str) # dictionary, property_name
|
|
1242
|
+
|
|
1243
|
+
def __init__(self):
|
|
1244
|
+
super().__init__()
|
|
1245
|
+
self.df = None
|
|
1246
|
+
self.dict_columns = {} # widget_id -> column_data
|
|
1247
|
+
self.column_counter = 0
|
|
1248
|
+
self.identity_remap_widget = None
|
|
1249
|
+
|
|
1250
|
+
self.templates = {
|
|
1251
|
+
'Node Identities': ['Numerical IDs', 'Identity Column'],
|
|
1252
|
+
'Node Centroids': ['Numerical IDs', 'Z', 'Y', 'X'],
|
|
1253
|
+
'Node Communities': ['Numerical IDs', 'Community Identifier']
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
self.setWindowTitle("Excel to Python Dictionary Converter")
|
|
1257
|
+
self.setGeometry(100, 100, 1200, 800)
|
|
1258
|
+
|
|
1259
|
+
self.setup_ui()
|
|
1260
|
+
|
|
1261
|
+
def on_splitter_moved(self, pos, index):
|
|
1262
|
+
"""Handle splitter movement to update font sizes"""
|
|
1263
|
+
splitter = self.sender()
|
|
1264
|
+
sizes = splitter.sizes()
|
|
1265
|
+
total_width = sum(sizes)
|
|
1266
|
+
|
|
1267
|
+
if total_width > 0:
|
|
1268
|
+
right_width = sizes[1]
|
|
1269
|
+
# Calculate scale factor based on right panel width (300 is base width)
|
|
1270
|
+
scale_factor = max(0.7, min(2.0, right_width / 300))
|
|
1271
|
+
|
|
1272
|
+
# Update identity remapping widget font sizes
|
|
1273
|
+
if self.identity_remap_widget.isVisible():
|
|
1274
|
+
self.identity_remap_widget.update_font_sizes(scale_factor)
|
|
1275
|
+
|
|
1276
|
+
def setup_ui(self):
|
|
1277
|
+
central_widget = QWidget()
|
|
1278
|
+
self.setCentralWidget(central_widget)
|
|
1279
|
+
|
|
1280
|
+
main_layout = QHBoxLayout()
|
|
1281
|
+
|
|
1282
|
+
# Template selector at top
|
|
1283
|
+
template_layout = QHBoxLayout()
|
|
1284
|
+
template_label = QLabel("Templates:")
|
|
1285
|
+
template_label.setStyleSheet("font-weight: bold;")
|
|
1286
|
+
template_layout.addWidget(template_label)
|
|
1287
|
+
|
|
1288
|
+
self.template_combo = QComboBox()
|
|
1289
|
+
self.template_combo.addItem("Select Template...")
|
|
1290
|
+
self.template_combo.addItems(['Node Identities', 'Node Centroids', 'Node Communities'])
|
|
1291
|
+
self.template_combo.currentTextChanged.connect(self.load_template)
|
|
1292
|
+
template_layout.addWidget(self.template_combo)
|
|
1293
|
+
template_layout.addStretch()
|
|
1294
|
+
|
|
1295
|
+
template_widget = QWidget()
|
|
1296
|
+
template_widget.setLayout(template_layout)
|
|
1297
|
+
template_widget.setMaximumHeight(40)
|
|
1298
|
+
|
|
1299
|
+
# Add to main layout
|
|
1300
|
+
main_layout_with_template = QVBoxLayout()
|
|
1301
|
+
main_layout_with_template.addWidget(template_widget)
|
|
1302
|
+
main_layout_with_template.addLayout(main_layout)
|
|
1303
|
+
central_widget.setLayout(main_layout_with_template)
|
|
1304
|
+
|
|
1305
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
1306
|
+
splitter.setHandleWidth(8)
|
|
1307
|
+
splitter.setStyleSheet("""
|
|
1308
|
+
QSplitter::handle {
|
|
1309
|
+
background-color: #cccccc;
|
|
1310
|
+
border: 1px solid #999999;
|
|
1311
|
+
}
|
|
1312
|
+
QSplitter::handle:hover {
|
|
1313
|
+
background-color: #007acc;
|
|
1314
|
+
}
|
|
1315
|
+
""")
|
|
1316
|
+
|
|
1317
|
+
# Left side - Excel data viewer
|
|
1318
|
+
left_widget = QWidget()
|
|
1319
|
+
left_widget.setMinimumWidth(400) # Set minimum width
|
|
1320
|
+
left_layout = QVBoxLayout()
|
|
1321
|
+
|
|
1322
|
+
left_label = QLabel("Excel Data Viewer")
|
|
1323
|
+
left_label.setStyleSheet("font-weight: bold; font-size: 16px; margin-bottom: 10px;")
|
|
1324
|
+
left_layout.addWidget(left_label)
|
|
1325
|
+
|
|
1326
|
+
# File drop zone
|
|
1327
|
+
self.drop_zone = DropZoneWidget()
|
|
1328
|
+
self.drop_zone.file_dropped.connect(self.load_file)
|
|
1329
|
+
self.drop_zone.setFixedHeight(60)
|
|
1330
|
+
left_layout.addWidget(self.drop_zone)
|
|
1331
|
+
|
|
1332
|
+
# Excel table
|
|
1333
|
+
self.excel_table = DraggableTableWidget()
|
|
1334
|
+
self.excel_table.setAlternatingRowColors(True)
|
|
1335
|
+
self.excel_table.horizontalHeader().setStretchLastSection(True)
|
|
1336
|
+
left_layout.addWidget(self.excel_table)
|
|
1337
|
+
|
|
1338
|
+
left_widget.setLayout(left_layout)
|
|
1339
|
+
|
|
1340
|
+
# Right side - Dictionary builder
|
|
1341
|
+
right_widget = QWidget()
|
|
1342
|
+
right_widget.setMinimumWidth(300) # Set minimum width
|
|
1343
|
+
right_layout = QVBoxLayout()
|
|
1344
|
+
|
|
1345
|
+
# Header with controls
|
|
1346
|
+
header_layout = QHBoxLayout()
|
|
1347
|
+
right_label = QLabel("Python Dictionary Builder")
|
|
1348
|
+
right_label.setStyleSheet("font-weight: bold; font-size: 16px;")
|
|
1349
|
+
header_layout.addWidget(right_label)
|
|
1350
|
+
|
|
1351
|
+
# Add column button
|
|
1352
|
+
self.add_btn = QPushButton("+")
|
|
1353
|
+
self.add_btn.setFixedSize(30, 30)
|
|
1354
|
+
self.add_btn.setStyleSheet("""
|
|
1355
|
+
QPushButton {
|
|
1356
|
+
background-color: #007acc;
|
|
1357
|
+
color: white;
|
|
1358
|
+
border: none;
|
|
1359
|
+
border-radius: 15px;
|
|
1360
|
+
font-weight: bold;
|
|
1361
|
+
font-size: 16px;
|
|
1362
|
+
}
|
|
1363
|
+
QPushButton:hover {
|
|
1364
|
+
background-color: #005a9e;
|
|
1365
|
+
}
|
|
1366
|
+
""")
|
|
1367
|
+
self.add_btn.clicked.connect(self.add_dict_column)
|
|
1368
|
+
header_layout.addWidget(self.add_btn)
|
|
1369
|
+
|
|
1370
|
+
right_layout.addLayout(header_layout)
|
|
1371
|
+
|
|
1372
|
+
# Dictionary columns scroll area
|
|
1373
|
+
self.dict_scroll = QScrollArea()
|
|
1374
|
+
self.dict_scroll.setWidgetResizable(True)
|
|
1375
|
+
self.dict_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
1376
|
+
|
|
1377
|
+
self.dict_container = QWidget()
|
|
1378
|
+
self.dict_layout = QVBoxLayout()
|
|
1379
|
+
self.dict_layout.addStretch()
|
|
1380
|
+
self.dict_container.setLayout(self.dict_layout)
|
|
1381
|
+
self.dict_scroll.setWidget(self.dict_container)
|
|
1382
|
+
|
|
1383
|
+
right_layout.addWidget(self.dict_scroll)
|
|
1384
|
+
|
|
1385
|
+
# Tabbed identity remapping widget (initially hidden)
|
|
1386
|
+
self.identity_remap_widget = TabbedIdentityWidget()
|
|
1387
|
+
self.identity_remap_widget.hide()
|
|
1388
|
+
right_layout.addWidget(self.identity_remap_widget)
|
|
1389
|
+
|
|
1390
|
+
# Export controls
|
|
1391
|
+
export_layout = QHBoxLayout()
|
|
1392
|
+
|
|
1393
|
+
# Property selector
|
|
1394
|
+
self.property_combo = QComboBox()
|
|
1395
|
+
self.property_combo.addItem("Select Property...")
|
|
1396
|
+
self.property_combo.addItems(['Node Identities', 'Node Centroids', 'Node Communities'])
|
|
1397
|
+
export_layout.addWidget(self.property_combo)
|
|
1398
|
+
|
|
1399
|
+
# Export button
|
|
1400
|
+
self.export_btn = QPushButton("→ Export to NetTracer3D")
|
|
1401
|
+
self.export_btn.setStyleSheet("""
|
|
1402
|
+
QPushButton {
|
|
1403
|
+
background-color: #28a745;
|
|
1404
|
+
color: white;
|
|
1405
|
+
border: none;
|
|
1406
|
+
padding: 10px;
|
|
1407
|
+
font-weight: bold;
|
|
1408
|
+
border-radius: 5px;
|
|
1409
|
+
}
|
|
1410
|
+
QPushButton:hover {
|
|
1411
|
+
background-color: #218838;
|
|
1412
|
+
}
|
|
1413
|
+
""")
|
|
1414
|
+
self.export_btn.clicked.connect(self.export_dictionary)
|
|
1415
|
+
export_layout.addWidget(self.export_btn)
|
|
1416
|
+
|
|
1417
|
+
right_layout.addLayout(export_layout)
|
|
1418
|
+
|
|
1419
|
+
right_widget.setLayout(right_layout)
|
|
1420
|
+
|
|
1421
|
+
# Add widgets to splitter
|
|
1422
|
+
splitter.addWidget(left_widget)
|
|
1423
|
+
splitter.addWidget(right_widget)
|
|
1424
|
+
|
|
1425
|
+
# Set initial sizes (60% left, 40% right)
|
|
1426
|
+
splitter.setSizes([600, 400])
|
|
1427
|
+
|
|
1428
|
+
# Connect splitter moved signal to update font sizes
|
|
1429
|
+
splitter.splitterMoved.connect(self.on_splitter_moved)
|
|
1430
|
+
|
|
1431
|
+
# Add splitter to main layout
|
|
1432
|
+
main_layout.addWidget(splitter)
|
|
1433
|
+
|
|
1434
|
+
def load_template(self, template_name):
|
|
1435
|
+
if template_name in self.templates:
|
|
1436
|
+
# Clear existing columns
|
|
1437
|
+
for widget_id in list(self.dict_columns.keys()):
|
|
1438
|
+
self.remove_dict_column(widget_id)
|
|
1439
|
+
|
|
1440
|
+
# Clear widgets
|
|
1441
|
+
for i in reversed(range(self.dict_layout.count())):
|
|
1442
|
+
item = self.dict_layout.itemAt(i)
|
|
1443
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1444
|
+
widget = item.widget()
|
|
1445
|
+
self.dict_layout.removeWidget(widget)
|
|
1446
|
+
widget.deleteLater()
|
|
1447
|
+
|
|
1448
|
+
# Add stretch back
|
|
1449
|
+
self.dict_layout.addStretch()
|
|
1450
|
+
|
|
1451
|
+
# Add new columns for template
|
|
1452
|
+
for key_name in self.templates[template_name]:
|
|
1453
|
+
self.add_dict_column()
|
|
1454
|
+
# Get the last added widget and set its header
|
|
1455
|
+
for i in range(self.dict_layout.count()):
|
|
1456
|
+
item = self.dict_layout.itemAt(i)
|
|
1457
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1458
|
+
widget = item.widget()
|
|
1459
|
+
if widget.widget_id not in [w.widget_id for w in self.get_existing_widgets()]:
|
|
1460
|
+
widget.header_input.setText(key_name)
|
|
1461
|
+
break
|
|
1462
|
+
|
|
1463
|
+
# Set property combo to match
|
|
1464
|
+
self.property_combo.setCurrentText(template_name)
|
|
1465
|
+
|
|
1466
|
+
# Show/hide identity remapping widget
|
|
1467
|
+
if template_name == 'Node Identities':
|
|
1468
|
+
self.identity_remap_widget.show()
|
|
1469
|
+
else:
|
|
1470
|
+
self.identity_remap_widget.hide()
|
|
1471
|
+
|
|
1472
|
+
def get_existing_widgets(self):
|
|
1473
|
+
widgets = []
|
|
1474
|
+
for i in range(self.dict_layout.count()):
|
|
1475
|
+
item = self.dict_layout.itemAt(i)
|
|
1476
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1477
|
+
widgets.append(item.widget())
|
|
1478
|
+
return widgets[:-1] # Exclude the stretch
|
|
1479
|
+
|
|
1480
|
+
def load_file(self, file_path):
|
|
1481
|
+
try:
|
|
1482
|
+
if file_path.lower().endswith('.xlsx'):
|
|
1483
|
+
self.df = pd.read_excel(file_path)
|
|
1484
|
+
elif file_path.lower().endswith('.csv'):
|
|
1485
|
+
self.df = pd.read_csv(file_path)
|
|
1486
|
+
else:
|
|
1487
|
+
QMessageBox.warning(self, "Error", "Unsupported file format")
|
|
1488
|
+
return
|
|
1489
|
+
|
|
1490
|
+
self.populate_excel_table()
|
|
1491
|
+
QMessageBox.information(self, "Success", f"Loaded {len(self.df)} rows and {len(self.df.columns)} columns")
|
|
1492
|
+
|
|
1493
|
+
except Exception as e:
|
|
1494
|
+
QMessageBox.critical(self, "Error", f"Failed to load file: {str(e)}")
|
|
1495
|
+
|
|
1496
|
+
def populate_excel_table(self):
|
|
1497
|
+
if self.df is None:
|
|
1498
|
+
return
|
|
1499
|
+
|
|
1500
|
+
# Limit display to 200 rows but keep full dataframe
|
|
1501
|
+
display_rows = min(200, len(self.df))
|
|
1502
|
+
|
|
1503
|
+
self.excel_table.setRowCount(display_rows)
|
|
1504
|
+
self.excel_table.setColumnCount(len(self.df.columns))
|
|
1505
|
+
|
|
1506
|
+
# Set headers
|
|
1507
|
+
self.excel_table.setHorizontalHeaderLabels([str(col) for col in self.df.columns])
|
|
1508
|
+
|
|
1509
|
+
# Populate data
|
|
1510
|
+
for i in range(display_rows):
|
|
1511
|
+
for j, col in enumerate(self.df.columns):
|
|
1512
|
+
item = QTableWidgetItem(str(self.df.iloc[i, j]))
|
|
1513
|
+
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) # Make read-only
|
|
1514
|
+
self.excel_table.setItem(i, j, item)
|
|
1515
|
+
|
|
1516
|
+
# Resize columns to content
|
|
1517
|
+
self.excel_table.resizeColumnsToContents()
|
|
1518
|
+
|
|
1519
|
+
def add_dict_column(self):
|
|
1520
|
+
self.column_counter += 1
|
|
1521
|
+
widget_id = f"col_{self.column_counter}"
|
|
1522
|
+
|
|
1523
|
+
dict_widget = DictColumnWidget(widget_id)
|
|
1524
|
+
dict_widget.column_dropped.connect(self.on_column_dropped)
|
|
1525
|
+
dict_widget.delete_requested.connect(self.remove_dict_column)
|
|
1526
|
+
|
|
1527
|
+
# Insert before the stretch
|
|
1528
|
+
self.dict_layout.insertWidget(self.dict_layout.count() - 1, dict_widget)
|
|
1529
|
+
|
|
1530
|
+
def remove_dict_column(self, widget_id):
|
|
1531
|
+
# Find and remove the widget
|
|
1532
|
+
for i in range(self.dict_layout.count()):
|
|
1533
|
+
item = self.dict_layout.itemAt(i)
|
|
1534
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1535
|
+
if item.widget().widget_id == widget_id:
|
|
1536
|
+
widget = item.widget()
|
|
1537
|
+
self.dict_layout.removeWidget(widget)
|
|
1538
|
+
widget.deleteLater()
|
|
1539
|
+
break
|
|
1540
|
+
|
|
1541
|
+
# Remove from data storage
|
|
1542
|
+
if widget_id in self.dict_columns:
|
|
1543
|
+
del self.dict_columns[widget_id]
|
|
1544
|
+
|
|
1545
|
+
def on_column_dropped(self, widget_id, col_idx, col_name):
|
|
1546
|
+
if self.df is not None and col_idx < len(self.df.columns):
|
|
1547
|
+
# Store the column data
|
|
1548
|
+
column_data = self.df.iloc[:, col_idx].values
|
|
1549
|
+
self.dict_columns[widget_id] = {
|
|
1550
|
+
'column_name': col_name,
|
|
1551
|
+
'column_index': col_idx,
|
|
1552
|
+
'data': column_data
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
# If this is the identity column in Node Identities template, populate remapping widget
|
|
1556
|
+
current_template = self.template_combo.currentText()
|
|
1557
|
+
if current_template == 'Node Identities':
|
|
1558
|
+
# Find the widget that received the drop
|
|
1559
|
+
for i in range(self.dict_layout.count()):
|
|
1560
|
+
item = self.dict_layout.itemAt(i)
|
|
1561
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1562
|
+
if item.widget().widget_id == widget_id:
|
|
1563
|
+
key_name = item.widget().header_input.text().strip()
|
|
1564
|
+
if key_name == 'Identity Column':
|
|
1565
|
+
# Populate the identity remapping widget
|
|
1566
|
+
self.identity_remap_widget.populate_identities(column_data)
|
|
1567
|
+
break
|
|
1568
|
+
|
|
1569
|
+
def export_dictionary(self):
|
|
1570
|
+
if not self.dict_columns:
|
|
1571
|
+
QMessageBox.warning(self, "Warning", "No dictionary columns defined")
|
|
1572
|
+
return
|
|
1573
|
+
|
|
1574
|
+
property_name = self.property_combo.currentText()
|
|
1575
|
+
if property_name == "Select Property...":
|
|
1576
|
+
QMessageBox.warning(self, "Warning", "Please select a property")
|
|
1577
|
+
return
|
|
1578
|
+
|
|
1579
|
+
try:
|
|
1580
|
+
result_dict = {}
|
|
1581
|
+
|
|
1582
|
+
# Build dictionary from all defined columns
|
|
1583
|
+
for widget_id in self.dict_columns:
|
|
1584
|
+
# Find the corresponding widget to get the key name
|
|
1585
|
+
for i in range(self.dict_layout.count()):
|
|
1586
|
+
item = self.dict_layout.itemAt(i)
|
|
1587
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1588
|
+
if item.widget().widget_id == widget_id:
|
|
1589
|
+
key_name = item.widget().header_input.text().strip()
|
|
1590
|
+
if key_name:
|
|
1591
|
+
column_data = self.dict_columns[widget_id]['data']
|
|
1592
|
+
|
|
1593
|
+
# Apply identity remapping and filtering if this is Node Identities
|
|
1594
|
+
if property_name == 'Node Identities':
|
|
1595
|
+
if key_name == 'Identity Column':
|
|
1596
|
+
# Get filtered indices and remapped identities
|
|
1597
|
+
filtered_indices = self.identity_remap_widget.get_filtered_indices(column_data.tolist())
|
|
1598
|
+
filtered_data = [column_data[i] for i in filtered_indices]
|
|
1599
|
+
remapped_data = self.identity_remap_widget.get_remapped_identities(filtered_data)
|
|
1600
|
+
result_dict[key_name] = remapped_data
|
|
1601
|
+
elif key_name == 'Numerical IDs':
|
|
1602
|
+
|
|
1603
|
+
# Check if user actually dropped a numerical IDs column
|
|
1604
|
+
if widget_id not in self.dict_columns or 'data' not in self.dict_columns[widget_id]:
|
|
1605
|
+
# Auto-generate sequential IDs and assign to column_data
|
|
1606
|
+
column_data = np.array(list(range(1, len(self.df) + 1)))
|
|
1607
|
+
|
|
1608
|
+
# Now use the exact same logic as if user provided the data
|
|
1609
|
+
identity_column_data = None
|
|
1610
|
+
# Find the identity column data
|
|
1611
|
+
for other_widget_id in self.dict_columns:
|
|
1612
|
+
for j in range(self.dict_layout.count()):
|
|
1613
|
+
item_j = self.dict_layout.itemAt(j)
|
|
1614
|
+
if item_j and item_j.widget() and hasattr(item_j.widget(), 'widget_id'):
|
|
1615
|
+
if item_j.widget().widget_id == other_widget_id:
|
|
1616
|
+
other_key_name = item_j.widget().header_input.text().strip()
|
|
1617
|
+
if other_key_name == 'Identity Column':
|
|
1618
|
+
identity_column_data = self.dict_columns[other_widget_id]['data']
|
|
1619
|
+
break
|
|
1620
|
+
if identity_column_data is not None:
|
|
1621
|
+
break
|
|
1622
|
+
|
|
1623
|
+
if identity_column_data is not None:
|
|
1624
|
+
filtered_indices = self.identity_remap_widget.get_filtered_indices(identity_column_data.tolist())
|
|
1625
|
+
filtered_numerical_ids = [column_data[i] for i in filtered_indices]
|
|
1626
|
+
result_dict[key_name] = filtered_numerical_ids
|
|
1627
|
+
else:
|
|
1628
|
+
result_dict[key_name] = column_data.tolist()
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
else:
|
|
1632
|
+
result_dict[key_name] = column_data.tolist()
|
|
1633
|
+
else:
|
|
1634
|
+
result_dict[key_name] = column_data.tolist()
|
|
1635
|
+
break
|
|
1636
|
+
|
|
1637
|
+
for i in range(self.dict_layout.count()):
|
|
1638
|
+
item = self.dict_layout.itemAt(i)
|
|
1639
|
+
if item and item.widget() and hasattr(item.widget(), 'widget_id'):
|
|
1640
|
+
widget = item.widget()
|
|
1641
|
+
widget_id = widget.widget_id
|
|
1642
|
+
key_name = widget.header_input.text().strip()
|
|
1643
|
+
|
|
1644
|
+
# Skip if already processed (has dropped data) or no key name
|
|
1645
|
+
if widget_id in self.dict_columns or not key_name:
|
|
1646
|
+
continue
|
|
1647
|
+
|
|
1648
|
+
# Handle auto-generation for Node Identities template
|
|
1649
|
+
if property_name == 'Node Identities' and key_name == 'Numerical IDs':
|
|
1650
|
+
|
|
1651
|
+
# Find the identity column data
|
|
1652
|
+
identity_column_data = None
|
|
1653
|
+
for other_widget_id in self.dict_columns:
|
|
1654
|
+
for j in range(self.dict_layout.count()):
|
|
1655
|
+
item_j = self.dict_layout.itemAt(j)
|
|
1656
|
+
if item_j and item_j.widget() and hasattr(item_j.widget(), 'widget_id'):
|
|
1657
|
+
if item_j.widget().widget_id == other_widget_id:
|
|
1658
|
+
other_key_name = item_j.widget().header_input.text().strip()
|
|
1659
|
+
if other_key_name == 'Identity Column':
|
|
1660
|
+
identity_column_data = self.dict_columns[other_widget_id]['data']
|
|
1661
|
+
break
|
|
1662
|
+
if identity_column_data is not None:
|
|
1663
|
+
break
|
|
1664
|
+
|
|
1665
|
+
if identity_column_data is not None:
|
|
1666
|
+
# Auto-generate sequential IDs
|
|
1667
|
+
auto_generated_ids = np.array(list(range(1, len(self.df) + 1)))
|
|
1668
|
+
|
|
1669
|
+
filtered_indices = self.identity_remap_widget.get_filtered_indices(identity_column_data.tolist())
|
|
1670
|
+
|
|
1671
|
+
filtered_numerical_ids = [auto_generated_ids[i] for i in filtered_indices]
|
|
1672
|
+
|
|
1673
|
+
result_dict[key_name] = filtered_numerical_ids
|
|
1674
|
+
else:
|
|
1675
|
+
# Fallback: generate sequential IDs for all rows
|
|
1676
|
+
result_dict[key_name] = list(range(1, len(self.df) + 1))
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
if not result_dict:
|
|
1680
|
+
QMessageBox.warning(self, "Warning", "No valid dictionary keys defined")
|
|
1681
|
+
return
|
|
1682
|
+
|
|
1683
|
+
# Emit signal to parent application
|
|
1684
|
+
self.data_exported.emit(result_dict, property_name)
|
|
1685
|
+
|
|
1686
|
+
# Still store in global variables for backward compatibility
|
|
1687
|
+
import builtins
|
|
1688
|
+
builtins.excel_dict = result_dict
|
|
1689
|
+
builtins.target_property = property_name
|
|
1690
|
+
|
|
1691
|
+
# Show success message with preview
|
|
1692
|
+
preview = str(result_dict)
|
|
1693
|
+
if len(preview) > 150:
|
|
1694
|
+
preview = preview[:150] + "..."
|
|
1695
|
+
|
|
1696
|
+
QMessageBox.information(
|
|
1697
|
+
self,
|
|
1698
|
+
"Export Successful",
|
|
1699
|
+
f"Dictionary exported for property '{property_name}'.\n\nData sent to parent application.\n\nPreview:\n{preview}"
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
except Exception as e:
|
|
1703
|
+
QMessageBox.critical(self, "Error", f"Failed to export dictionary: {str(e)}")
|
|
1704
|
+
|
|
1705
|
+
def main(standalone=True):
|
|
1706
|
+
if standalone:
|
|
1707
|
+
app = QApplication(sys.argv)
|
|
1708
|
+
app.setStyle('Fusion')
|
|
1709
|
+
|
|
1710
|
+
window = ExcelToDictGUI()
|
|
1711
|
+
window.show()
|
|
1712
|
+
|
|
1713
|
+
sys.exit(app.exec())
|
|
1714
|
+
else:
|
|
1715
|
+
# Return a fresh instance of the class
|
|
1716
|
+
return ExcelToDictGUI
|
|
1717
|
+
|
|
1718
|
+
if __name__ == "__main__":
|
|
1719
|
+
main(True)
|