nettracer3d 0.7.5__py3-none-any.whl → 0.7.7__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,1669 @@
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
+ # Apply same filtering to Numerical IDs
1603
+ identity_column_data = None
1604
+ # Find the identity column data
1605
+ for other_widget_id in self.dict_columns:
1606
+ for j in range(self.dict_layout.count()):
1607
+ item_j = self.dict_layout.itemAt(j)
1608
+ if item_j and item_j.widget() and hasattr(item_j.widget(), 'widget_id'):
1609
+ if item_j.widget().widget_id == other_widget_id:
1610
+ other_key_name = item_j.widget().header_input.text().strip()
1611
+ if other_key_name == 'Identity Column':
1612
+ identity_column_data = self.dict_columns[other_widget_id]['data']
1613
+ break
1614
+ if identity_column_data is not None:
1615
+ break
1616
+
1617
+ if identity_column_data is not None:
1618
+ filtered_indices = self.identity_remap_widget.get_filtered_indices(identity_column_data.tolist())
1619
+ filtered_numerical_ids = [column_data[i] for i in filtered_indices]
1620
+ result_dict[key_name] = filtered_numerical_ids
1621
+ else:
1622
+ result_dict[key_name] = column_data.tolist()
1623
+ else:
1624
+ result_dict[key_name] = column_data.tolist()
1625
+ else:
1626
+ result_dict[key_name] = column_data.tolist()
1627
+ break
1628
+
1629
+ if not result_dict:
1630
+ QMessageBox.warning(self, "Warning", "No valid dictionary keys defined")
1631
+ return
1632
+
1633
+ # Emit signal to parent application
1634
+ self.data_exported.emit(result_dict, property_name)
1635
+
1636
+ # Still store in global variables for backward compatibility
1637
+ import builtins
1638
+ builtins.excel_dict = result_dict
1639
+ builtins.target_property = property_name
1640
+
1641
+ # Show success message with preview
1642
+ preview = str(result_dict)
1643
+ if len(preview) > 150:
1644
+ preview = preview[:150] + "..."
1645
+
1646
+ QMessageBox.information(
1647
+ self,
1648
+ "Export Successful",
1649
+ f"Dictionary exported for property '{property_name}'.\n\nData sent to parent application.\n\nPreview:\n{preview}"
1650
+ )
1651
+
1652
+ except Exception as e:
1653
+ QMessageBox.critical(self, "Error", f"Failed to export dictionary: {str(e)}")
1654
+
1655
+ def main(standalone=True):
1656
+ if standalone:
1657
+ app = QApplication(sys.argv)
1658
+ app.setStyle('Fusion')
1659
+
1660
+ window = ExcelToDictGUI()
1661
+ window.show()
1662
+
1663
+ sys.exit(app.exec())
1664
+ else:
1665
+ # Return a fresh instance of the class
1666
+ return ExcelToDictGUI
1667
+
1668
+ if __name__ == "__main__":
1669
+ main(True)