• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

anthonypdawson / vector-inspector / 22584832749

02 Mar 2026 04:16PM UTC coverage: 79.791% (-0.2%) from 79.983%
22584832749

push

github

anthonypdawson
feat: update release notes for version 0.5.4 and improve WebEngine object disposal to prevent shutdown warnings

40 of 74 new or added lines in 3 files covered. (54.05%)

26 existing lines in 1 file now uncovered.

11474 of 14380 relevant lines covered (79.79%)

0.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.41
/src/vector_inspector/ui/views/visualization_view.py
1
"""Vector visualization view with dimensionality reduction (modular panels)."""
2

3
from __future__ import annotations
1✔
4

5
import tempfile
1✔
6
import traceback
1✔
7
import webbrowser
1✔
8
from datetime import UTC
1✔
9
from typing import Any, Optional
1✔
10

11
import numpy as np
1✔
12
from PySide6.QtCore import QThread, Signal
1✔
13
from PySide6.QtWidgets import (
1✔
14
    QCheckBox,
15
    QHBoxLayout,
16
    QLabel,
17
    QMessageBox,
18
    QSpinBox,
19
    QTabWidget,
20
    QVBoxLayout,
21
    QWidget,
22
)
23

24
from vector_inspector.core.connection_manager import ConnectionInstance
1✔
25

26
# Feature flags now accessed via app_state.advanced_features_enabled
27
from vector_inspector.core.logging import log_error, log_info
1✔
28
from vector_inspector.services import ClusterRunner, ThreadedTaskRunner
1✔
29
from vector_inspector.services.visualization_service import VisualizationService
1✔
30
from vector_inspector.state import AppState
1✔
31
from vector_inspector.ui.components.loading_dialog import LoadingDialog
1✔
32
from vector_inspector.ui.styles import (
1✔
33
    TAB_FONT_SIZE,
34
    TAB_FONT_WEIGHT,
35
    TAB_PADDING,
36
)
37
from vector_inspector.ui.views.visualization import ClusteringPanel, DRPanel, HistogramPanel, PlotPanel
1✔
38

39

40
class VisualizationThread(QThread):
1✔
41
    """Background thread for dimensionality reduction."""
42

43
    finished = Signal(np.ndarray)
1✔
44
    error = Signal(str)
1✔
45

46
    def __init__(self, embeddings, method, n_components):
1✔
47
        super().__init__()
1✔
48
        self.embeddings = embeddings
1✔
49
        self.method = method
1✔
50
        self.n_components = n_components
1✔
51

52
    def run(self):
1✔
53
        """Run dimensionality reduction."""
54
        try:
1✔
55
            result = VisualizationService.reduce_dimensions(
1✔
56
                self.embeddings, method=self.method, n_components=self.n_components
57
            )
58
            if result is not None:
1✔
59
                self.finished.emit(result)
1✔
60
            else:
61
                self.error.emit("Dimensionality reduction failed")
1✔
62
        except Exception as e:
1✔
63
            traceback.print_exc()
1✔
64
            self.error.emit(str(e))
1✔
65

66

67
class ClusteringThread(QThread):
1✔
68
    """Background thread for clustering."""
69

70
    finished = Signal(object)  # cluster_labels, algorithm
1✔
71
    error = Signal(str)
1✔
72

73
    def __init__(self, embeddings, algorithm, params):
1✔
74
        super().__init__()
1✔
75
        self.embeddings = embeddings
1✔
76
        self.algorithm = algorithm
1✔
77
        self.params = params
1✔
78

79
    def run(self):
1✔
80
        """Run clustering."""
81
        try:
1✔
82
            from vector_inspector.core.clustering import run_clustering
1✔
83

84
            labels, algorithm = run_clustering(self.embeddings, self.algorithm, self.params)
1✔
85
            self.finished.emit((labels, algorithm))
1✔
86
        except Exception as e:
1✔
87
            traceback.print_exc()
1✔
88
            self.error.emit(str(e))
1✔
89

90

91
class VisualizationDataLoadThread(QThread):
1✔
92
    """Background thread for loading visualization data."""
93

94
    finished = Signal(dict)  # data
1✔
95
    error = Signal(str)
1✔
96

97
    def __init__(self, connection, collection, sample_size, parent=None):
1✔
98
        super().__init__(parent)
1✔
99
        self.connection = connection
1✔
100
        self.collection = collection
1✔
101
        self.sample_size = sample_size
1✔
102

103
    def run(self):
1✔
104
        """Load data from collection."""
105
        try:
1✔
106
            if not self.connection:
1✔
107
                self.error.emit("No database connection available")
1✔
108
                return
1✔
109

110
            if self.sample_size is None:
1✔
111
                data = self.connection.get_all_items(self.collection)
1✔
112
            else:
113
                data = self.connection.get_all_items(self.collection, limit=self.sample_size)
1✔
114

115
            if data:
1✔
116
                self.finished.emit(data)
1✔
117
            else:
118
                self.error.emit("Failed to load data")
1✔
119
        except Exception as e:
1✔
120
            traceback.print_exc()
1✔
121
            self.error.emit(str(e))
1✔
122

123

124
class VisualizationView(QWidget):
1✔
125
    """View for visualizing vectors in 2D/3D using modular panels."""
126

127
    # Signal emitted when user wants to view a point in data browser
128
    view_in_data_browser_requested = Signal(str)  # item_id
1✔
129

130
    app_state: AppState
1✔
131
    task_runner: ThreadedTaskRunner
1✔
132
    cluster_runner: ClusterRunner
1✔
133

134
    def __init__(
1✔
135
        self,
136
        app_state: AppState,
137
        task_runner: ThreadedTaskRunner,
138
        connection_manager=None,
139
        parent=None,
140
    ):
141
        super().__init__(parent)
1✔
142

143
        # Store AppState and task runner
144
        self.app_state = app_state
1✔
145
        self.task_runner = task_runner
1✔
146
        self.cluster_runner = ClusterRunner()
1✔
147
        self.connection = self.app_state.provider
1✔
148

149
        self.current_collection = ""
1✔
150
        self.current_data = None
1✔
151
        self.reduced_data = None
1✔
152
        self.visualization_thread = None
1✔
153
        self.data_load_thread = None
1✔
154
        self.clustering_thread = None
1✔
155
        self.temp_html_files = []
1✔
156
        self.cluster_labels = None
1✔
157
        self._last_temp_html = None
1✔
158
        self.loading_dialog = LoadingDialog("Loading visualization...", self)
1✔
159
        self._connection_manager = connection_manager
1✔
160
        self._setup_ui()
1✔
161
        self._connect_plot_signals()
1✔
162

163
        # Connect to AppState signals
164
        self._connect_state_signals()
1✔
165
        # Update services with current connection if available
166
        if self.app_state.provider:
1✔
167
            self._on_provider_changed(self.app_state.provider)
1✔
168

169
    def _connect_state_signals(self) -> None:
1✔
170
        """Subscribe to AppState changes."""
171
        # React to connection changes
172
        self.app_state.provider_changed.connect(self._on_provider_changed)
1✔
173

174
        # React to collection changes
175
        self.app_state.collection_changed.connect(self._on_collection_changed)
1✔
176

177
        # React to loading state
178
        self.app_state.loading_started.connect(self._on_loading_started)
1✔
179
        self.app_state.loading_finished.connect(self._on_loading_finished)
1✔
180

181
        # React to errors
182
        self.app_state.error_occurred.connect(self._on_error)
1✔
183

184
    def _on_provider_changed(self, connection: Optional[ConnectionInstance]) -> None:
1✔
185
        """React to provider/connection change."""
186
        # Update connection
187
        self.connection = connection
1✔
188
        self.histogram_panel.set_connection(connection)
1✔
189

190
    def _on_collection_changed(self, collection: str) -> None:
1✔
191
        """React to collection change."""
192
        if collection:
1✔
193
            self.set_collection(collection)
1✔
194

195
    def _on_loading_started(self, message: str) -> None:
1✔
196
        """React to loading started."""
197
        self.loading_dialog.show_loading(message)
1✔
198

199
    def _on_loading_finished(self) -> None:
1✔
200
        """React to loading finished."""
201
        self.loading_dialog.hide()
1✔
202

203
    def _on_error(self, title: str, message: str) -> None:
1✔
204
        """React to error."""
205
        QMessageBox.critical(self, title, message)
1✔
206

207
    def _connect_plot_signals(self):
1✔
208
        """Connect plot panel signals."""
209
        self.plot_panel.view_in_data_browser.connect(self._on_view_in_data_browser)
1✔
210

211
    def _setup_ui(self):
1✔
212
        layout = QVBoxLayout(self)
1✔
213

214
        # Shared controls (sample size + use all data)
215
        shared_layout = QHBoxLayout()
1✔
216
        shared_layout.addWidget(QLabel("Sample size:"))
1✔
217
        self.sample_spin = QSpinBox()
1✔
218
        self.sample_spin.setMinimum(10)
1✔
219
        # Feature gating: limit sample size in free version
220
        if self.app_state.advanced_features_enabled:
1✔
221
            self.sample_spin.setMaximum(10000)
×
222
        else:
223
            self.sample_spin.setMaximum(500)
1✔
224
        self.sample_spin.setValue(500)
1✔
225
        self.sample_spin.setSingleStep(100)
1✔
226
        shared_layout.addWidget(self.sample_spin)
1✔
227
        self.use_all_checkbox = QCheckBox("Use all data")
1✔
228
        shared_layout.addWidget(self.use_all_checkbox)
1✔
229
        shared_layout.addStretch()
1✔
230
        layout.addLayout(shared_layout)
1✔
231

232
        # Feature gating: disable "Use all data" in free version
233
        if not self.app_state.advanced_features_enabled:
1✔
234
            self.use_all_checkbox.setEnabled(False)
1✔
235
            self.use_all_checkbox.setToolTip(self.app_state.get_feature_tooltip())
1✔
236

237
        def on_use_all_changed():
1✔
238
            self.sample_spin.setEnabled(not self.use_all_checkbox.isChecked())
1✔
239

240
        self.use_all_checkbox.stateChanged.connect(on_use_all_changed)
1✔
241

242
        # Modular panels
243
        self.clustering_panel = ClusteringPanel(self, app_state=self.app_state)
1✔
244
        self.dr_panel = DRPanel(self)
1✔
245
        self.plot_panel = PlotPanel(self)
1✔
246
        self.histogram_panel = HistogramPanel(self)
1✔
247
        self.histogram_panel.set_connection_manager(self._connection_manager)
1✔
248

249
        # Tab widget: Tab 1 = Visualization, Tab 2 = Distributions
250
        self.tab_widget = QTabWidget()
1✔
251

252
        viz_tab = QWidget()
1✔
253
        viz_layout = QVBoxLayout(viz_tab)
1✔
254
        viz_layout.setContentsMargins(0, 0, 0, 0)
1✔
255
        viz_layout.addWidget(self.clustering_panel)
1✔
256
        viz_layout.addWidget(self.dr_panel)
1✔
257
        viz_layout.addWidget(self.plot_panel, stretch=10)
1✔
258
        self.tab_widget.addTab(viz_tab, "Visualization")
1✔
259

260
        self.tab_widget.addTab(self.histogram_panel, "Distributions")
1✔
261

262
        # Make tabs more noticeable: add emoji and slightly heavier styling
263
        try:
1✔
264
            self.tab_widget.setTabText(0, "🔬 Visualization")
1✔
265
            self.tab_widget.setTabText(1, "📊 Distributions")
1✔
266
            # Local stylesheet on the QTabBar to increase weight/padding and
267
            # give a subtle selected-background so the tabs stand out.
268
            # Use highlight color from user settings (if present) to stay consistent
269
            try:
1✔
270
                # Only apply the tab highlight styling when the user explicitly
271
                # enabled accent styling. Avoids unexpectedly changing the
272
                # default widget appearance for new users.
273
                if self.app_state.settings_service.get_use_accent_enabled():
1✔
274
                    highlight = self.app_state.settings_service.get_highlight_color()
1✔
275
                    highlight_bg = self.app_state.settings_service.get_highlight_color_bg()
1✔
276

277
                    tab_style = (
1✔
278
                        f"QTabBar::tab {{ font-weight: {TAB_FONT_WEIGHT}; padding: {TAB_PADDING}; font-size: {TAB_FONT_SIZE};}}"
279
                        f"QTabBar::tab:selected {{ background-color: {highlight_bg}; border-bottom: 2px solid {highlight}; }}"
280
                    )
281
                    self.tab_widget.tabBar().setStyleSheet(tab_style)
1✔
282
                # else: leave native tab styling
283
            except Exception:
×
284
                # Best-effort; avoid crashing if styling not supported in some envs
285
                pass
×
286
        except Exception:
×
287
            # Best-effort; avoid crashing if styling not supported in some envs
288
            pass
×
289

290
        layout.addWidget(self.tab_widget, stretch=10)
1✔
291

292
        self.status_label = QLabel("No collection selected")
1✔
293
        self.status_label.setStyleSheet("color: gray;")
1✔
294
        self.status_label.setMaximumHeight(30)
1✔
295
        layout.addWidget(self.status_label)
1✔
296

297
        # Connect DRPanel generate button
298
        self.dr_panel.generate_button.clicked.connect(self._generate_visualization)
1✔
299
        self.dr_panel.open_browser_button.clicked.connect(self._open_in_browser)
1✔
300

301
        # Connect ClusteringPanel run button
302
        self.clustering_panel.cluster_button.clicked.connect(self._run_clustering)
1✔
303

304
    def _generate_visualization(self):
1✔
305
        """Generate visualization of vectors."""
306
        # Disable browser button until plot is generated
307
        self.dr_panel.open_browser_button.setEnabled(False)
1✔
308

309
        if not self.current_collection:
1✔
310
            QMessageBox.warning(self, "No Collection", "Please select a collection first.")
1✔
311
            return
1✔
312

313
        if self.use_all_checkbox.isChecked():
1✔
314
            sample_size = None
1✔
315
        else:
316
            sample_size = self.sample_spin.value()
1✔
317
        self._last_sample_size = sample_size
1✔
318

319
        # Cancel any existing data load thread
320
        if self.data_load_thread and self.data_load_thread.isRunning():
1✔
321
            self.data_load_thread.quit()
1✔
322
            self.data_load_thread.wait()
1✔
323

324
        # Create and start data load thread
325
        self.data_load_thread = VisualizationDataLoadThread(
1✔
326
            self.connection,
327
            self.current_collection,
328
            sample_size,
329
            parent=self,
330
        )
331
        self.data_load_thread.finished.connect(self._on_data_loaded)
1✔
332
        self.data_load_thread.error.connect(self._on_data_load_error)
1✔
333

334
        # Show loading dialog during data load
335
        self.loading_dialog.show_loading("Loading data for visualization...")
1✔
336
        self.data_load_thread.start()
1✔
337

338
    def _on_data_loaded(self, data: dict) -> None:
1✔
339
        """Handle successful data load."""
340
        self.loading_dialog.hide_loading()
1✔
341

342
        if (
1✔
343
            data is None
344
            or not data
345
            or "embeddings" not in data
346
            or data["embeddings"] is None
347
            or len(data["embeddings"]) == 0
348
        ):
349
            QMessageBox.warning(
1✔
350
                self,
351
                "No Data",
352
                "No embeddings found in collection. Make sure the collection contains vector embeddings.",
353
            )
354
            return
1✔
355

356
        self.current_data = data
1✔
357
        self.histogram_panel.set_data(
1✔
358
            data,
359
            collection_name=self.current_collection,
360
            sample_size=getattr(self, "_last_sample_size", None),
361
        )
362
        self.status_label.setText("Reducing dimensions...")
1✔
363
        self.dr_panel.generate_button.setEnabled(False)
1✔
364

365
        # Get parameters
366
        method = self.dr_panel.method_combo.currentText().lower()
1✔
367
        if method == "t-sne":
1✔
368
            method = "tsne"
1✔
369
        n_components = 2 if self.dr_panel.dimensions_combo.currentText() == "2D" else 3
1✔
370

371
        # Run dimensionality reduction in background thread
372
        self.visualization_thread = VisualizationThread(data["embeddings"], method, n_components)
1✔
373
        self.visualization_thread.finished.connect(self._on_reduction_finished)
1✔
374
        self.visualization_thread.error.connect(self._on_reduction_error)
1✔
375
        # Show loading during reduction
376
        self.loading_dialog.show_loading("Reducing dimensions...")
1✔
377
        self.visualization_thread.start()
1✔
378

379
    def _on_data_load_error(self, error_message: str) -> None:
1✔
380
        """Handle data load error."""
381
        self.loading_dialog.hide_loading()
1✔
382
        QMessageBox.warning(
1✔
383
            self,
384
            "Load Error",
385
            f"Failed to load data: {error_message}",
386
        )
387

388
    def _on_reduction_finished(self, reduced_data: Any):
1✔
389
        """Handle dimensionality reduction completion."""
390
        self.loading_dialog.hide_loading()
1✔
391
        self.reduced_data = reduced_data
1✔
392
        self.plot_panel.create_plot(
1✔
393
            reduced_data=reduced_data,
394
            current_data=self.current_data,
395
            cluster_labels=self.cluster_labels,
396
            method_name=self.dr_panel.method_combo.currentText(),
397
        )
398
        self._save_temp_html()
1✔
399
        self.dr_panel.generate_button.setEnabled(True)
1✔
400
        self.dr_panel.open_browser_button.setEnabled(True)
1✔
401
        self.status_label.setText("Visualization complete")
1✔
402

403
    def _on_reduction_error(self, error_msg: str):
1✔
404
        """Handle dimensionality reduction error."""
405
        self.loading_dialog.hide_loading()
1✔
406
        log_error("Visualization failed: %s", error_msg)
1✔
407
        QMessageBox.warning(self, "Error", f"Visualization failed: {error_msg}")
1✔
408
        self.dr_panel.generate_button.setEnabled(True)
1✔
409
        self.status_label.setText("Visualization failed")
1✔
410

411
    def _save_temp_html(self):
1✔
412
        """Save current plot HTML to temp file for browser viewing."""
413
        html = self.plot_panel.get_current_html()
1✔
414
        if html:
1✔
415
            with tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8") as temp_file:
1✔
416
                temp_file.write(html)
1✔
417
                temp_file.flush()
1✔
418
                self.temp_html_files.append(temp_file.name)
1✔
419
                self._last_temp_html = temp_file.name
1✔
420

421
    def _open_in_browser(self):
1✔
422
        """Open the last generated plot in a web browser."""
423
        if self._last_temp_html:
1✔
424
            webbrowser.open(f"file://{self._last_temp_html}")
1✔
425

426
    def _run_clustering(self):
1✔
427
        """Run clustering on current data."""
428
        if not self.current_collection:
1✔
429
            QMessageBox.warning(self, "No Collection", "Please select a collection first.")
1✔
430
            return
1✔
431

432
        # Load data if not already loaded
433
        if self.current_data is None:
1✔
434
            if self.use_all_checkbox.isChecked():
1✔
435
                sample_size = None
×
436
            else:
437
                sample_size = self.sample_spin.value()
1✔
438

439
            # Cancel any existing data load thread
440
            if self.data_load_thread and self.data_load_thread.isRunning():
1✔
441
                self.data_load_thread.quit()
×
442
                self.data_load_thread.wait()
×
443

444
            # Create and start data load thread for clustering
445
            self.data_load_thread = VisualizationDataLoadThread(
1✔
446
                self.connection,
447
                self.current_collection,
448
                sample_size,
449
                parent=self,
450
            )
451
            self.data_load_thread.finished.connect(self._on_clustering_data_loaded)
1✔
452
            self.data_load_thread.error.connect(self._on_data_load_error)
1✔
453

454
            # Show loading dialog during data load
455
            self.loading_dialog.show_loading("Loading data for clustering...")
1✔
456
            self.data_load_thread.start()
1✔
457
        else:
458
            # Data already loaded, proceed with clustering
459
            self._start_clustering()
1✔
460

461
    def _on_clustering_data_loaded(self, data: dict) -> None:
1✔
462
        """Handle successful data load for clustering."""
463
        self.loading_dialog.hide_loading()
1✔
464

465
        if (
1✔
466
            data is None
467
            or not data
468
            or "embeddings" not in data
469
            or data["embeddings"] is None
470
            or len(data["embeddings"]) == 0
471
        ):
472
            QMessageBox.warning(
1✔
473
                self,
474
                "No Data",
475
                "No embeddings found in collection.",
476
            )
477
            return
1✔
478

479
        self.current_data = data
1✔
480
        self.histogram_panel.set_data(
1✔
481
            data,
482
            collection_name=self.current_collection,
483
            sample_size=getattr(self, "_last_sample_size", None),
484
        )
485
        self._start_clustering()
1✔
486

487
    def _start_clustering(self) -> None:
1✔
488
        """Start clustering with already loaded data."""
489
        # Get algorithm and parameters from panel
490
        algorithm = self.clustering_panel.cluster_algorithm_combo.currentText()
1✔
491
        params = self.clustering_panel.get_clustering_params()
1✔
492

493
        # Run clustering in background thread
494
        self.loading_dialog.show_loading("Running clustering...")
1✔
495
        self.clustering_panel.cluster_button.setEnabled(False)
1✔
496

497
        self.clustering_thread = ClusteringThread(self.current_data["embeddings"], algorithm, params)
1✔
498
        self.clustering_thread.finished.connect(self._on_clustering_finished)
1✔
499
        self.clustering_thread.error.connect(self._on_clustering_error)
1✔
500
        self.clustering_thread.start()
1✔
501

502
    def _on_clustering_finished(self, result):
1✔
503
        """Handle clustering completion."""
504
        self.loading_dialog.hide_loading()
1✔
505
        labels, algo = result
1✔
506
        self.cluster_labels = labels
1✔
507

508
        # Count clusters
509
        unique_labels = set(self.cluster_labels)
1✔
510
        # Update clustering result label in panel
511
        if algo in ["HDBSCAN", "DBSCAN", "OPTICS"]:
1✔
512
            n_clusters = len([label for label in unique_labels if label != -1])
1✔
513
            n_noise = list(self.cluster_labels).count(-1)
1✔
514
            msg = f"Found {n_clusters} clusters, {n_noise} noise points"
1✔
515
        else:
516
            n_clusters = len(unique_labels)
1✔
517
            msg = f"Found {n_clusters} clusters"
1✔
518

519
        self.clustering_panel.cluster_result_label.setText(msg)
1✔
520
        self.clustering_panel.cluster_result_label.setVisible(True)
1✔
521
        self.status_label.setText(msg)
1✔
522
        self.status_label.setStyleSheet("color: green;")
1✔
523
        self.clustering_panel.cluster_button.setEnabled(True)
1✔
524

525
        # Save cluster labels to metadata if checkbox is checked
526
        if self.clustering_panel.save_to_metadata_checkbox.isChecked():
1✔
527
            self._save_cluster_labels_to_metadata()
1✔
528

529
        # Recreate plot with cluster colors if we have reduced data
530
        if self.reduced_data is not None:
1✔
531
            self.plot_panel.create_plot(
1✔
532
                reduced_data=self.reduced_data,
533
                current_data=self.current_data,
534
                cluster_labels=self.cluster_labels,
535
                method_name=self.dr_panel.method_combo.currentText(),
536
            )
537
            self._save_temp_html()
1✔
538

539
    def _save_cluster_labels_to_metadata(self):
1✔
540
        """Save cluster labels to item metadata in the database."""
541
        if not self.current_data or not self.cluster_labels.any():
1✔
542
            return
1✔
543

544
        if not self.connection:
1✔
545
            log_error("Cannot save cluster labels: no database connection")
1✔
546
            return
1✔
547

548
        if not self.current_collection:
1✔
549
            log_error("Cannot save cluster labels: no collection selected")
1✔
550
            return
1✔
551

552
        try:
1✔
553
            from datetime import datetime
1✔
554

555
            ids = self.current_data.get("ids", [])
1✔
556
            metadatas = self.current_data.get("metadatas", [])
1✔
557

558
            # Update metadata with cluster labels
559
            updated_metadatas = []
1✔
560
            for i, (item_id, metadata) in enumerate(zip(ids, metadatas)):
1✔
561
                if i >= len(self.cluster_labels):
1✔
562
                    break
×
563

564
                # Create a copy of metadata to avoid modifying original
565
                updated_meta = dict(metadata) if metadata else {}
1✔
566
                updated_meta["cluster"] = int(self.cluster_labels[i])
1✔
567
                updated_meta["updated_at"] = datetime.now(UTC).isoformat()
1✔
568
                updated_metadatas.append(updated_meta)
1✔
569

570
            # Batch update all items with new cluster metadata
571
            success = self.connection.update_items(
1✔
572
                self.current_collection,
573
                ids=ids[: len(updated_metadatas)],
574
                metadatas=updated_metadatas,
575
            )
576

577
            if success:
1✔
578
                log_info("Successfully saved %d cluster labels to metadata", len(updated_metadatas))
1✔
579
                # Update local cache
580
                self.current_data["metadatas"] = updated_metadatas
1✔
581
            else:
582
                log_error("Failed to save cluster labels to metadata")
1✔
583
                QMessageBox.warning(
1✔
584
                    self,
585
                    "Warning",
586
                    "Clustering complete, but failed to save cluster labels to metadata.",
587
                )
588
        except Exception as e:
1✔
589
            log_error("Error saving cluster labels to metadata: %s", e)
1✔
590
            QMessageBox.warning(self, "Warning", f"Clustering complete, but error saving labels to metadata: {e!s}")
1✔
591

592
    def _on_clustering_error(self, error_msg: str):
1✔
593
        """Handle clustering error."""
594
        self.loading_dialog.hide_loading()
1✔
595
        log_error("Clustering failed: %s", error_msg)
1✔
596
        QMessageBox.warning(self, "Error", f"Clustering failed: {error_msg}")
1✔
597
        self.clustering_panel.cluster_button.setEnabled(True)
1✔
598
        self.status_label.setText("Clustering failed")
1✔
599

600
    def set_collection(self, collection_name: str):
1✔
601
        """Set the current collection to visualize."""
602
        self.current_collection = collection_name
1✔
603
        self.current_data = None
1✔
604
        self.reduced_data = None
1✔
605
        self.cluster_labels = None
1✔
606
        # Clear clustering results when switching collection/provider
607
        try:
1✔
608
            if hasattr(self, "clustering_panel") and hasattr(self.clustering_panel, "cluster_result_label"):
1✔
609
                self.clustering_panel.cluster_result_label.setVisible(False)
1✔
610
                self.clustering_panel.cluster_result_label.setText("")
1✔
611
        except Exception:
1✔
612
            pass
1✔
613

614
        self.status_label.setText(f"Collection: {collection_name}")
1✔
615

616
    def _on_view_in_data_browser(self, _point_index: int, point_id: str):
1✔
617
        """Handle button click to view selected point in data browser.
618

619
        Args:
620
            _point_index: Index of the selected point (unused)
621
            point_id: ID of the selected point
622
        """
623
        if point_id:
1✔
624
            self.view_in_data_browser_requested.emit(point_id)
1✔
625

626
    def cleanup_temp_html(self):
1✔
627
        """Clean up temporary HTML files."""
628
        import contextlib
1✔
629
        import os
1✔
630

631
        from PySide6.QtWidgets import QApplication
1✔
632

633
        # Dispose webengine objects in child panels first so pages/views
634
        # are deleted before the WebEngineProfile is released by Qt.
635
        try:
1✔
636
            try:
1✔
637
                if hasattr(self, "plot_panel") and getattr(self.plot_panel, "dispose", None):
1✔
638
                    self.plot_panel.dispose()
1✔
NEW
639
            except Exception:
×
NEW
640
                pass
×
641
            try:
1✔
642
                if hasattr(self, "histogram_panel") and getattr(self.histogram_panel, "dispose", None):
1✔
643
                    self.histogram_panel.dispose()
1✔
NEW
644
            except Exception:
×
NEW
645
                pass
×
646

647
            # Let Qt process deletion events to avoid race conditions
648
            try:
1✔
649
                QApplication.processEvents()
1✔
NEW
650
            except Exception:
×
NEW
651
                pass
×
NEW
652
        except Exception:
×
NEW
653
            pass
×
654

655
        for f in getattr(self, "temp_html_files", []):
1✔
656
            with contextlib.suppress(Exception):
1✔
657
                os.remove(f)
1✔
658
        self.temp_html_files = []
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc