# Copyright 2025 Qilimanjaro Quantum Tech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING, Final, Iterable
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Arc, Circle, FancyArrow, FancyBboxPatch
from qilisdk.digital.gates import Controlled, Gate, M, X
from qilisdk.utils.visualization.style import CircuitStyle
if TYPE_CHECKING:
from matplotlib.axes import Axes
from qilisdk.digital import Circuit
###############################################################################
# Matplotlib implementation
###############################################################################
[docs]
class MatplotlibCircuitRenderer:
"""Render a :class:`~qilisdk.digital.Circuit` using *matplotlib*."""
# Z-order groups -------------------------------------------------------
_Z: Final = {
"wire": 1,
"wire_label": 1,
"gate": 3,
"node": 3,
"bridge": 2,
"connector": 4,
"gate_label": 4,
}
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
def __init__(self, circuit: Circuit, ax: Axes | None = None, *, style: CircuitStyle = CircuitStyle()) -> None:
self.circuit = circuit
self.style = style
self._ax = ax or self._make_axes(style.dpi)
self._end_measure_qubits: set[int] = set()
self._wires = circuit.nqubits
# *layer_widths[w][l]* - width (inches) of layer *l* on wire *w*
self._layer_widths: dict[int, list[float]] = {w: [] for w in range(circuit.nqubits)}
@property
[docs]
def axes(self) -> Axes:
return self._ax
[docs]
def plot(self) -> None:
"""
Render the circuit on the current axes and show the figure.
Traverses the circuit gates once, placing and drawing each element,
deferring final-column measurements as needed, draws wires and finalizes
the figure.
"""
self._generate_layer_gate_mapping()
self._draw_wire_labels()
# ------------------------------------------------------------------
# 1. compute last-gate index for each qubit ------------------------
# ------------------------------------------------------------------
# done elswere
# ------------------------------------------------------------------
# 2. iterate through gates, drawing or deferring measurements -----
# ------------------------------------------------------------------
deferred_qubits: set[int] = set()
self._max_layer_width: list[float] = [self.style.start_pad]
for layer in self._layer_gate_mapping:
for _, gate in self._layer_gate_mapping[layer].items():
if isinstance(gate, M):
inline_qubits: list[int] = []
for q in gate.target_qubits:
if self.last_idx.get(q) == layer:
deferred_qubits.add(q)
self._end_measure_qubits.add(q)
else:
inline_qubits.append(q)
if inline_qubits:
self._draw_inline_measure(inline_qubits, layer=layer)
continue
if isinstance(gate, Controlled):
self._draw_controlled_gate(gate, layer=layer)
continue
if gate.name == "SWAP":
self._draw_swap_gate(list(gate.target_qubits or []), layer=layer)
continue
# Targets-only (single or multi-qubit) box
self._draw_targets_gate(
label=self._gate_label(gate), targets=list(gate.target_qubits or []), layer=layer
)
maxi = max(
[
0.0,
*(
self._layer_widths[wire][layer]
for wire in range(self._wires)
if len(self._layer_widths[wire]) - 1 >= layer
),
]
)
self._max_layer_width.append(maxi)
# ------------------------------------------------------------------
# 3. draw any deferred (final-column) measurements -----------------
# ------------------------------------------------------------------
if deferred_qubits:
self._draw_concurrent_measures(sorted(deferred_qubits), layer=layer)
# ------------------------------------------------------------------
# final touches -----------------------------------------------------
# ------------------------------------------------------------------
self._draw_wires()
self._finalise_figure()
# plt.tight_layout()
plt.show()
[docs]
def save(self, filename: str) -> None: # thin wrapper
"""Save current figure to disk.
Args:
filename: Path to save the figure (e.g., 'circuit.png').
"""
self.axes.figure.savefig(filename, bbox_inches="tight") # type: ignore[union-attr]
# ------------------------------------------------------------------
# Low-level drawing helpers (private)
# ------------------------------------------------------------------
def _generate_layer_gate_mapping(self) -> None:
self._layer_gate_mapping: dict[int, dict[int, Gate]] = {}
gate_maping: dict[int, list[Gate]] = {}
for qubit in range(self.circuit.nqubits):
gate_maping[qubit] = []
for gate in self.circuit.gates:
qubits = gate.qubits
if len(qubits) == 1:
gate_maping[qubits[0]].append(gate)
elif len(qubits) > 1:
if self.style.layout == "compact":
con_qubits = qubits
elif self.style.layout == "normal":
con_qubits = tuple(range(min(qubits), max(qubits) + 1))
for qubit in con_qubits:
gate_maping[qubit].append(gate)
layer: int = 0
waiting_list: dict[int, Gate] = {}
completed = [False] * self.circuit.nqubits
for q, l in gate_maping.items():
completed[q] = not bool(l)
ignore_q: list[int] = []
self.last_idx: dict[int, int] = {}
while not all(completed):
if layer not in self._layer_gate_mapping:
self._layer_gate_mapping[layer] = {}
for q in range(self.circuit.nqubits):
if q in ignore_q:
ignore_q.remove(q)
continue
if len(gate_maping[q]) == 0 and not completed[q]:
completed[q] = True
self.last_idx[q] = layer - 1
if q in waiting_list or completed[q]:
continue
gate = gate_maping[q][0]
if gate.nqubits == 1:
self._layer_gate_mapping[layer][q] = gate
gate_maping[q].pop(0)
if gate.nqubits > 1:
waiting_list[q] = gate
qubits = gate.qubits
if self.style.layout == "compact":
con_qubits = qubits
elif self.style.layout == "normal":
con_qubits = tuple(range(min(qubits), max(qubits) + 1))
if all(key in waiting_list for key in con_qubits) and all(
waiting_list[qr] == gate for qr in con_qubits
):
self._layer_gate_mapping[layer][q] = gate
for c_qubit in con_qubits:
gate_maping[c_qubit].pop(0)
del waiting_list[c_qubit]
if self.style.layout == "compact":
for m_qubit in range(min(qubits), q):
if m_qubit not in qubits and m_qubit in self._layer_gate_mapping[layer]:
ignore_q.append(m_qubit)
if layer + 1 not in self._layer_gate_mapping:
self._layer_gate_mapping[layer + 1] = {}
self._layer_gate_mapping[layer + 1][m_qubit] = self._layer_gate_mapping[layer][
m_qubit
]
del self._layer_gate_mapping[layer][m_qubit]
ignore_q += [*(m_qubit for m_qubit in range(q + 1, max(qubits) + 1))]
if len(gate_maping[q]) == 0 and not completed[q]:
completed[q] = True
self.last_idx[q] = layer
layer += 1
def _xpos(self, layer: int) -> float:
"""
Compute the left x of a given layer.
With align_layer=True, this ignores *wires* and aligns across all wires.
Args:
layer: Column index (0 = the initial label pad entry).
Returns:
The x-coordinate (inches) of the left edge of this column.
"""
return sum(self._max_layer_width[: layer + 1])
def _reserve(self, width: float, wires: Iterable[int], layer: int) -> None:
"""
Reserve width in a column for a set of wires.
Args:
width: Box width (inches), *excluding* left/right gate margins.
wires: Wires to update.
layer: Column index to reserve.
"""
full_width = width + self.style.gate_margin * 2
for w in wires:
layers = self._layer_widths[w]
if len(layers) > layer:
layers[layer] = max(layers[layer], full_width)
else:
for _ in range(len(layers), layer):
layers.append(0.0)
layers.append(full_width)
def _place(self, wires: Iterable[int], layer: int, *, min_width: float = 0.0) -> float:
"""
Choose a column and compute its left x for a set of wires.
This does *not* draw anything; it optionally reserves a minimal width
for the selected column to create the column on those wires.
Args:
wires: Wires that must participate in this column.
min_width: Optional minimal content width to reserve (inches).
Returns:
A tuple ``(x, layer, wires_sorted)``:
- x: Left x of the column (including left margin).
- layer: Column index.
- wires_sorted: Sorted unique wires used for placement.
"""
wires = [*range(min(wires), max(wires) + 1)]
x = self._xpos(layer) + self.style.gate_margin
if min_width:
self._reserve(min_width, wires, layer)
return x
def _text_width(self, text: str) -> float:
"""
Measure rendered text width in inches for current DPI/style.
Args:
text: Text to measure (mathtext is supported).
Returns:
The rendered width in inches.
"""
t = plt.Text(
0,
0,
text,
fontproperties=self.style.font,
)
self.axes.add_artist(t)
renderer = self.axes.figure.canvas.get_renderer() # type: ignore[attr-defined]
width = t.get_window_extent(renderer=renderer).width / self.style.dpi
t.remove()
return width
# Basic primitives ----------------------------------------------------
def _draw_targets_gate(
self,
*,
label: str,
targets: list[int] | None,
x: float | None = None,
layer: int | None = None,
color: str | None = None,
) -> tuple[float, int, float]:
"""
Draw a box gate that touches only *targets* (no controls).
If ``x``/``layer`` are not provided, the earliest column that all
targets can share is used.
Args:
label: Text label to show inside the box.
targets: Target qubit indices. If None, defaults to all wires.
x: Optional left x of the column (from a prior `_place`).
layer: Optional column index (from a prior `_place`).
color: Optional box fill/edge color.
Returns:
A tuple ``(x, layer, width)`` where:
- x: Left x used for this column.
- layer: Column index used.
- width: Content width (inches) of the box.
"""
targets = list(targets or range(self._wires))
t_sorted = sorted(targets)
a, b = t_sorted[0], t_sorted[-1]
# Decide layer/x if not given (no-controls: only target wires matter)
if layer is None or x is None:
layer = max(len(self._layer_widths[w]) for w in t_sorted)
x = self._xpos(layer) + self.style.gate_margin
# Measure and reserve the full width at the given x/layer
width = max(self._text_width(label) + self.style.gate_pad * 2, self.style.min_gate_w)
self._reserve(width, t_sorted, layer)
# Geometry
y_a = self._ypos(a, n_qubits=self._wires, sep=self.style.wire_sep)
y_b = self._ypos(b, n_qubits=self._wires, sep=self.style.wire_sep)
y_bottom = min(y_a, y_b) - self.style.min_gate_h / 2
height = abs(y_b - y_a) + self.style.min_gate_h
y_center = (y_a + y_b) / 2.0
gate_color = color or self.style.theme.primary
# Box
self.axes.add_patch(
FancyBboxPatch(
(x, y_bottom),
width,
height,
boxstyle=self.style.bulge,
mutation_scale=0.3,
facecolor=gate_color,
edgecolor=gate_color,
zorder=self._Z["gate"],
)
)
# Label
self.axes.text(
x + width / 2,
y_center,
label,
ha="center",
va="center",
color=self.style.theme.on_primary,
fontproperties=self.style.font,
zorder=self._Z["gate_label"],
)
# Visual connectors for multi-targets
if len(t_sorted) > 1:
for t in t_sorted:
y_t = self._ypos(t, n_qubits=self._wires, sep=self.style.wire_sep)
self.axes.add_patch(
Circle(
(x + self.style.connector_r, y_t),
self.style.connector_r,
color=self.style.theme.background,
zorder=self._Z["connector"],
)
)
self.axes.add_patch(
Circle(
(x + width - self.style.connector_r, y_t),
self.style.connector_r,
color=self.style.theme.background,
zorder=self._Z["connector"],
)
)
return x, layer, width
def _draw_control_dot(self, wire: int, x: float) -> None:
"""
Draw a filled control dot at the given wire/x.
Args:
wire: Qubit index.
x: Column anchor x coordinate.
"""
y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
self.axes.add_patch(Circle((x, y), self.style.control_r, color=self.style.theme.accent, zorder=self._Z["node"]))
def _draw_plus_sign(self, wire: int, x: float) -> None:
"""
Draw a target ⊕ marker at the given wire/x.
Args:
wire: Qubit index.
x: Column anchor x coordinate.
"""
y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
self.axes.add_patch(Circle((x, y), self.style.target_r, color=self.style.theme.accent, zorder=self._Z["node"]))
self.axes.add_line(
plt.Line2D(
(x, x),
(y - self.style.target_r / 2, y + self.style.target_r / 2),
lw=1.5,
color=self.style.theme.background,
zorder=self._Z["gate_label"],
)
)
self.axes.add_line(
plt.Line2D(
(x - self.style.target_r / 2, x + self.style.target_r / 2),
(y, y),
lw=1.5,
color=self.style.theme.background,
zorder=self._Z["gate_label"],
)
)
def _draw_bridge(self, wire_a: int, wire_b: int, x: float) -> None:
"""
Draw a vertical bridge line between two wires at x.
Args:
wire_a: First wire.
wire_b: Second wire.
x: Column x coordinate where the bridge is drawn.
"""
y1, y2 = (
self._ypos(wire_a, n_qubits=self._wires, sep=self.style.wire_sep),
self._ypos(wire_b, n_qubits=self._wires, sep=self.style.wire_sep),
)
self.axes.add_line(plt.Line2D([x, x], [y1, y2], color=self.style.theme.accent, zorder=self._Z["bridge"]))
def _draw_swap_mark(self, wire: int, x: float) -> None:
"""
Draw one X of a SWAP marker on a given wire at x.
Args:
wire: Qubit index.
x: Column anchor x coordinate.
"""
y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
offset = self.style.min_gate_w / 3
color = self.style.theme.accent
for xs, ys in (
([x + offset, x - offset], [y + self.style.min_gate_h / 4, y - self.style.min_gate_h / 4]),
([x - offset, x + offset], [y + self.style.min_gate_h / 4, y - self.style.min_gate_h / 4]),
):
self.axes.add_line(plt.Line2D(xs, ys, color=color, linewidth=2, zorder=self._Z["gate"]))
def _draw_swap_gate(
self,
targets: list[int],
layer: int,
*,
x: float | None = None,
) -> float:
"""
Draw a SWAP between two target wires.
Args:
targets: Exactly two wires to swap.
x: Optional left x (from `_place`).
layer: Optional column index (from `_place`).
Returns:
The anchor x within the column where the swap glyph is centered.
"""
t_sorted = sorted(targets)
if x is None:
x = self._place(t_sorted, layer, min_width=self.style.target_r * 2)
else:
self._reserve(self.style.target_r * 2, t_sorted, layer)
x_anchor = x + self.style.gate_pad
for t in t_sorted:
self._draw_swap_mark(t, x_anchor)
# vertical bridge between the two targets
self._draw_bridge(t_sorted[0], t_sorted[1], x_anchor)
return x_anchor
def _draw_controlled_gate(self, gate: Controlled, layer: int) -> None:
"""
Draw a controlled gate (controls + targets).
Handles:
- MCX family as control dots + ⊕ (no box),
- Controlled-SWAP by reusing swap glyphs,
- Generic controlled gates as a box over targets with control stems.
Args:
gate: Controlled gate instance.
"""
targets = list(gate.target_qubits or range(self._wires))
controls = list(gate.control_qubits or [])
all_wires = sorted(set(targets + controls))
# Place a column shared by all involved wires; reserve minimal node width
x = self._place(all_wires, layer, min_width=self.style.target_r * 2)
# Controlled-X family (CNOT / multi-controlled X): target glyph, not a box
if gate.is_modified_from(X):
x_anchor = x + self.style.gate_pad
for c in controls:
self._draw_control_dot(c, x_anchor)
self._draw_bridge(c, targets[0], x_anchor)
self._draw_plus_sign(targets[0], x_anchor)
return
# Controlled SWAP (Fredkin): reuse the SWAP primitive, then add controls
if getattr(gate.basic_gate, "name", "") == "SWAP":
x_anchor = self._draw_swap_gate(targets, x=x, layer=layer)
for c in controls:
self._draw_control_dot(c, x_anchor)
self._draw_bridge(c, targets[0], x_anchor)
return
# Generic controlled gate: draw the target box at this same column,
# then widen control wires for that layer and add stems to the center.
label = self._gate_label(gate.basic_gate)
gate_color = self.style.theme.accent
x_box, layer_box, width = self._draw_targets_gate(
label=label, targets=targets, x=x, layer=layer, color=gate_color
)
# Ensure control wires also reserve the same width for this layer
extra_controls = [c for c in controls if c not in targets]
if extra_controls:
self._reserve(width, extra_controls, layer_box)
x_center = x_box + width / 2.0
for c in controls:
self._draw_control_dot(c, x_center)
self._draw_bridge(c, targets[0], x_center)
# Measurements --------------------------------------------------------
def _draw_inline_measure(self, qubits: list[int], layer: int) -> None:
"""
Draw measurement boxes interleaved with gates (same column).
Args:
qubits: Wires to measure in this column.
"""
layer = max(len(self._layer_widths[q]) for q in qubits)
x = self._xpos(layer) + self.style.gate_margin
self._reserve(self.style.min_gate_w, qubits, layer)
for q in qubits:
self._draw_measure_symbol(q, x)
def _draw_concurrent_measures(self, qubits: list[int], layer: int) -> None:
"""
Draw a final column of measurements (one shared column).
Args:
qubits: Wires to measure concurrently.
"""
layer = max(len(v) for v in self._layer_widths.values())
x = self._xpos(layer) + self.style.gate_margin
self._max_layer_width.append(self.style.min_gate_w)
for q in qubits:
self._draw_measure_symbol(q, x)
def _draw_measure_symbol(self, wire: int, x: float) -> None:
"""
Draw a measurement glyph at the given wire/x.
Args:
wire: Qubit index.
x: Shared left x for this measurement column.
"""
y = self._ypos(wire, n_qubits=self._wires, sep=self.style.wire_sep)
self.axes.add_patch(
FancyBboxPatch(
(x, y - self.style.min_gate_h / 2),
self.style.min_gate_w,
self.style.min_gate_h,
boxstyle=self.style.bulge,
mutation_scale=0.3,
facecolor=self.style.theme.background,
edgecolor=self.style.theme.on_background,
linewidth=1.25,
zorder=self._Z["gate"],
)
)
self.axes.add_patch(
Arc(
(x + self.style.min_gate_w / 2, y - self.style.min_gate_h / 2),
self.style.min_gate_w * 1.5,
self.style.min_gate_h,
theta1=0,
theta2=180,
linewidth=1.25,
color=self.style.theme.on_background,
zorder=self._Z["gate_label"],
)
)
self.axes.add_patch(
FancyArrow(
x + self.style.min_gate_w / 2,
y - self.style.min_gate_h / 2,
dx=self.style.min_gate_w * 0.7,
dy=self.style.min_gate_h * 0.7,
length_includes_head=True,
width=0,
color=self.style.theme.on_background,
linewidth=1.25,
zorder=self._Z["gate_label"],
)
)
# Final decoration ----------------------------------------------------
def _draw_wires(self) -> None:
"""
Draw horizontal wires up to the last occupied x (plus tail).
For wires whose last operation is a measurement, the wire stops at the
measurement edge with no right-hand tail.
"""
# how far the drawing for this wire actually goes
x_end = sum(self._max_layer_width)
for q in range(self._wires):
y = self._ypos(q, n_qubits=self._wires, sep=self.style.wire_sep)
# keep the tail only for wires that KEEP going after their last gate
self.axes.add_line(
plt.Line2D([0, x_end], [y, y], lw=1, color=self.style.theme.surface_muted, zorder=self._Z["wire"])
)
def _draw_wire_labels(self) -> None:
"""Draw wire labels to the left of the drawing."""
labels = self.style.wire_label or [rf"$q_{{{i}}}$" for i in range(self._wires)]
widths = [self._text_width(lbl) for lbl in labels]
self._max_label_width = max(widths)
for i, label in enumerate(labels):
y = self._ypos(i, n_qubits=self._wires, sep=self.style.wire_sep)
self.axes.text(
-self.style.label_pad,
y,
label,
ha="right",
va="center",
fontproperties=self.style.font,
color=self.style.theme.on_background,
zorder=self._Z["wire_label"],
)
def _finalise_figure(self) -> None:
"""Finalize axes limits, aspect, background, and title."""
fig = self.axes.figure
fig.set_facecolor(self.style.theme.background)
total_length = sum(self._max_layer_width)
x_end = self.style.padding + total_length
y_end = self.style.padding + (self._wires - 1) * self.style.wire_sep
self.axes.set_xlim(
-self.style.padding - self._max_label_width - self.style.label_pad,
x_end,
)
self.axes.set_ylim(-self.style.padding, y_end)
if self.style.title:
self.axes.set_title(
self.style.title,
pad=10,
color=self.style.theme.surface_muted,
fontdict={"fontsize": self.style.fontsize},
)
# In IPython keep figure square so equal aspect ratio does not shrink
try:
get_ipython() # type: ignore
size = max(self.axes.get_xlim()[1] - self.axes.get_xlim()[0], y_end + self.style.padding)
fig.set_size_inches(size, size, forward=True) # type: ignore[union-attr]
except NameError:
fig.set_size_inches( # type: ignore[union-attr]
self.axes.get_xlim()[1] - self.axes.get_xlim()[0], y_end + self.style.padding, forward=True
)
self.axes.set_aspect("equal", adjustable="box")
self.axes.axis("off")
# ------------------------------------------------------------------
# Helpers - human-readable gate labels & π-fractions
# ------------------------------------------------------------------
@staticmethod
def _ypos(index: int, *, n_qubits: int, sep: float) -> float:
return (n_qubits - 1 - index) * sep
@staticmethod
def _pi_fraction(value: float, /, tol: float = 1e-2) -> str:
"""
Format a float as a π-fraction (mathtext) when close to a rational.
Args:
value: Angle value (radians).
tol: Tolerance for accepting the rational approximation.
Returns:
Mathtext string like ``"\\pi/5"`` or fallback decimal.
"""
coeff = value / np.pi
frac = Fraction(coeff).limit_denominator(32)
n, d = frac.numerator, frac.denominator
if abs(frac - coeff) < tol:
if n == 0:
return "0"
if d == 1:
return r"\pi" if n == 1 else rf"{n}\pi"
return rf"\pi/{d}" if n == 1 else rf"{n}\pi/{d}"
return f"{value:.2f}"
@staticmethod
def _with_superscript_dagger(label: str) -> str:
# Convert trailing dagger to math superscript, e.g. "RX†" -> r"$\mathrm{RX}^{\dagger}$"
if label.endswith("†"):
base = label[:-1]
return rf"$\mathrm{{{base}}}^{{\dagger}}$"
return label
@staticmethod
def _gate_label(gate: Gate) -> str:
"""Build a display label for a (possibly parameterized) gate.
Args:
gate: Gate object.
Returns:
Label text. Parameterized gates get ``name ( $args$ )``.
"""
name = MatplotlibCircuitRenderer._with_superscript_dagger(gate.name)
if gate.is_parameterized and gate.get_parameter_values():
parameters = ", ".join(
MatplotlibCircuitRenderer._pi_fraction(value) for value in gate.get_parameter_values()
)
return rf"{name} (${parameters}$)"
return gate.name
@staticmethod
def _make_axes(dpi: int) -> Axes:
"""
Create a new figure and axes with the given DPI.
Args:
dpi (int): The DPI of the figure
Returns:
A newly created Matplotlib Axes.
"""
_, ax = plt.subplots(dpi=dpi, constrained_layout=True)
return ax