• 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

90.91
/src/vector_inspector/ui/views/visualization/plot_panel.py
1
"""Plot panel for displaying vector visualizations."""
2

3
from typing import Any, Optional
1✔
4

5
from PySide6.QtCore import QObject, Signal, Slot
1✔
6
from PySide6.QtWebChannel import QWebChannel
1✔
7
from PySide6.QtWebEngineCore import QWebEngineSettings
1✔
8
from PySide6.QtWebEngineWidgets import QWebEngineView
1✔
9
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
1✔
10

11

12
class PlotEventBridge(QObject):
1✔
13
    """Bridge for receiving events from Plotly JavaScript."""
14

15
    point_selected = Signal(int, str)  # Signal(point_index, point_id)
1✔
16

17
    def __init__(self, parent=None):
1✔
18
        super().__init__(parent)
1✔
19

20
    @Slot(int, str)
1✔
21
    def onPointSelected(self, point_index: int, point_id: str):
1✔
22
        """Called from JavaScript when a point is selected."""
23
        self.point_selected.emit(point_index, point_id)
1✔
24

25

26
class PlotPanel(QWidget):
1✔
27
    # Signal emitted when user clicks "View in Data Browser" button
28
    view_in_data_browser = Signal(int, str)  # point_index, point_id
1✔
29

30
    def __init__(self, parent=None):
1✔
31
        super().__init__(parent)
1✔
32
        self._current_html = None
1✔
33
        self._current_ids = []
1✔
34
        self._selected_index = None
1✔
35
        self._selected_id = None
1✔
36
        self._cluster_labels = None
1✔
37
        self._event_bridge = PlotEventBridge(self)
1✔
38
        self._event_bridge.point_selected.connect(self._on_point_selected)
1✔
39
        self._setup_ui()
1✔
40

41
    def _setup_ui(self):
1✔
42
        layout = QVBoxLayout(self)
1✔
43
        self.web_view = QWebEngineView()
1✔
44

45
        # Set up web channel for JS-Qt communication
46
        self.channel = QWebChannel()
1✔
47
        self.channel.registerObject("plotBridge", self._event_bridge)
1✔
48
        self.web_view.page().setWebChannel(self.channel)
1✔
49

50
        # Enable JavaScript
51
        settings = self.web_view.settings()
1✔
52
        settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
1✔
53

54
        layout.addWidget(self.web_view, stretch=10)
1✔
55

56
        # Add button bar below plot (for 2D point selection)
57
        self.selection_container = QWidget()
1✔
58
        button_layout = QHBoxLayout(self.selection_container)
1✔
59
        button_layout.setContentsMargins(0, 0, 0, 0)
1✔
60

61
        self.selection_label = QLabel("No point selected")
1✔
62
        self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
63
        button_layout.addWidget(self.selection_label)
1✔
64

65
        button_layout.addStretch()
1✔
66

67
        self.clear_selection_button = QPushButton("Clear Selection")
1✔
68
        self.clear_selection_button.setEnabled(False)
1✔
69
        self.clear_selection_button.clicked.connect(self._on_clear_selection_clicked)
1✔
70
        button_layout.addWidget(self.clear_selection_button)
1✔
71

72
        self.view_data_button = QPushButton("View Selected Point in Data Browser")
1✔
73
        self.view_data_button.setEnabled(False)
1✔
74
        self.view_data_button.clicked.connect(self._on_view_data_clicked)
1✔
75
        button_layout.addWidget(self.view_data_button)
1✔
76

77
        layout.addWidget(self.selection_container)
1✔
78
        self.setLayout(layout)
1✔
79

80
    def _on_point_selected(self, point_index: int, point_id: str):
1✔
81
        """Handle point selection/deselection from plot (toggle behavior)."""
82
        if point_index < 0:
1✔
83
            # Deselection
84
            self._selected_index = None
1✔
85
            self._selected_id = None
1✔
86
            self.selection_label.setText("No point selected")
1✔
87
            self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
88
            self.view_data_button.setEnabled(False)
1✔
89
            self.clear_selection_button.setEnabled(False)
1✔
90
        else:
91
            # Selection
92
            self._selected_index = point_index
1✔
93
            self._selected_id = point_id
1✔
94

95
            # Build label with cluster info if available
96
            label_text = f"Selected: Point #{point_index + 1} (ID: {point_id})"
1✔
97
            if self._cluster_labels is not None and point_index < len(self._cluster_labels):
1✔
98
                cluster_id = int(self._cluster_labels[point_index])
1✔
99
                cluster_text = "Noise" if cluster_id == -1 else str(cluster_id)
1✔
100
                label_text += f" | Cluster: {cluster_text}"
1✔
101

102
            self.selection_label.setText(label_text)
1✔
103
            self.selection_label.setStyleSheet("color: green;")
1✔
104
            self.view_data_button.setEnabled(True)
1✔
105
            self.clear_selection_button.setEnabled(True)
1✔
106

107
    def _on_clear_selection_clicked(self):
1✔
108
        """Handle Clear Selection button click."""
109
        # Clear selection in the plot
110
        js_code = """
1✔
111
        var plotDiv = document.getElementsByClassName('plotly-graph-div')[0];
112
        if (plotDiv && typeof Plotly !== 'undefined') {
113
            Plotly.restyle(plotDiv, {'selectedpoints': [null]});
114
        }
115
        """
116
        self.web_view.page().runJavaScript(js_code)
1✔
117

118
        # Trigger deselection in UI
119
        self._on_point_selected(-1, "")
1✔
120

121
    def _on_view_data_clicked(self):
1✔
122
        """Handle View in Data Browser button click."""
123
        if self._selected_index is not None and self._selected_id is not None:
1✔
124
            self.view_in_data_browser.emit(self._selected_index, self._selected_id)
1✔
125

126
    def create_plot(
1✔
127
        self,
128
        reduced_data: Any,
129
        current_data: dict,
130
        cluster_labels: Optional[Any],
131
        method_name: str,
132
    ):
133
        """Create and display plotly visualization.
134

135
        Args:
136
            reduced_data: Dimensionality-reduced embeddings (2D or 3D numpy array)
137
            current_data: Dictionary with 'ids', 'documents', 'embeddings', etc.
138
            cluster_labels: Optional array of cluster labels for coloring points
139
            method_name: Name of DR method (PCA, t-SNE, UMAP) for titles
140
        """
141
        if reduced_data is None or current_data is None:
1✔
142
            return
1✔
143

144
        # Clear previous selection when creating new plot
145
        self._selected_index = None
1✔
146
        self._selected_id = None
1✔
147
        self._cluster_labels = cluster_labels
1✔
148
        self.selection_label.setText("No point selected")
1✔
149
        self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
150
        self.view_data_button.setEnabled(False)
1✔
151
        self.clear_selection_button.setEnabled(False)
1✔
152

153
        # Show/hide selection UI based on plot type (2D vs 3D)
154
        is_2d = reduced_data.shape[1] == 2
1✔
155
        self.selection_container.setVisible(is_2d)
1✔
156

157
        # Lazy import plotly
158
        from vector_inspector.utils.lazy_imports import get_plotly
1✔
159

160
        go = get_plotly()
1✔
161

162
        ids = current_data.get("ids", [])
1✔
163
        documents = current_data.get("documents", [])
1✔
164

165
        # Store IDs for event handling
166
        self._current_ids = ids
1✔
167

168
        # Prepare hover text
169
        hover_texts = []
1✔
170
        for i, (id_val, doc) in enumerate(zip(ids, documents, strict=True)):
1✔
171
            doc_preview = str(doc)[:100] if doc else "No document"
1✔
172
            cluster_info = ""
1✔
173
            # Add cluster info if clustering was performed
174
            if cluster_labels is not None and i < len(cluster_labels):
1✔
175
                cluster_id = int(cluster_labels[i])
1✔
176
                cluster_info = f"<br>Cluster: {cluster_id if cluster_id >= 0 else 'Noise'}"
1✔
177
            hover_texts.append(f"ID: {id_val}<br>Doc: {doc_preview}{cluster_info}")
1✔
178

179
        # Determine colors
180
        if cluster_labels is not None:
1✔
181
            # Color by cluster
182
            colors = cluster_labels
1✔
183
            colorscale = "Viridis"
1✔
184
        else:
185
            # Color by index (default gradient)
186
            colors = list(range(len(ids)))
1✔
187
            colorscale = "Viridis"
1✔
188

189
        # Create plot
190
        if reduced_data.shape[1] == 2:
1✔
191
            # 2D plot
192
            fig = go.Figure(
1✔
193
                data=[
194
                    go.Scatter(
195
                        x=reduced_data[:, 0],
196
                        y=reduced_data[:, 1],
197
                        mode="markers",
198
                        marker={
199
                            "size": 8,
200
                            "color": colors,
201
                            "colorscale": colorscale,
202
                            "showscale": True,
203
                        },
204
                        text=hover_texts,
205
                        hoverinfo="text",
206
                    )
207
                ]
208
            )
209

210
            fig.update_layout(
1✔
211
                title=f"Vector Visualization - {method_name}",
212
                xaxis_title=f"{method_name} Dimension 1",
213
                yaxis_title=f"{method_name} Dimension 2",
214
                hovermode="closest",
215
                height=800,
216
                width=1200,
217
                clickmode="event+select",  # Enable selection on click
218
            )
219
        else:
220
            # 3D plot
221
            fig = go.Figure(
1✔
222
                data=[
223
                    go.Scatter3d(
224
                        x=reduced_data[:, 0],
225
                        y=reduced_data[:, 1],
226
                        z=reduced_data[:, 2],
227
                        mode="markers",
228
                        marker={
229
                            "size": 5,
230
                            "color": colors,
231
                            "colorscale": colorscale,
232
                            "showscale": True,
233
                        },
234
                        text=hover_texts,
235
                        hoverinfo="text",
236
                    )
237
                ]
238
            )
239
            fig.update_layout(
1✔
240
                title=f"Vector Visualization - {method_name}",
241
                scene={
242
                    "xaxis_title": f"{method_name} Dimension 1",
243
                    "yaxis_title": f"{method_name} Dimension 2",
244
                    "zaxis_title": f"{method_name} Dimension 3",
245
                },
246
                height=800,
247
                width=1200,
248
                clickmode="event+select",  # Enable selection on click
249
            )
250

251
        # Display in embedded web view
252
        html = fig.to_html(include_plotlyjs="cdn")
1✔
253

254
        # Inject JavaScript for selection tracking via QWebChannel (2D plots only)
255
        js_injection = """
1✔
256
        <script src="qrc:///qtwebchannel/qwebchannel.js"></script>
257
        <script>
258
            var plotBridge = null;
259
            var selectedPointIndex = -1;
260

261
            // Initialize QWebChannel and wait for it to be ready
262
            new QWebChannel(qt.webChannelTransport, function(channel) {
263
                plotBridge = channel.objects.plotBridge;
264

265
                // Set up Plotly selection event handlers (2D plots only)
266
                var plotDiv = document.getElementsByClassName('plotly-graph-div')[0];
267
                if (plotDiv && plotDiv.on) {
268
                    // Use plotly_selected for 2D plots (works with clickmode="event+select")
269
                    plotDiv.on('plotly_selected', function(data) {
270
                        if (data && data.points && data.points.length > 0) {
271
                            var point = data.points[0];
272
                            if (!point || point.pointIndex === undefined || point.pointIndex === null) {
273
                                return;
274
                            }
275
                            var pointIndex = point.pointIndex;
276
                            
277
                            // Toggle: if clicking same point, deselect
278
                            if (selectedPointIndex === pointIndex) {
279
                                selectedPointIndex = -1;
280
                                if (plotBridge && plotBridge.onPointSelected) {
281
                                    plotBridge.onPointSelected(-1, '');
282
                                }
283
                                return;
284
                            }
285
                            
286
                            selectedPointIndex = pointIndex;
287
                            
288
                            // Extract ID from hover text
289
                            var pointId = String(pointIndex);
290
                            if (point.text) {
291
                                var match = point.text.match(/ID:\\s*([^<\\r\\n]+)/);
292
                                if (match && match[1]) {
293
                                    pointId = match[1].trim();
294
                                }
295
                            }
296

297
                            if (plotBridge && plotBridge.onPointSelected) {
298
                                plotBridge.onPointSelected(pointIndex, pointId);
299
                            }
300
                        }
301
                    });
302
                    
303
                    // Handle explicit deselection
304
                    plotDiv.on('plotly_deselect', function() {
305
                        selectedPointIndex = -1;
306
                        if (plotBridge && plotBridge.onPointSelected) {
307
                            plotBridge.onPointSelected(-1, '');
308
                        }
309
                    });
310
                }
311
            });
312
        </script>
313
        """
314

315
        # Insert JS before closing body tag
316
        html = html.replace("</body>", js_injection + "</body>")
1✔
317

318
        self._current_html = html
1✔
319
        self.web_view.setHtml(html)
1✔
320

321
    def get_current_html(self) -> Optional[str]:
1✔
322
        """Get the current plot HTML for saving/export."""
323
        return self._current_html
1✔
324

325
    def dispose(self) -> None:
1✔
326
        """Explicitly dispose of WebEngine objects to avoid profile-release warnings.
327

328
        This removes the web channel, requests deletion of the page and view,
329
        and clears references so Qt can tear them down in the correct order.
330
        """
331
        try:
1✔
332
            if hasattr(self, "web_view") and self.web_view is not None:
1✔
333
                try:
1✔
334
                    page = self.web_view.page()
1✔
335
                    # Disconnect web channel if set on the page
336
                    try:
1✔
337
                        page.setWebChannel(None)
1✔
NEW
338
                    except Exception:
×
NEW
339
                        pass
×
340
                    page.deleteLater()
1✔
NEW
341
                except Exception:
×
NEW
342
                    pass
×
343

344
                try:
1✔
345
                    self.web_view.setParent(None)
1✔
NEW
346
                except Exception:
×
NEW
347
                    pass
×
348
                try:
1✔
349
                    self.web_view.deleteLater()
1✔
NEW
350
                except Exception:
×
NEW
351
                    pass
×
352
                self.web_view = None
1✔
353

354
            if hasattr(self, "channel") and self.channel is not None:
1✔
355
                try:
1✔
356
                    self.channel.deleteLater()
1✔
NEW
357
                except Exception:
×
NEW
358
                    pass
×
359
                self.channel = None
1✔
360

361
            if hasattr(self, "_event_bridge") and self._event_bridge is not None:
1✔
362
                try:
1✔
363
                    self._event_bridge.deleteLater()
1✔
NEW
364
                except Exception:
×
NEW
365
                    pass
×
366
                self._event_bridge = None
1✔
NEW
367
        except Exception:
×
368
            # Best-effort disposal; do not raise during app shutdown
NEW
369
            pass
×
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