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

anthonypdawson / vector-inspector / 23299931735

19 Mar 2026 02:29PM UTC coverage: 80.654%. First build
23299931735

Pull #27

github

anthonypdawson
feat: enhance telemetry tests with detailed comments on fixture behavior and API expectations
Pull Request #27: Telemetry, LLM and bug fixes

417 of 598 new or added lines in 18 files covered. (69.73%)

13483 of 16717 relevant lines covered (80.65%)

0.81 hits per line

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

90.53
/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
from vector_inspector.services.telemetry_service import TelemetryService
1✔
12

13

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

17
    point_selected = Signal(int, str)  # Signal(point_index, point_id)
1✔
18
    interaction = Signal(str, int)  # Signal(action, selected_count)
1✔
19

20
    def __init__(self, parent=None):
1✔
21
        super().__init__(parent)
1✔
22

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

28
    @Slot(str, int)
1✔
29
    def onInteraction(self, action: str, selected_count: int):
1✔
30
        """Called from JavaScript for interactions like zoom/pan/lasso."""
31
        try:
1✔
32
            self.interaction.emit(action, int(selected_count))
1✔
NEW
33
        except Exception:
×
NEW
34
            pass
×
35

36

37
class PlotPanel(QWidget):
1✔
38
    # Signal emitted when user clicks "View in Data Browser" button
39
    view_in_data_browser = Signal(int, str)  # point_index, point_id
1✔
40

41
    def __init__(self, parent=None):
1✔
42
        super().__init__(parent)
1✔
43
        self._current_html = None
1✔
44
        self._current_ids = []
1✔
45
        self._selected_index = None
1✔
46
        self._selected_id = None
1✔
47
        self._cluster_labels = None
1✔
48
        self._event_bridge = PlotEventBridge(self)
1✔
49
        self._event_bridge.point_selected.connect(self._on_point_selected)
1✔
50
        self._event_bridge.interaction.connect(self._on_interaction)
1✔
51
        self._setup_ui()
1✔
52

53
    def _setup_ui(self):
1✔
54
        layout = QVBoxLayout(self)
1✔
55
        self.web_view = QWebEngineView()
1✔
56

57
        # Set up web channel for JS-Qt communication
58
        self.channel = QWebChannel()
1✔
59
        self.channel.registerObject("plotBridge", self._event_bridge)
1✔
60
        self.web_view.page().setWebChannel(self.channel)
1✔
61

62
        # Enable JavaScript
63
        settings = self.web_view.settings()
1✔
64
        settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
1✔
65

66
        layout.addWidget(self.web_view, stretch=10)
1✔
67

68
        # Add button bar below plot (for 2D point selection)
69
        self.selection_container = QWidget()
1✔
70
        button_layout = QHBoxLayout(self.selection_container)
1✔
71
        button_layout.setContentsMargins(0, 0, 0, 0)
1✔
72

73
        self.selection_label = QLabel("No point selected")
1✔
74
        self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
75
        button_layout.addWidget(self.selection_label)
1✔
76

77
        button_layout.addStretch()
1✔
78

79
        self.clear_selection_button = QPushButton("Clear Selection")
1✔
80
        self.clear_selection_button.setEnabled(False)
1✔
81
        self.clear_selection_button.clicked.connect(self._on_clear_selection_clicked)
1✔
82
        button_layout.addWidget(self.clear_selection_button)
1✔
83

84
        self.view_data_button = QPushButton("View Selected Point in Data Browser")
1✔
85
        self.view_data_button.setEnabled(False)
1✔
86
        self.view_data_button.clicked.connect(self._on_view_data_clicked)
1✔
87
        button_layout.addWidget(self.view_data_button)
1✔
88

89
        layout.addWidget(self.selection_container)
1✔
90
        self.setLayout(layout)
1✔
91

92
    def _on_point_selected(self, point_index: int, point_id: str):
1✔
93
        """Handle point selection/deselection from plot (toggle behavior)."""
94
        if point_index < 0:
1✔
95
            # Deselection
96
            self._selected_index = None
1✔
97
            self._selected_id = None
1✔
98
            self.selection_label.setText("No point selected")
1✔
99
            self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
100
            self.view_data_button.setEnabled(False)
1✔
101
            self.clear_selection_button.setEnabled(False)
1✔
102
        else:
103
            # Selection
104
            self._selected_index = point_index
1✔
105
            self._selected_id = point_id
1✔
106

107
            # Build label with cluster info if available
108
            label_text = f"Selected: Point #{point_index + 1} (ID: {point_id})"
1✔
109
            if self._cluster_labels is not None and point_index < len(self._cluster_labels):
1✔
110
                cluster_id = int(self._cluster_labels[point_index])
1✔
111
                cluster_text = "Noise" if cluster_id == -1 else str(cluster_id)
1✔
112
                label_text += f" | Cluster: {cluster_text}"
1✔
113

114
            self.selection_label.setText(label_text)
1✔
115
            self.selection_label.setStyleSheet("color: green;")
1✔
116
            self.view_data_button.setEnabled(True)
1✔
117
            self.clear_selection_button.setEnabled(True)
1✔
118

119
    def _on_clear_selection_clicked(self):
1✔
120
        """Handle Clear Selection button click."""
121
        # Clear selection in the plot
122
        js_code = """
1✔
123
        var plotDiv = document.getElementsByClassName('plotly-graph-div')[0];
124
        if (plotDiv && typeof Plotly !== 'undefined') {
125
            Plotly.restyle(plotDiv, {'selectedpoints': [null]});
126
        }
127
        """
128
        self.web_view.page().runJavaScript(js_code)
1✔
129

130
        # Trigger deselection in UI
131
        self._on_point_selected(-1, "")
1✔
132

133
    def _on_view_data_clicked(self):
1✔
134
        """Handle View in Data Browser button click."""
135
        if self._selected_index is not None and self._selected_id is not None:
1✔
136
            self.view_in_data_browser.emit(self._selected_index, self._selected_id)
1✔
137

138
    def _on_interaction(self, action: str, selected_count: int):
1✔
139
        """Handle generic plot interactions from JS bridge and emit telemetry."""
140
        try:
1✔
141
            collection = getattr(self.parent(), "current_collection", "") or ""
1✔
142
            TelemetryService.send_event(
1✔
143
                "ui.visualization_interacted",
144
                {"metadata": {"action": action, "selected_count": int(selected_count), "collection_name": collection}},
145
            )
146
        except Exception:
1✔
147
            pass
1✔
148

149
    def create_plot(
1✔
150
        self,
151
        reduced_data: Any,
152
        current_data: dict,
153
        cluster_labels: Optional[Any],
154
        method_name: str,
155
    ):
156
        """Create and display plotly visualization.
157

158
        Args:
159
            reduced_data: Dimensionality-reduced embeddings (2D or 3D numpy array)
160
            current_data: Dictionary with 'ids', 'documents', 'embeddings', etc.
161
            cluster_labels: Optional array of cluster labels for coloring points
162
            method_name: Name of DR method (PCA, t-SNE, UMAP) for titles
163
        """
164
        if reduced_data is None or current_data is None:
1✔
165
            return
1✔
166

167
        # Clear previous selection when creating new plot
168
        self._selected_index = None
1✔
169
        self._selected_id = None
1✔
170
        self._cluster_labels = cluster_labels
1✔
171
        self.selection_label.setText("No point selected")
1✔
172
        self.selection_label.setStyleSheet("color: gray; font-style: italic;")
1✔
173
        self.view_data_button.setEnabled(False)
1✔
174
        self.clear_selection_button.setEnabled(False)
1✔
175

176
        # Show/hide selection UI based on plot type (2D vs 3D)
177
        is_2d = reduced_data.shape[1] == 2
1✔
178
        self.selection_container.setVisible(is_2d)
1✔
179

180
        # Lazy import plotly
181
        from vector_inspector.utils.lazy_imports import get_plotly
1✔
182

183
        go = get_plotly()
1✔
184

185
        ids = current_data.get("ids", [])
1✔
186
        documents = current_data.get("documents", [])
1✔
187

188
        # Store IDs for event handling
189
        self._current_ids = ids
1✔
190

191
        # Prepare hover text
192
        hover_texts = []
1✔
193
        for i, (id_val, doc) in enumerate(zip(ids, documents, strict=True)):
1✔
194
            doc_preview = str(doc)[:100] if doc else "No document"
1✔
195
            cluster_info = ""
1✔
196
            # Add cluster info if clustering was performed
197
            if cluster_labels is not None and i < len(cluster_labels):
1✔
198
                cluster_id = int(cluster_labels[i])
1✔
199
                cluster_info = f"<br>Cluster: {cluster_id if cluster_id >= 0 else 'Noise'}"
1✔
200
            hover_texts.append(f"ID: {id_val}<br>Doc: {doc_preview}{cluster_info}")
1✔
201

202
        # Determine colors
203
        if cluster_labels is not None:
1✔
204
            # Color by cluster
205
            colors = cluster_labels
1✔
206
            colorscale = "Viridis"
1✔
207
        else:
208
            # Color by index (default gradient)
209
            colors = list(range(len(ids)))
1✔
210
            colorscale = "Viridis"
1✔
211

212
        # Create plot
213
        if reduced_data.shape[1] == 2:
1✔
214
            # 2D plot
215
            fig = go.Figure(
1✔
216
                data=[
217
                    go.Scatter(
218
                        x=reduced_data[:, 0],
219
                        y=reduced_data[:, 1],
220
                        mode="markers",
221
                        marker={
222
                            "size": 8,
223
                            "color": colors,
224
                            "colorscale": colorscale,
225
                            "showscale": True,
226
                        },
227
                        text=hover_texts,
228
                        hoverinfo="text",
229
                    )
230
                ]
231
            )
232

233
            fig.update_layout(
1✔
234
                title=f"Vector Visualization - {method_name}",
235
                xaxis_title=f"{method_name} Dimension 1",
236
                yaxis_title=f"{method_name} Dimension 2",
237
                hovermode="closest",
238
                height=800,
239
                width=1200,
240
                clickmode="event+select",  # Enable selection on click
241
            )
242
        else:
243
            # 3D plot
244
            fig = go.Figure(
1✔
245
                data=[
246
                    go.Scatter3d(
247
                        x=reduced_data[:, 0],
248
                        y=reduced_data[:, 1],
249
                        z=reduced_data[:, 2],
250
                        mode="markers",
251
                        marker={
252
                            "size": 5,
253
                            "color": colors,
254
                            "colorscale": colorscale,
255
                            "showscale": True,
256
                        },
257
                        text=hover_texts,
258
                        hoverinfo="text",
259
                    )
260
                ]
261
            )
262
            fig.update_layout(
1✔
263
                title=f"Vector Visualization - {method_name}",
264
                scene={
265
                    "xaxis_title": f"{method_name} Dimension 1",
266
                    "yaxis_title": f"{method_name} Dimension 2",
267
                    "zaxis_title": f"{method_name} Dimension 3",
268
                },
269
                height=800,
270
                width=1200,
271
                clickmode="event+select",  # Enable selection on click
272
            )
273

274
        # Display in embedded web view
275
        html = fig.to_html(include_plotlyjs="cdn")
1✔
276

277
        # Inject JavaScript for selection tracking via QWebChannel (2D plots only)
278
        js_injection = """
1✔
279
        <script src="qrc:///qtwebchannel/qwebchannel.js"></script>
280
        <script>
281
            var plotBridge = null;
282
            var selectedPointIndex = -1;
283

284
            // Initialize QWebChannel and wait for it to be ready
285
            new QWebChannel(qt.webChannelTransport, function(channel) {
286
                plotBridge = channel.objects.plotBridge;
287

288
                // Set up Plotly selection event handlers (2D plots only)
289
                var plotDiv = document.getElementsByClassName('plotly-graph-div')[0];
290
                if (plotDiv && plotDiv.on) {
291
                    // Use plotly_selected for 2D plots (works with clickmode="event+select")
292
                    plotDiv.on('plotly_selected', function(data) {
293
                        if (data && data.points && data.points.length > 0) {
294
                            var point = data.points[0];
295
                            if (!point || point.pointIndex === undefined || point.pointIndex === null) {
296
                                return;
297
                            }
298
                            var pointIndex = point.pointIndex;
299
                            
300
                            // Toggle: if clicking same point, deselect
301
                            if (selectedPointIndex === pointIndex) {
302
                                selectedPointIndex = -1;
303
                                if (plotBridge && plotBridge.onPointSelected) {
304
                                    plotBridge.onPointSelected(-1, '');
305
                                }
306
                                return;
307
                            }
308
                            
309
                            selectedPointIndex = pointIndex;
310
                            
311
                            // Extract ID from hover text
312
                            var pointId = String(pointIndex);
313
                            if (point.text) {
314
                                var match = point.text.match(/ID:\\s*([^<\\r\\n]+)/);
315
                                if (match && match[1]) {
316
                                    pointId = match[1].trim();
317
                                }
318
                            }
319

320
                            if (plotBridge && plotBridge.onPointSelected) {
321
                                plotBridge.onPointSelected(pointIndex, pointId);
322
                            }
323
                            // Notify interaction bridge with selected count
324
                            if (plotBridge && plotBridge.onInteraction) {
325
                                plotBridge.onInteraction('select', data.points.length);
326
                            }
327
                        }
328
                    });
329
                    
330
                    // Handle explicit deselection
331
                    plotDiv.on('plotly_deselect', function() {
332
                        selectedPointIndex = -1;
333
                        if (plotBridge && plotBridge.onPointSelected) {
334
                            plotBridge.onPointSelected(-1, '');
335
                        }
336
                        if (plotBridge && plotBridge.onInteraction) {
337
                            plotBridge.onInteraction('select', 0);
338
                        }
339
                    });
340
                    
341
                    // Handle zoom / pan via relayout event
342
                    plotDiv.on('plotly_relayout', function(layout) {
343
                        // Basic heuristic: presence of axis range keys indicates zoom
344
                        var action = 'pan';
345
                        try {
346
                            if (layout['xaxis.range'] || layout['xaxis.range[0]'] || layout['yaxis.range'] || layout['xaxis.autorange'] === false) {
347
                                action = 'zoom';
348
                            }
349
                        } catch (e) {
350
                            action = 'pan';
351
                        }
352
                        if (plotBridge && plotBridge.onInteraction) {
353
                            plotBridge.onInteraction(action, 0);
354
                        }
355
                    });
356
                }
357
            });
358
        </script>
359
        """
360

361
        # Insert JS before closing body tag
362
        html = html.replace("</body>", js_injection + "</body>")
1✔
363

364
        self._current_html = html
1✔
365
        self.web_view.setHtml(html)
1✔
366

367
    def get_current_html(self) -> Optional[str]:
1✔
368
        """Get the current plot HTML for saving/export."""
369
        return self._current_html
1✔
370

371
    def dispose(self) -> None:
1✔
372
        """Explicitly dispose of WebEngine objects to avoid profile-release warnings.
373

374
        This removes the web channel, requests deletion of the page and view,
375
        and clears references so Qt can tear them down in the correct order.
376
        """
377
        try:
1✔
378
            if hasattr(self, "web_view") and self.web_view is not None:
1✔
379
                try:
1✔
380
                    page = self.web_view.page()
1✔
381
                    # Disconnect web channel if set on the page
382
                    try:
1✔
383
                        page.setWebChannel(None)
1✔
384
                    except Exception:
×
385
                        pass
×
386
                    page.deleteLater()
1✔
387
                except Exception:
×
388
                    pass
×
389

390
                try:
1✔
391
                    self.web_view.setParent(None)
1✔
392
                except Exception:
×
393
                    pass
×
394
                try:
1✔
395
                    self.web_view.deleteLater()
1✔
396
                except Exception:
×
397
                    pass
×
398
                self.web_view = None
1✔
399

400
            if hasattr(self, "channel") and self.channel is not None:
1✔
401
                try:
1✔
402
                    self.channel.deleteLater()
1✔
403
                except Exception:
×
404
                    pass
×
405
                self.channel = None
1✔
406

407
            if hasattr(self, "_event_bridge") and self._event_bridge is not None:
1✔
408
                try:
1✔
409
                    self._event_bridge.deleteLater()
1✔
410
                except Exception:
×
411
                    pass
×
412
                self._event_bridge = None
1✔
413
        except Exception:
×
414
            # Best-effort disposal; do not raise during app shutdown
415
            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