Q2NSViz 0.1.0
Q2NS trace visualizer
Loading...
Searching...
No Matches
canvas.py
Go to the documentation of this file.
1# -----------------------------------------------------------------------------
2# Q2NSViz - Quantum Network Trace Visualizer
3# Copyright (c) 2026 QuantumInternet.it
4#
5# This program is released under the MIT License - see LICENSE for details.
6# -----------------------------------------------------------------------------
7
8import logging
9import math
10
11from PyQt6.QtCore import QPointF, QRectF, Qt
12from PyQt6.QtGui import QBrush, QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
13from PyQt6.QtWidgets import QToolTip, QWidget
14
15from ..logic import SimulationStateManager, Snapshot
16from .theme import Theme, ui_font_family
17
18logger = logging.getLogger(__name__)
19
20
21def _diamond_path(x: float, y: float, size: float) -> QPainterPath:
22 """Return the diamond marker used for classical packets and in-flight cbits."""
23 path = QPainterPath()
24 path.moveTo(x, y - size)
25 path.lineTo(x + size, y)
26 path.lineTo(x, y + size)
27 path.lineTo(x - size, y)
28 path.closeSubpath()
29 return path
30
31
32class NetworkCanvas(QWidget):
33 """Custom widget that renders the network topology and quantum state.
34
35 Paint order (back to front)
36 ---------------------------
37 1. Background fill
38 2. Channel lines
39 3. Node boxes (each with its resident qubits and classical bits)
40 4. Lost-qubit crosses
41 5. Measured-qubit markers
42 6. In-flight qubits (colored circles on the channel lane)
43 7. In-flight classical packets (``sendPacket``)
44 8. In-flight classical bits (``sendCbit``)
45 9. Entanglement links (colored lines between qubit circles)
46 10. Legend overlay (optional)
47 11. Current-time ``traceText`` overlay
48
49 A qubit inside an operation window wears a ring whose style names the
50 operation: dashed single for ``entangle``, solid single for ``measure``,
51 solid double for ``graphMeasure``.
52
53 The widget owns no state of its own beyond the ``Snapshot`` pushed by
54 the ``QuantumVisualizerWindow`` controller via ``set_snapshot()`` and
55 the qubit sets derived from it. The shared ``state_manager`` is read
56 only for the static topology and the raw event list (in-flight
57 animation geometry and ``traceText`` overlays). The widget is **not**
58 thread-safe; call only from the Qt main thread.
59 @ingroup q2nsviz_canvas
60 """
61
62 # --- Layout constants (pixels) ------------------------------------------
63 NODE_WIDTH: int = 120 # pixel width of each node box
64 NODE_HEIGHT: int = 70 # pixel height of each node box
65 QUBIT_RADIUS: int = 8 # radius of qubit circles
66 CHANNEL_WIDTH: int = 6 # stroke width for channel lines
67 LAYOUT_MARGIN_X: int = 100 # left/right canvas margin for the node layout area
68 LAYOUT_MARGIN_Y: int = 75 # top/bottom canvas margin for the node layout area
69 LEGEND_RESERVE: int = 210 # left gutter reserved for the legend panel so it never overlaps nodes
70
71 def __init__(self, state_manager: SimulationStateManager):
72 super().__init__()
73 self.state_manager = state_manager
74 self._snap: Snapshot | None = None
75 self.current_time = 0
76 self.inflight_qubits: set[str] = set()
77 self.removed_qubits: set[str] = set()
78 self.gate_qubits: set[str] = set()
79 self.measuring_qubits: set[str] = set()
80 self.graph_measuring_qubits: set[str] = set()
81 self.lost_qubits: set[str] = set()
82 self.entangled_qubits: set[str] = set()
83 self.inflight_cbits: set[str] = set()
84 self.removed_cbits: set[str] = set()
85 self._legend_visible: bool = True
86 self._show_node_labels: bool = True
87 self.setMinimumSize(600, 400)
88 self.setMouseTracking(True)
89 self._qubit_positions_cache: dict[str, QPointF] = {}
90 self._node_positions_cache: dict[str, QPointF] = {}
91
92 def _events_of(self, event_type: str) -> list[dict]:
93 """Return the loaded events of one type, from the manager's load-time index."""
94 return self.state_manager.events_by_type.get(event_type, [])
95
96 def set_snapshot(self, snap: Snapshot):
97 """Display the state carried by *snap* and repaint.
98
99 Called by the ``QuantumVisualizerWindow`` controller, which performs
100 the per-frame ``snapshot_at()`` query and pushes the resulting
101 ``Snapshot`` here. Unpacks the inflight, removed, and
102 in-progress-operation qubit sets, then triggers a full repaint.
103 Must be called from the Qt main thread.
104
105 @param snap ``Snapshot`` of the network state to render.
106 """
107 self._snap = snap
108 self.current_time = snap.t_ns
109 self.inflight_qubits = set(snap.inflight_qubits)
110 self.removed_qubits = set(snap.removed_qubits)
111 self.gate_qubits = set(snap.gate_qubits)
112 self.measuring_qubits = set(snap.measuring_qubits)
113 self.graph_measuring_qubits = set(snap.graph_measuring_qubits)
114 self.inflight_cbits = set(snap.inflight_cbits)
115 self.removed_cbits = set(snap.removed_cbits)
116 self.lost_qubits = set(snap.lost_qubits)
117 self.entangled_qubits = set().union(*snap.entangled_states.values())
118 self.update()
119
120 def reset(self):
121 """Clear all transient qubit sets and repaint an empty canvas.
122
123 Called when the user loads a new file or the application starts.
124 """
125 self._snap = None
126 self.current_time = 0
127 self.inflight_qubits.clear()
128 self.removed_qubits.clear()
129 self.gate_qubits.clear()
130 self.measuring_qubits.clear()
131 self.graph_measuring_qubits.clear()
132 self.lost_qubits.clear()
133 self.entangled_qubits.clear()
134 self.inflight_cbits.clear()
135 self.removed_cbits.clear()
136 # An empty canvas short-circuits paintEvent before the hit-test caches are
137 # rebuilt, so clear them here or hover would still report the old topology.
138 self._qubit_positions_cache.clear()
139 self._node_positions_cache.clear()
140 self.update()
141
142 def mouseMoveEvent(self, event):
143 """Hit-test nodes and in-flight qubits on hover and update the tooltip.
144
145 Tests each node bounding box first; on a miss, scans in-flight qubits.
146 Updates the Qt tooltip text so the OS displays it near the cursor.
147
148 @param event Qt mouse-move event providing the current cursor position.
149 """
150 pos = event.position()
151 snap_qubits = self._snap.qubits if self._snap else {}
152 snap_cbits = self._snap.cbits if self._snap else {}
153 for node_label, npos in self._node_positions_cache.items():
154 if abs(pos.x() - npos.x()) <= self.NODE_WIDTHNODE_WIDTH / 2 and abs(pos.y() - npos.y()) <= self.NODE_HEIGHT / 2:
155 qubit_count = sum(
156 1 for q in snap_qubits.values() if q.node == node_label and q.label not in self.removed_qubits
157 )
158 cbit_count = sum(
159 1 for c in snap_cbits.values() if c.node == node_label and c.label not in self.removed_cbits
160 )
161 tip = f"{node_label} | qubits: {qubit_count} cbits: {cbit_count}"
162 QToolTip.showText(event.globalPosition().toPoint(), tip, self)
163 return
164 hit_radius = self.QUBIT_RADIUSQUBIT_RADIUS + 4
165 for label, qpos in self._qubit_positions_cache.items():
166 dx = pos.x() - qpos.x()
167 dy = pos.y() - qpos.y()
168 if math.sqrt(dx * dx + dy * dy) <= hit_radius:
169 QToolTip.showText(event.globalPosition().toPoint(), label, self)
170 return
171 QToolTip.hideText()
172 super().mouseMoveEvent(event)
173
174 def paintEvent(self, event):
175 """Render one frame of the network visualization.
176
177 Computes node and qubit screen positions once, then executes the
178 layered draw passes listed in the class docstring. Caches qubit
179 positions in ``_qubit_positions_cache`` for use by the tooltip
180 hit-test in ``mouseMoveEvent``.
181 """
182 painter = QPainter(self)
183 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
184 painter.fillRect(self.rect(), Theme.BG_DARK)
185 if not self.state_manager.nodes:
186 self._draw_empty_state(painter)
187 return
188 node_positions = self._calculate_positions()
189 qubit_positions = self._get_qubit_positions(node_positions)
190 self._draw_channels(painter, node_positions)
191 self._draw_nodes(painter, node_positions)
192 self._draw_lost_qubits(painter, node_positions)
193 self._draw_measured_qubits(painter, node_positions)
194 self._draw_inflight_qubits(painter, qubit_positions)
195 self._draw_inflight_packets(painter, node_positions)
196 self._draw_inflight_cbits(painter, node_positions)
197 self._draw_entanglement(painter, qubit_positions)
198 if self._legend_visible:
199 self._draw_legend(painter)
200 self._draw_event_overlay(painter)
201 self._qubit_positions_cache = qubit_positions
202 self._node_positions_cache = node_positions
203
204 def _draw_empty_state(self, painter: QPainter):
205 painter.setPen(QPen(Theme.TEXT_MUTED))
206 painter.setFont(QFont(ui_font_family(), 14))
207 painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "Load a simulation file to begin")
208
209 def _calculate_positions(self) -> dict[str, QPointF]:
210 """Compute screen centre coordinates for each node.
211
212 Nodes with explicit (x, y) percentages from ``createNode`` events
213 are placed using those percentages relative to the layout area.
214 All other nodes are arranged on a circle centred on the widget.
215
216 When the legend is visible, the layout area is inset from the left by
217 ``LEGEND_RESERVE`` so that nodes never render underneath the legend panel.
218
219 @returns ``{node_label: QPointF}`` for every node in
220 ``state_manager.nodes``.
221 """
222 positions: dict[str, QPointF] = {}
223 nodes = list(self.state_manager.nodes.items())
224 if not nodes:
225 return positions
226 left_reserve = self.LEGEND_RESERVE if self._legend_visible else 0
227 width = self.width() - 2 * self.LAYOUT_MARGIN_X - left_reserve
228 height = self.height() - 2 * self.LAYOUT_MARGIN_Y
229 num_nodes = len(nodes)
230 for idx, (label, node) in enumerate(nodes):
231 if node.has_explicit_position:
232 x = self.LAYOUT_MARGIN_X + left_reserve + width * (node.x_pct / 100)
233 y = self.LAYOUT_MARGIN_Y + height * (node.y_pct / 100)
234 else:
235 angle = (2 * math.pi * idx / num_nodes) - math.pi / 2
236 x = self.width() / 2 + left_reserve / 2 + (width / 2.5) * math.cos(angle)
237 y = self.height() / 2 + (height / 2.5) * math.sin(angle)
238 positions[label] = QPointF(x, y)
239 return positions
240
241 def _draw_channels(self, painter: QPainter, positions: dict[str, QPointF]):
242 """Render quantum and classical channel lines with perpendicular lane offset.
243
244 Node pairs that share both a quantum and a classical channel receive
245 parallel lanes offset perpendicularly so neither line obscures the other.
246 Duplicate channel definitions are de-duplicated at load time, so any that
247 slip through here are silently discarded.
248
249 @param painter Active ``QPainter`` for the canvas widget.
250 @param positions Mapping of node label to widget-space centre point.
251 """
252 channel_pairs: dict[tuple, dict] = {}
253 for channel in self.state_manager.channels:
254 if channel.from_node not in positions or channel.to_node not in positions:
255 continue
256 pair = tuple(sorted([channel.from_node, channel.to_node]))
257 if pair not in channel_pairs:
258 channel_pairs[pair] = {"quantum": None, "classical": None}
259 if channel_pairs[pair].get(channel.kind) is not None:
260 continue # already de-duplicated at load time
261 channel_pairs[pair][channel.kind] = channel
262
263 for (node1, node2), channels in channel_pairs.items():
264 start = positions[node1]
265 end = positions[node2]
266 dx = end.x() - start.x()
267 dy = end.y() - start.y()
268 length = math.sqrt(dx * dx + dy * dy) or 1
269 perp_x = -dy / length
270 perp_y = dx / length
271
272 if channels["quantum"]:
273 q_start = QPointF(
274 start.x() + perp_x * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2, start.y() + perp_y * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2
275 )
276 q_end = QPointF(
277 end.x() + perp_x * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2, end.y() + perp_y * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2
278 )
279 pen = QPen(Theme.QUANTUM_CHANNEL, self.CHANNEL_WIDTHCHANNEL_WIDTH)
280 pen.setCapStyle(Qt.PenCapStyle.FlatCap)
281 painter.setPen(pen)
282 painter.drawLine(q_start, q_end)
283
284 if channels["classical"]:
285 c_start = QPointF(
286 start.x() - perp_x * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2, start.y() - perp_y * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2
287 )
288 c_end = QPointF(
289 end.x() - perp_x * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2, end.y() - perp_y * self.CHANNEL_WIDTHCHANNEL_WIDTH / 2
290 )
291 pen = QPen(Theme.CLASSICAL_CHANNEL, self.CHANNEL_WIDTHCHANNEL_WIDTH)
292 pen.setCapStyle(Qt.PenCapStyle.FlatCap)
293 painter.setPen(pen)
294 painter.drawLine(c_start, c_end)
295
296 def _draw_entanglement(self, painter: QPainter, qubit_positions: dict[str, QPointF]):
297 """Draw entanglement edges between qubit circles.
298
299 Each edge of the snapshot's ``ent_graph`` that connects two qubits
300 with known screen positions is rendered as a colored line. Qubits
301 in ``removed_qubits`` are excluded to avoid artifacts from stale
302 edges that have not yet been cleaned up by a ``removeQubit`` event.
303
304 @param painter Active ``QPainter`` in the correct state.
305 @param qubit_positions Screen positions returned by
306 ``_get_qubit_positions()``.
307 """
308 pen = QPen(Theme.ENTANGLEMENT, 2.5)
309 pen.setStyle(Qt.PenStyle.SolidLine)
310 pen.setCapStyle(Qt.PenCapStyle.RoundCap)
311 painter.setPen(pen)
312 painter.setOpacity(0.85)
313
314 drawn = set()
315 ent_graph = self._snap.ent_graph if self._snap else {}
316 for qubit1, neighbors in ent_graph.items():
317 if qubit1 in self.removed_qubits or qubit1 not in qubit_positions:
318 continue
319
320 for qubit2 in neighbors:
321 if qubit2 in self.removed_qubits or qubit2 not in qubit_positions:
322 continue
323
324 pair = tuple(sorted([qubit1, qubit2]))
325 if pair in drawn:
326 continue
327 drawn.add(pair)
328 painter.drawLine(qubit_positions[qubit1], qubit_positions[qubit2])
329 painter.setOpacity(1.0)
330
331 def _draw_lost_qubits(self, painter: QPainter, positions: dict[str, QPointF]):
332 if not self.lost_qubits:
333 return
334 loss_by_node: dict[str, list[str]] = {}
335 snap_qubits = self._snap.qubits if self._snap else {}
336 for label in self.lost_qubits:
337 qubit = snap_qubits.get(label)
338 if qubit and qubit.node and qubit.node in positions:
339 loss_by_node.setdefault(qubit.node, []).append(label)
340
341 fill = QColor(Theme.QUBIT_LOST)
342 fill.setAlpha(200)
343 cross_pen = QPen(QColor(Theme.QUBIT_LOST).darker(140), 1.5)
344 r = self.QUBIT_RADIUSQUBIT_RADIUS - 2
345
346 for node_label, lost_labels in loss_by_node.items():
347 center = positions[node_label]
348 num = len(lost_labels)
349 spacing = min(18, (self.NODE_WIDTHNODE_WIDTH - 30) / max(num, 1))
350 start_x = center.x() - (num - 1) * spacing / 2
351 y = center.y() + self.NODE_HEIGHT / 2 + r + 6
352 for i in range(num):
353 pos = QPointF(start_x + i * spacing, y)
354 painter.setPen(Qt.PenStyle.NoPen)
355 painter.setBrush(QBrush(fill))
356 painter.drawEllipse(pos, r, r)
357 d = r * 0.55
358 painter.setPen(cross_pen)
359 painter.drawLine(QPointF(pos.x() - d, pos.y() - d), QPointF(pos.x() + d, pos.y() + d))
360 painter.drawLine(QPointF(pos.x() + d, pos.y() - d), QPointF(pos.x() - d, pos.y() + d))
361
362 def _draw_measured_qubits(self, painter: QPainter, positions: dict[str, QPointF]):
363 """Render all measured qubits as gray circles below their node.
364
365 Drawn whether or not a ``removeQubit`` event also fired, so a measurement
366 always leaves a marker distinct from the lost-qubit cross.
367
368 @param painter Active ``QPainter``.
369 @param positions Node screen positions from ``_calculate_positions()``.
370 """
371 measured = set(self._snap.measured_qubits) if self._snap else set()
372 if not measured:
373 return
374 by_node: dict[str, list[str]] = {}
375 for label in measured:
376 qubit = self._snap.qubits.get(label)
377 if qubit and qubit.node and qubit.node in positions:
378 by_node.setdefault(qubit.node, []).append(label)
379
380 fill = QColor(Theme.QUBIT_MEASURED)
381 fill.setAlpha(200)
382 tick_pen = QPen(QColor(Theme.QUBIT_MEASURED).darker(120), 1.5)
383 r = self.QUBIT_RADIUSQUBIT_RADIUS - 2
384
385 for node_label, labels in by_node.items():
386 center = positions[node_label]
387 num = len(labels)
388 spacing = min(18, (self.NODE_WIDTHNODE_WIDTH - 30) / max(num, 1))
389 # Measured qubits appear one row below lost qubits
390 start_x = center.x() - (num - 1) * spacing / 2
391 y = center.y() + self.NODE_HEIGHT / 2 + r + 6 + (r * 2 + 4)
392 for i in range(num):
393 pos = QPointF(start_x + i * spacing, y)
394 painter.setPen(Qt.PenStyle.NoPen)
395 painter.setBrush(QBrush(fill))
396 painter.drawEllipse(pos, r, r)
397 painter.setPen(tick_pen)
398 painter.drawLine(
399 QPointF(pos.x() - r * 0.45, pos.y()), QPointF(pos.x() - r * 0.1, pos.y() + r * 0.45)
400 )
401 painter.drawLine(
402 QPointF(pos.x() - r * 0.1, pos.y() + r * 0.45), QPointF(pos.x() + r * 0.5, pos.y() - r * 0.4)
403 )
404
405 @staticmethod
406 def _draw_soft_shadow(painter: QPainter, rect: QRectF, radius: float):
407 """Draw a soft drop shadow beneath a rounded-rect card."""
408 painter.save()
409 painter.setPen(Qt.PenStyle.NoPen)
410 layers = 8
411 for i in range(layers):
412 frac = (i + 1) / layers
413 spread = 7.0 * (1.0 - frac)
414 painter.setBrush(QColor(15, 23, 42, int(9 * frac)))
415 shadow = QRectF(
416 rect.left() - spread,
417 rect.top() - spread + 2.5,
418 rect.width() + 2 * spread,
419 rect.height() + 2 * spread,
420 )
421 painter.drawRoundedRect(shadow, radius + spread, radius + spread)
422 painter.restore()
423
424 def _draw_nodes(self, painter: QPainter, positions: dict[str, QPointF]):
425 """Render each network node as a rounded card.
426
427 Draws qubits and classical bits stacked beneath the node box via
428 ``_draw_node_qubits`` and ``_draw_node_cbits``. Node labels are
429 elided to fit the node width when ``_show_node_labels`` is set.
430
431 @param painter Active ``QPainter`` for the canvas widget.
432 @param positions Mapping of node label to widget-space centre point.
433 """
434 radius = 12
435 for label, pos in positions.items():
436 rect = QRectF(
437 pos.x() - self.NODE_WIDTHNODE_WIDTH / 2, pos.y() - self.NODE_HEIGHT / 2, self.NODE_WIDTHNODE_WIDTH, self.NODE_HEIGHT
438 )
439 self._draw_soft_shadow(painter, rect, radius)
440 painter.setPen(QPen(Theme.BORDER, 1.0))
441 painter.setBrush(QBrush(Theme.BG_MEDIUM))
442 painter.drawRoundedRect(rect, radius, radius)
443 if self._show_node_labels:
444 painter.setPen(QPen(QColor(Theme.NODE_TEXT)))
445 painter.setFont(QFont(ui_font_family(), 11, QFont.Weight.Medium))
446 fm = QFontMetrics(painter.font())
447 elided = fm.elidedText(label, Qt.TextElideMode.ElideRight, int(self.NODE_WIDTHNODE_WIDTH - 12))
448 painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, elided)
449 self._draw_node_qubits(painter, label, pos)
450 self._draw_node_cbits(painter, label, pos)
451
452 def _get_live_qubit_label_set(self) -> set[str]:
453 """Return the set of qubit labels that are alive (neither measured nor removed).
454
455 @returns Live qubit labels from the current snapshot.
456 """
457 return set(self._snap.live_qubit_labels) if self._snap else set()
458
459 def _get_live_cbit_label_set(self) -> set[str]:
460 """Return the set of classical bit labels that are not yet removed."""
461 if self._snap is None:
462 return set()
463 return {label for label in self._snap.cbits if label not in self.removed_cbits}
464
465 def _get_node_qubit_positions(
466 self, node_label: str, center: QPointF, live_labels: set[str]
467 ) -> dict[str, QPointF]:
468 snap_qubits = self._snap.qubits if self._snap else {}
469 qubits = [
470 label
471 for label, qubit in snap_qubits.items()
472 if qubit.node == node_label and label in live_labels and label not in self.inflight_qubits
473 ]
474 if not qubits:
475 return {}
476 num_qubits = len(qubits)
477 spacing = min(18, (self.NODE_WIDTHNODE_WIDTH - 30) / max(num_qubits, 1))
478 start_x = center.x() - (num_qubits - 1) * spacing / 2
479 y = center.y() + self.NODE_HEIGHT / 2 - 16
480 return {qubit_label: QPointF(start_x + index * spacing, y) for index, qubit_label in enumerate(qubits)}
481
482 def _get_node_cbit_positions(
483 self, node_label: str, center: QPointF, live_labels: set[str]
484 ) -> dict[str, QPointF]:
485 """Return screen positions for classical bits stored at *node_label*."""
486 snap_cbits = self._snap.cbits if self._snap else {}
487 cbits = [
488 label
489 for label, cbit in snap_cbits.items()
490 if cbit.node == node_label and label in live_labels and label not in self.inflight_cbits
491 ]
492 if not cbits:
493 return {}
494 num_cbits = len(cbits)
495 spacing = min(16, (self.NODE_WIDTHNODE_WIDTH - 30) / max(num_cbits, 1))
496 start_x = center.x() - (num_cbits - 1) * spacing / 2
497 # Classical bits appear one row below the qubit row
498 y = center.y() + self.NODE_HEIGHT / 2 - 4
499 return {cbit_label: QPointF(start_x + index * spacing, y) for index, cbit_label in enumerate(cbits)}
500
501 def _draw_node_cbits(self, painter: QPainter, node_label: str, center: QPointF):
502 """Render classical bits stored at *node_label* as small squares."""
503 cbit_positions = self._get_node_cbit_positions(node_label, center, self._get_live_cbit_label_set())
504 if not cbit_positions:
505 return
506 half = 5 # half-side of the classical-bit square
507 for _cbit_label, pos in cbit_positions.items():
508 color = QColor(Theme.CBIT)
509 painter.setPen(Qt.PenStyle.NoPen)
510 painter.setBrush(QColor(15, 23, 42, 40))
511 painter.drawRoundedRect(QRectF(pos.x() - half, pos.y() - half + 1.0, half * 2, half * 2), 2, 2)
512 painter.setPen(QPen(color.darker(118), 1.0))
513 painter.setBrush(QBrush(color))
514 painter.drawRoundedRect(QRectF(pos.x() - half, pos.y() - half, half * 2, half * 2), 2, 2)
515
516 def _draw_node_qubits(self, painter: QPainter, node_label: str, center: QPointF):
517 """Render qubits stored at *node_label* as small circles."""
518 qubit_positions = self._get_node_qubit_positions(node_label, center, self._get_live_qubit_label_set())
519 if not qubit_positions:
520 return
521 for qubit_label, pos in qubit_positions.items():
522 color = Theme.QUBIT_ENTANGLED if qubit_label in self.entangled_qubits else Theme.QUBIT_PRODUCT
523 painter.setPen(Qt.PenStyle.NoPen)
524 painter.setBrush(QColor(15, 23, 42, 45))
525 painter.drawEllipse(QPointF(pos.x(), pos.y() + 1.0), self.QUBIT_RADIUSQUBIT_RADIUS, self.QUBIT_RADIUSQUBIT_RADIUS)
526 painter.setPen(QPen(QColor(color).darker(115), 1.0))
527 painter.setBrush(QBrush(color))
528 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS, self.QUBIT_RADIUSQUBIT_RADIUS)
529 if qubit_label in self.gate_qubits:
530 gate_pen = QPen(QColor(Theme.HALO_GATE), 1.5, Qt.PenStyle.DashLine)
531 painter.setPen(gate_pen)
532 painter.setBrush(Qt.BrushStyle.NoBrush)
533 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS + 4, self.QUBIT_RADIUSQUBIT_RADIUS + 4)
534 elif qubit_label in self.measuring_qubits:
535 painter.setPen(QPen(QColor(Theme.HALO_MEASURE), 1.5))
536 painter.setBrush(Qt.BrushStyle.NoBrush)
537 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS + 4, self.QUBIT_RADIUSQUBIT_RADIUS + 4)
538 elif qubit_label in self.graph_measuring_qubits:
539 gm_color = QColor(Theme.HALO_GRAPH_MEASURE)
540 painter.setPen(QPen(gm_color, 1.5))
541 painter.setBrush(Qt.BrushStyle.NoBrush)
542 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS + 4, self.QUBIT_RADIUSQUBIT_RADIUS + 4)
543 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS + 8, self.QUBIT_RADIUSQUBIT_RADIUS + 8)
544
545 def _draw_inflight_cbits(self, painter: QPainter, positions: dict[str, QPointF]):
546 """Animate in-flight cbits as classical packet diamonds."""
547 for event in self._events_of("sendCbit"):
548 t0 = event.get("t0_ns", 0)
549 t1 = event.get("t1_ns", 0)
550 if not (t0 <= self.current_time < t1):
551 continue
552 from_node = event.get("from")
553 to_node = event.get("to")
554 if from_node not in positions or to_node not in positions:
555 continue
556 progress = (self.current_time - t0) / (t1 - t0) if t1 > t0 else 0
557 start = positions[from_node]
558 end = positions[to_node]
559 dx = end.x() - start.x()
560 dy = end.y() - start.y()
561 length = math.sqrt(dx * dx + dy * dy) or 1
562 perp_x = -dy / length
563 perp_y = dx / length
564 # Travel on the classical channel lane (same side as sendPacket)
565 if sorted([from_node, to_node])[0] == from_node:
566 lane_x, lane_y = -perp_x * 3, -perp_y * 3
567 else:
568 lane_x, lane_y = perp_x * 3, perp_y * 3
569 x = start.x() + dx * progress + lane_x
570 y = start.y() + dy * progress + lane_y
571 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 1.5))
572 painter.setBrush(QBrush(QColor(Theme.PACKET_CLASSICAL)))
573 painter.drawPath(_diamond_path(x, y, 7))
574
575 def _get_qubit_positions(self, positions: dict[str, QPointF]) -> dict[str, QPointF]:
576 """Return screen positions for all qubits, including in-flight ones."""
577 qubit_positions = {}
578 live_labels = self._get_live_qubit_label_set()
579 for node_label, center in positions.items():
580 qubit_positions.update(self._get_node_qubit_positions(node_label, center, live_labels))
581
582 for event in self._events_of("sendQubit"):
583 t0 = event.get("t0_ns", 0)
584 t1 = event.get("t1_ns", 0)
585 if not (t0 <= self.current_time < t1):
586 continue
587 qubit_label = event.get("bit")
588 from_node = event.get("from")
589 to_node = event.get("to")
590 if from_node not in positions or to_node not in positions:
591 continue
592 progress = (self.current_time - t0) / (t1 - t0) if t1 > t0 else 0
593 start = positions[from_node]
594 end = positions[to_node]
595 dx = end.x() - start.x()
596 dy = end.y() - start.y()
597 length = math.sqrt(dx * dx + dy * dy) or 1
598 perp_x = -dy / length
599 perp_y = dx / length
600 # Alphabetical node order determines which lane (side of the channel
601 # line) the qubit occupies, ensuring stable positioning each frame.
602 if sorted([from_node, to_node])[0] == from_node:
603 lane_x, lane_y = perp_x * 3, perp_y * 3
604 else:
605 lane_x, lane_y = -perp_x * 3, -perp_y * 3
606 qubit_positions[qubit_label] = QPointF(
607 start.x() + dx * progress + lane_x, start.y() + dy * progress + lane_y
608 )
609 return qubit_positions
610
611 def _draw_inflight_qubits(self, painter: QPainter, qubit_positions: dict[str, QPointF]):
612 """Render in-flight qubits as filled circles on the channel lane.
613
614 Only qubits currently in ``inflight_qubits`` are drawn. Their
615 color is determined by the qubit's semantic state (product vs.
616 entangled) as defined by the active color palette.
617
618 @param painter Active ``QPainter``.
619 @param qubit_positions Screen positions returned by
620 ``_get_qubit_positions()``, which places every
621 in-flight qubit on its channel lane.
622 """
623 for qubit_label in self.inflight_qubits:
624 pos = qubit_positions.get(qubit_label)
625 if pos is None:
626 continue
627 color = Theme.QUBIT_ENTANGLED if qubit_label in self.entangled_qubits else Theme.QUBIT_PRODUCT
628 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 2))
629 painter.setBrush(QBrush(color))
630 painter.drawEllipse(pos, self.QUBIT_RADIUSQUBIT_RADIUS, self.QUBIT_RADIUSQUBIT_RADIUS)
631
632 def _draw_inflight_packets(self, painter: QPainter, positions: dict[str, QPointF]):
633 """Render classical network packets in flight between nodes.
634
635 Iterates ``sendPacket`` events and draws a diamond marker at the
636 proportional position along the source-to-destination vector for any
637 packet whose transit window ``[t0_ns, t1_ns)`` contains the current
638 simulation time.
639
640 @param painter Active ``QPainter`` for the canvas widget.
641 @param positions Mapping of node label to widget-space centre point.
642 """
643 for event in self._events_of("sendPacket"):
644 t0 = event.get("t0_ns", 0)
645 t1 = event.get("t1_ns", 0)
646 if not (t0 <= self.current_time < t1):
647 continue
648 from_node = event.get("from")
649 to_node = event.get("to")
650 label = event.get("label", "")
651 if from_node not in positions or to_node not in positions:
652 continue
653 progress = (self.current_time - t0) / (t1 - t0) if t1 > t0 else 0
654 start = positions[from_node]
655 end = positions[to_node]
656 dx = end.x() - start.x()
657 dy = end.y() - start.y()
658 length = math.sqrt(dx * dx + dy * dy) or 1
659 perp_x = -dy / length
660 perp_y = dx / length
661 # Packets travel on the opposite lane from qubits (classical
662 # channel side). Alphabetical node order determines which side.
663 if sorted([from_node, to_node])[0] == from_node:
664 lane_x, lane_y = -perp_x * 3, -perp_y * 3
665 else:
666 lane_x, lane_y = perp_x * 3, perp_y * 3
667 x = start.x() + dx * progress + lane_x
668 y = start.y() + dy * progress + lane_y
669 color = {"tcp": Theme.PACKET_TCP, "udp": Theme.PACKET_UDP}.get(
670 event.get("protocol", "").lower(), Theme.PACKET_CLASSICAL
671 )
672 size = 7
673 painter.setPen(QPen(QColor(Theme.BG_MEDIUM), 1.5))
674 painter.setBrush(QBrush(color))
675 painter.drawPath(_diamond_path(x, y, size))
676 if label:
677 short_label = label[:18] + ("…" if len(label) > 18 else "")
678 painter.setPen(QPen(Theme.TEXT_SECONDARY))
679 painter.setFont(QFont(ui_font_family(), 8))
680 painter.drawText(QPointF(x + size + 4, y + 4), short_label)
681
682 def toggle_legend(self) -> bool:
683 """Toggle legend visibility and repaint. Returns the new visible state."""
684 self._legend_visible = not self._legend_visible
685 self.update()
686 return self._legend_visible
687
688 def toggle_node_labels(self) -> bool:
689 """Toggle node label visibility and repaint. Returns the new visible state."""
691 self.update()
692 return self._show_node_labels
693
694 def _draw_legend(self, painter: QPainter):
695 """Render a grouped color-legend card in the bottom-left corner.
696
697 Entries are organised into categories separated by hairline spacing.
698 Each row pairs a graphical icon with a text label; the icon style
699 matches what is rendered on the canvas so the legend stays consistent.
700 """
701 ROW_H = 22
702 GROUP_GAP = 9
703 ICON_R = 6.5
704 HALO_R = 10.0
705 ICON_CX = 17
706 TEXT_X = ICON_CX + HALO_R + 9
707 FONT_SIZE = 10
708
709 groups = [
710 [
711 ("channel", Theme.QUANTUM_CHANNEL, self.CHANNEL_WIDTHCHANNEL_WIDTH, "Quantum channel"),
712 ("channel", Theme.CLASSICAL_CHANNEL, self.CHANNEL_WIDTHCHANNEL_WIDTH, "Classical channel"),
713 ("link", Theme.ENTANGLEMENT, 2.5, "Entanglement link"),
714 ],
715 [
716 ("qubit", Theme.QUBIT_PRODUCT, 0, "Qubit (product state)"),
717 ("qubit", Theme.QUBIT_ENTANGLED, 0, "Qubit (entangled)"),
718 ("cbit", Theme.CBIT, 0, "Classical bit"),
719 ("lost", Theme.QUBIT_LOST, 0, "Lost qubit"),
720 ("measured", Theme.QUBIT_MEASURED, 0, "Measured qubit"),
721 ],
722 [
723 ("halo_gate", Theme.HALO_GATE, 0, "Gate (processing)"),
724 ("halo_measure", Theme.HALO_MEASURE, 0, "Measuring"),
725 ("halo_graph", Theme.HALO_GRAPH_MEASURE, 0, "Graph-measuring"),
726 ],
727 [
728 ("packet", Theme.PACKET_CLASSICAL, 0, "Classical packet"),
729 ("packet", Theme.PACKET_TCP, 0, "TCP packet"),
730 ("packet", Theme.PACKET_UDP, 0, "UDP packet"),
731 ],
732 ]
733 entries = [entry for group in groups for entry in group]
734
735 HEADER_H = 30
736 pad = 10
737 _fm = QFontMetrics(QFont(ui_font_family(), FONT_SIZE))
738 hdr_font = QFont(ui_font_family(), FONT_SIZE - 1)
739 hdr_font.setWeight(QFont.Weight.DemiBold)
740 hdr_font.setCapitalization(QFont.Capitalization.AllUppercase)
741 hdr_font.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.2)
742 _fm_hdr = QFontMetrics(hdr_font)
743 panel_w = max(
744 TEXT_X + max(_fm.horizontalAdvance(text) for *_, text in entries) + 16,
745 _fm_hdr.horizontalAdvance("LEGEND") + pad * 2 + 4,
746 )
747 panel_h = HEADER_H + len(entries) * ROW_H + (len(groups) - 1) * GROUP_GAP + pad
748 if panel_h + 18 > self.height() or panel_w + 18 > self.width():
749 return
750 panel_x = 10
751 panel_y = self.height() - panel_h - 10
752 card = QRectF(panel_x, panel_y, panel_w, panel_h)
753 self._draw_soft_shadow(painter, card, 12)
754 bg = QColor(Theme.BG_MEDIUM)
755 bg.setAlpha(245)
756 painter.setPen(QPen(QColor(Theme.BORDER), 1))
757 painter.setBrush(QBrush(bg))
758 painter.drawRoundedRect(card, 12, 12)
759
760 painter.setFont(hdr_font)
761 painter.setPen(QPen(QColor(Theme.TEXT_MUTED)))
762 hdr_y = panel_y + (HEADER_H - _fm_hdr.height()) / 2 + _fm_hdr.ascent()
763 painter.drawText(QPointF(panel_x + pad + 2, hdr_y), "Legend")
764
765 painter.setFont(QFont(ui_font_family(), FONT_SIZE))
766 cy = panel_y + HEADER_H + ROW_H * 0.5
767 for gi, group in enumerate(groups):
768 if gi > 0:
769 cy += GROUP_GAP
770 for kind, color, line_w, text in group:
771 c = QColor(color)
772 cx_ = panel_x + ICON_CX
773 if kind in ("channel", "link"):
774 pen = QPen(c, min(line_w, 3.0))
775 pen.setCapStyle(Qt.PenCapStyle.RoundCap)
776 painter.setPen(pen)
777 painter.setBrush(Qt.BrushStyle.NoBrush)
778 painter.drawLine(QPointF(panel_x + 6, cy), QPointF(panel_x + TEXT_X - 9, cy))
779 elif kind == "qubit":
780 painter.setPen(QPen(QColor(c).darker(115), 1.0))
781 painter.setBrush(QBrush(c))
782 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
783 elif kind == "cbit":
784 half = ICON_R * 0.85
785 painter.setPen(QPen(QColor(c).darker(118), 1.0))
786 painter.setBrush(QBrush(c))
787 painter.drawRoundedRect(QRectF(cx_ - half, cy - half, half * 2, half * 2), 2, 2)
788 elif kind == "lost":
789 fill = QColor(c)
790 fill.setAlpha(200)
791 painter.setPen(Qt.PenStyle.NoPen)
792 painter.setBrush(QBrush(fill))
793 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
794 d = ICON_R * 0.55
795 painter.setPen(QPen(QColor(c).darker(140), 2.0))
796 painter.drawLine(QPointF(cx_ - d, cy - d), QPointF(cx_ + d, cy + d))
797 painter.drawLine(QPointF(cx_ + d, cy - d), QPointF(cx_ - d, cy + d))
798 elif kind == "measured":
799 fill = QColor(c)
800 fill.setAlpha(200)
801 painter.setPen(Qt.PenStyle.NoPen)
802 painter.setBrush(QBrush(fill))
803 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
804 r = ICON_R
805 painter.setPen(QPen(QColor(c).darker(120), 2.0))
806 painter.drawLine(QPointF(cx_ - r * 0.45, cy), QPointF(cx_ - r * 0.1, cy + r * 0.45))
807 painter.drawLine(QPointF(cx_ - r * 0.1, cy + r * 0.45), QPointF(cx_ + r * 0.5, cy - r * 0.4))
808 elif kind == "halo_gate":
809 painter.setPen(Qt.PenStyle.NoPen)
810 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
811 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
812 painter.setPen(QPen(QColor(c), 1.5, Qt.PenStyle.DashLine))
813 painter.setBrush(Qt.BrushStyle.NoBrush)
814 painter.drawEllipse(QPointF(cx_, cy), HALO_R, HALO_R)
815 elif kind == "halo_measure":
816 painter.setPen(Qt.PenStyle.NoPen)
817 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
818 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
819 painter.setPen(QPen(QColor(c), 1.5))
820 painter.setBrush(Qt.BrushStyle.NoBrush)
821 painter.drawEllipse(QPointF(cx_, cy), HALO_R, HALO_R)
822 elif kind == "halo_graph":
823 painter.setPen(Qt.PenStyle.NoPen)
824 painter.setBrush(QBrush(QColor(Theme.QUBIT_PRODUCT)))
825 painter.drawEllipse(QPointF(cx_, cy), ICON_R, ICON_R)
826 painter.setPen(QPen(QColor(c), 1.5))
827 painter.setBrush(Qt.BrushStyle.NoBrush)
828 painter.drawEllipse(QPointF(cx_, cy), HALO_R - 2, HALO_R - 2)
829 painter.drawEllipse(QPointF(cx_, cy), HALO_R + 1, HALO_R + 1)
830 elif kind == "packet":
831 s = ICON_R
832 diamond = QPainterPath()
833 diamond.moveTo(cx_, cy - s)
834 diamond.lineTo(cx_ + s, cy)
835 diamond.lineTo(cx_, cy + s)
836 diamond.lineTo(cx_ - s, cy)
837 diamond.closeSubpath()
838 painter.setPen(QPen(QColor(c).darker(118), 1.0))
839 painter.setBrush(QBrush(c))
840 painter.drawPath(diamond)
841 painter.setPen(QPen(Theme.TEXT_SECONDARY))
842 painter.drawText(
843 QRectF(panel_x + TEXT_X, cy - ROW_H / 2, panel_w - TEXT_X - 6, ROW_H),
844 Qt.AlignmentFlag.AlignVCenter,
845 text,
846 )
847 cy += ROW_H
848
849 def _draw_event_overlay(self, painter: QPainter):
850 """Draw traceText events for the current timestamp in the bottom-right corner.
851
852 Renders a semi-transparent panel listing every ``traceText`` event
853 whose timestamp exactly matches the current slider position. At most
854 five events are shown; a "… +N more" line is appended when there are
855 additional events.
856 """
857 log = self.state_manager.log_events
858 hi = self.state_manager.log_count_at(self.current_time)
859 lo = self.state_manager.log_count_at(self.current_time - 1)
860 current_events = log[lo:hi]
861 if not current_events:
862 return
863
864 MAX_BODY = 5
865 font_hdr = QFont(ui_font_family(), 11)
866 font_hdr.setWeight(QFont.Weight.Bold)
867 font_body = QFont("Courier New", 11)
868 fm_h = QFontMetrics(font_hdr)
869 fm_b = QFontMetrics(font_body)
870 line_h = fm_b.height() + 3
871
872 t_us = self.current_time / 1000
873 header = f"@ {t_us:.3f} \u03bcs"
874
875 body_lines: list[str] = []
876 for ev in current_events[:MAX_BODY]:
877 node = ev.get("node", "")
878 text = ev.get("text", "")
879 body_lines.append(f"[{node}] {text}" if node else text)
880 extra = len(current_events) - MAX_BODY
881 if extra > 0:
882 body_lines.append(f"\u2026 +{extra} more")
883
884 PAD_X, PAD_Y = 12, 8
885 all_widths = [fm_h.horizontalAdvance(header)] + [fm_b.horizontalAdvance(ln) for ln in body_lines]
886 panel_w = max(all_widths) + PAD_X * 2
887 panel_h = PAD_Y + fm_h.height() + len(body_lines) * line_h + PAD_Y
888
889 margin = 12
890 px = self.width() - panel_w - margin
891 py = self.height() - panel_h - margin
892
893 painter.save()
894 bg = QColor(Theme.BG_MEDIUM)
895 bg.setAlpha(220)
896 painter.setBrush(QBrush(bg))
897 painter.setPen(QPen(QColor(Theme.BORDER), 1))
898 painter.drawRoundedRect(px, py, panel_w, panel_h, 6, 6)
899
900 painter.setFont(font_hdr)
901 painter.setPen(QPen(QColor(Theme.PRIMARY)))
902 painter.drawText(px + PAD_X, py + PAD_Y + fm_h.ascent(), header)
903
904 painter.setFont(font_body)
905 painter.setPen(QPen(QColor(Theme.TEXT_PRIMARY)))
906 y0 = py + PAD_Y + fm_h.height() + 2
907 for i, line in enumerate(body_lines):
908 painter.drawText(px + PAD_X, y0 + i * line_h + fm_b.ascent(), line)
909
910 painter.restore()
911
912 def apply_theme(self):
913 """Repaint the canvas with the currently active color palette.
914
915 Call after ``toggle_dark()`` to reflect palette changes.
916 """
917 self.update()
918
919
920__all__ = ["NetworkCanvas"]
Custom widget that renders the network topology and quantum state.
Definition canvas.py:32
set_snapshot(self, Snapshot snap)
Display the state carried by snap and repaint.
Definition canvas.py:96
mouseMoveEvent(self, event)
Hit-test nodes and in-flight qubits on hover and update the tooltip.
Definition canvas.py:142
reset(self)
Clear all transient qubit sets and repaint an empty canvas.
Definition canvas.py:120
bool toggle_node_labels(self)
Toggle node label visibility and repaint.
Definition canvas.py:688
bool toggle_legend(self)
Toggle legend visibility and repaint.
Definition canvas.py:682
paintEvent(self, event)
Render one frame of the network visualization.
Definition canvas.py:174
apply_theme(self)
Repaint the canvas with the currently active color palette.
Definition canvas.py:912