画个量子门玩玩:用 Canvas 从零搓一个能拖拽连线的电路图
门电路这东西,画起来比听起来简单多了。
我第一次被量子电路图搞晕,不是因为看不懂那些H门、CNOT门,而是因为画图工具太™难用了。要么是死贵的专业软件,要么是只能看不能改的截图。所以我一拍大腿——干脆自己写一个。
这篇文章会带你用 Canvas 从零撸一个能拖拽门电路、能连线的小工具。不依赖任何框架,就原生 JS + Canvas,写完大概 300 行代码。拖拽、连线、删除,这些交互通通安排上。
先跑起来:搭一个 Canvas 画板
量子电路图本质上就是个二维平面,横轴是时间(量子比特从 |0⟩ 到测量),纵轴是不同的量子线。每个门就是放在交叉点上的一个小方块。
先把画板搭起来,一个 800×400 的 Canvas,背景搞成深色,显得专业一点(主要是护眼)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>量子电路图 - 拖拽连线版</title>
<style>
body { margin: 0; background: #1a1a2e; display: flex; justify-content: center; padding-top: 20px; }
canvas { border: 1px solid #333; border-radius: 8px; cursor: crosshair; }
</style>
</head>
<body>
<canvas id="circuit" width="800" height="400"></canvas>
<script src="circuit.js"></script>
</body>
</html>
canvas 的 cursor: crosshair 是个小细节,十字光标让拖拽操作更有"我在搞精密仪器"的错觉。
接下来在 circuit.js 里先画几条量子线。量子线就是水平直线,每条线代表一个 qubit。我们画 4 条,间距 60px,上下留点边距。
const canvas = document.getElementById('circuit');
const ctx = canvas.getContext('2d');
const QUBIT_COUNT = 4;
const QUBIT_SPACING = 60;
const TOP_MARGIN = 80;
const LINE_COLOR = '#4a4a6a';
const GATE_COLOR = '#e94560';
const GATE_WIDTH = 50;
const GATE_HEIGHT = 36;
function drawGrid() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画量子线
ctx.strokeStyle = LINE_COLOR;
ctx.lineWidth = 1.5;
for (let i = 0; i < QUBIT_COUNT; i++) {
const y = TOP_MARGIN + i * QUBIT_SPACING;
ctx.beginPath();
ctx.moveTo(40, y);
ctx.lineTo(canvas.width - 40, y);
ctx.stroke();
// 左侧标注 |0⟩
ctx.fillStyle = '#aaa';
ctx.font = '14px monospace';
ctx.fillText('|0⟩', 10, y + 5);
}
}
drawGrid();
刷新页面,四条灰线安安静静躺在那,左边标着 |0⟩。量子电路图的基本骨架就有了。但这还只是个静态画面,接下来得让门"活"起来。
门的数据结构:别想太复杂
一个量子门在数据结构里就是几个字段:类型(H、X、CNOT 之类)、位置(x, y)、作用在哪个 qubit 上,以及如果它是个多量子比特门(比如 CNOT),还得记录控制位和目标位。
但刚开始咱们别搞太复杂。先定义一个 Gate 类,每个门知道自己画在哪条线上、是什么类型、当前坐标。
class Gate {
constructor(type, qubitIndex, x) {
this.type = type; // 'H', 'X', 'Z', 'CNOT', 'MEASURE'
this.qubitIndex = qubitIndex; // 作用在第几条线上
this.x = x;
this.y = TOP_MARGIN + qubitIndex * QUBIT_SPACING;
this.width = GATE_WIDTH;
this.height = GATE_HEIGHT;
}
draw(ctx) {
ctx.save();
ctx.fillStyle = GATE_COLOR;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
// 圆角矩形作为门的外框
const rx = 8, ry = 8;
const { x, y, width, height } = this;
ctx.beginPath();
ctx.moveTo(x + rx, y);
ctx.lineTo(x + width - rx, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + ry);
ctx.lineTo(x + width, y + height - ry);
ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height);
ctx.lineTo(x + rx, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - ry);
ctx.lineTo(x, y + ry);
ctx.quadraticCurveTo(x, y, x + rx, y);
ctx.closePath();
ctx.fill();
ctx.stroke();
// 门类型文字
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.type, x + width / 2, y + height / 2);
ctx.restore();
}
}
然后在 drawGrid 后面加上测试用的门:
const gates = [
new Gate('H', 0, 150),
new Gate('X', 1, 250),
new Gate('Z', 2, 350),
new Gate('H', 3, 200),
];
function drawAll() {
drawGrid();
gates.forEach(g => g.draw(ctx));
}
drawAll();
现在画面上应该出现几个红底白字的方块,标着 H、X、Z,各自蹲在不同的量子线上。挺像那么回事了。
拖拽:从"点中"到"移动"的完整逻辑
拖拽的核心就三步:mousedown 时判断鼠标是否点中了某个门;mousemove 时更新这个门的位置;mouseup 时释放。但有个坑——门只能沿着自己的量子线水平移动,不能上下乱跑。毕竟一个 Hadamard 门不能突然蹦到另一条 qubit 上去。
let selectedGate = null;
let dragOffsetX = 0;
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// 从后往前遍历,优先选中最上层的门
for (let i = gates.length - 1; i >= 0; i--) {
const g = gates[i];
if (mx >= g.x && mx <= g.x + g.width &&
my >= g.y && my <= g.y + g.height) {
selectedGate = g;
dragOffsetX = mx - g.x;
canvas.style.cursor = 'grabbing';
break;
}
}
});
canvas.addEventListener('mousemove', (e) => {
if (!selectedGate) {
// 没拖拽时,检测鼠标是否悬停在门上,改光标
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
let hovering = false;
for (let i = gates.length - 1; i >= 0; i--) {
const g = gates[i];
if (mx >= g.x && mx <= g.x + g.width &&
my >= g.y && my <= g.y + g.height) {
hovering = true;
break;
}
}
canvas.style.cursor = hovering ? 'grab' : 'crosshair';
return;
}
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
selectedGate.x = mx - dragOffsetX;
// 限制在画布范围内
selectedGate.x = Math.max(40, Math.min(selectedGate.x, canvas.width - 40 - selectedGate.width));
drawAll();
});
canvas.addEventListener('mouseup', () => {
selectedGate = null;
canvas.style.cursor = 'crosshair';
});
这里有几个细节值得注意:遍历 gates 时从后往前,这样如果两个门重叠,会选中最上面那个(后画的在上面)。dragOffsetX 记录了鼠标在门内的相对位置,这样拖拽时门不会"跳"一下。还有光标在 grab 和 grabbing 之间切换,跟 Figma 的操作手感一致。
现在刷新页面,你可以用鼠标拖着那些 H、X、Z 方块满屏跑了——当然,只在水平方向上有意义,因为 y 坐标被锁死在量子线上。
连线:CNOT 门的视觉灵魂
量子电路里最显眼的不是那些方块门,而是 CNOT 门——一个实心圆点(控制位)加一个大圆圈(目标位),中间一条竖线连着。这条连线是量子电路图的视觉标志。
我们先从简单的开始:创建一个 CNOT 门,它本质上是一个门作用在两个 qubit 上。数据结构里需要记录 controlQubit 和 targetQubit。
class CNOTGate {
constructor(controlQubit, targetQubit, x) {
this.type = 'CNOT';
this.controlQubit = controlQubit;
this.targetQubit = targetQubit;
this.x = x;
this.controlY = TOP_MARGIN + controlQubit * QUBIT_SPACING;
this.targetY = TOP_MARGIN + targetQubit * QUBIT_SPACING;
}
draw(ctx) {
const { x, controlY, targetY } = this;
const dotRadius = 6;
ctx.save();
// 连线
ctx.strokeStyle = '#e94560';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, controlY);
ctx.lineTo(x, targetY);
ctx.stroke();
// 控制位:实心圆
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.arc(x, controlY, dotRadius, 0, Math.PI * 2);
ctx.fill();
// 目标位:大空心圆
ctx.strokeStyle = '#e94560';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.arc(x, targetY, 12, 0, Math.PI * 2);
ctx.stroke();
// 目标位中间的十字线(可选,更专业)
ctx.beginPath();
ctx.moveTo(x - 6, targetY);
ctx.lineTo(x + 6, targetY);
ctx.moveTo(x, targetY - 6);
ctx.lineTo(x, targetY + 6);
ctx.stroke();
ctx.restore();
}
}
然后我们改造一下存储结构。之前 gates 数组里只放普通门,现在需要统一管理。可以用一个 elements 数组,里面既可以是 Gate 也可以是 CNOTGate。或者干脆让 CNOT 也继承自 Gate,但 draw 方法不同。
为了简单,我选择用统一的数组,绘制时判断类型:
const elements = [
new Gate('H', 0, 150),
new Gate('X', 1, 250),
new CNOTGate(2, 3, 350), // 控制位在 qubit 2,目标位在 qubit 3
new Gate('H', 3, 500),
];
function drawAll() {
drawGrid();
elements.forEach(el => el.draw(ctx));
}
现在刷新页面,你应该能看到一条红色竖线连接着量子线 2 和 3,上面一个实心圆点,下面一个大圆圈加十字。这就是 CNOT 门的标准画法。
但等等——这个 CNOT 门能拖拽吗?还不能。因为我们的 mousedown 判断逻辑只检查了 Gate 类的 x, y, width, height 矩形区域,而 CNOT 没有这个矩形。得给 CNOT 也加上命中检测。
// 在 CNOTGate 类里加一个方法
containsPoint(mx, my) {
// CNOT 的"可点击区域"是控制位圆点和目标位圆圈之间的整条竖线区域
const minY = Math.min(this.controlY, this.targetY);
const maxY = Math.max(this.controlY, this.targetY);
return Math.abs(mx - this.x) < 15 && my >= minY - 10 && my <= maxY + 10;
}
然后在 mousedown 里:
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i];
let hit = false;
if (el instanceof Gate) {
hit = mx >= el.x && mx <= el.x + el.width &&
my >= el.y && my <= el.y + el.height;
} else if (el instanceof CNOTGate) {
hit = el.containsPoint(mx, my);
}
if (hit) {
selectedGate = el;
dragOffsetX = mx - el.x;
break;
}
}
CNOT 拖拽时的更新逻辑也得处理一下——x 变了,controlY 和 targetY 不变(因为 qubit 索引没变),但绘制时需要重新计算位置。好在我们的 draw 方法每次都从 this.x 和 this.controlY/targetY 读取,所以拖拽时只需要改 el.x 就行。
// mousemove 里
if (selectedGate) {
selectedGate.x = mx - dragOffsetX;
selectedGate.x = Math.max(40, Math.min(selectedGate.x, canvas.width - 40 - 50));
drawAll();
}
现在 CNOT 门也能拖着跑了,竖线跟着移动,控制和目标位的标记也同步更新。挺酷的。
进阶交互:双击添加新门
光拖拽已有的门不够爽,我们得能从工具栏往画布上扔新门。最简单的实现方式:在 Canvas 上方放一排按钮,点击后进入"放置模式",再点画布就把门放上去。
<div class="toolbar">
<button data-gate="H">H 门</button>
<button data-gate="X">X 门</button>
<button data-gate="Z">Z 门</button>
<button data-gate="CNOT">CNOT</button>
<button data-gate="MEASURE">测量</button>
<button id="delete-btn">🗑 删除选中</button>
</div>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.toolbar button {
padding: 8px 16px;
background: #16213e;
color: #e94560;
border: 1px solid #e94560;
border-radius: 6px;
cursor: pointer;
font-family: monospace;
font-size: 14px;
transition: background 0.2s;
}
.toolbar button:hover {
background: #e94560;
color: #fff;
}
.toolbar button.active {
background: #e94560;
color: #fff;
}
JS 逻辑:点击按钮设置一个 currentTool 变量,然后在 canvas 上点击时,根据当前工具创建对应的门。
let currentTool = null;
let placingCNOT = { step: 0, controlQubit: null }; // CNOT 需要两步:先选控制位,再选目标位
document.querySelectorAll('[data-gate]').forEach(btn => {
btn.addEventListener('click', () => {
// 切换 active 样式
document.querySelectorAll('[data-gate]').forEach(b => b.classList.remove('active'));
if (btn.dataset.gate === 'CNOT') {
currentTool = 'CNOT';
placingCNOT = { step: 0, controlQubit: null };
btn.classList.add('active');
} else {
currentTool = btn.dataset.gate;
btn.classList.add('active');
}
});
});
canvas.addEventListener('click', (e) => {
if (!currentTool) return;
if (selectedGate) return; // 如果正在拖拽,不处理点击
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// 计算点击位置最接近哪条量子线
const qubitIndex = Math.round((my - TOP_MARGIN) / QUBIT_SPACING);
if (qubitIndex < 0 || qubitIndex >= QUBIT_COUNT) return;
if (currentTool === 'CNOT') {
if (placingCNOT.step === 0) {
placingCNOT.controlQubit = qubitIndex;
placingCNOT.step = 1;
// 视觉提示:改变光标或显示状态
canvas.style.cursor = 'cell';
} else {
const controlQubit = placingCNOT.controlQubit;
const targetQubit = qubitIndex;
if (controlQubit === targetQubit) {
// 控制位和目标位不能相同
placingCNOT.step = 0;
canvas.style.cursor = 'crosshair';
return;
}
elements.push(new CNOTGate(controlQubit, targetQubit, mx));
placingCNOT.step = 0;
currentTool = null;
canvas.style.cursor = 'crosshair';
document.querySelector('[data-gate="CNOT"]').classList.remove('active');
drawAll();
}
} else {
// 普通单量子比特门
elements.push(new Gate(currentTool, qubitIndex, mx - GATE_WIDTH / 2));
currentTool = null;
document.querySelectorAll('[data-gate]').forEach(b => b.classList.remove('active'));
drawAll();
}
});
CNOT 的放置逻辑稍微绕一点:第一次点击选控制位,第二次点击选目标位。中间状态用 placingCNOT.step 跟踪。如果两次点了同一条线,就重置。
删除功能就简单了——选中一个门,点删除按钮,从数组里移除。
document.getElementById('delete-btn').addEventListener('click', () => {
if (selectedGate) {
const index = elements.indexOf(selectedGate);
if (index > -1) {
elements.splice(index, 1);
selectedGate = null;
drawAll();
}
}
});
// 也支持按 Delete 键删除
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedGate) {
const index = elements.indexOf(selectedGate);
if (index > -1) {
elements.splice(index, 1);
selectedGate = null;
drawAll();
}
}
}
});
测量门和更多细节
量子电路的最后一步通常是测量,画法是一个带小弧线的方框,里面写个"M"。我们可以给 Gate 类加个特殊处理:
// 在 Gate.draw 里,绘制文字之前
if (this.type === 'MEASURE') {
// 画测量符号:一个矩形,顶部有弧线,里面有仪表指针
ctx.fillStyle = '#e94560';
ctx.fillText('M', x + width / 2, y + height / 2 - 4);
// 画一个小弧线(简化为半圆)
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(x + width / 2, y + height / 2 + 6, 8, Math.PI, 0);
ctx.stroke();
// 画一个箭头
ctx.beginPath();
ctx.moveTo(x + width / 2, y + height / 2 + 14);
ctx.lineTo(x + width / 2 - 4, y + height / 2 + 8);
ctx.moveTo(x + width / 2, y + height / 2 + 14);
ctx.lineTo(x + width / 2 + 4, y + height / 2 + 8);
ctx.stroke();
return; // 已经画了文字和符号,跳过默认的 fillText
}
现在工具栏里点"测量",就能在量子线末端放一个测量门。配合 H 门、CNOT 门,一个简单的 Bell 态制备电路就能画出来了:H 门在 qubit 0,CNOT 控制 qubit 0 目标 qubit 1,最后两条线各接一个测量门。
常见问题
问:为什么我拖拽门的时候会选中整个页面文字?
这是浏览器的默认拖拽行为。在 canvas 的 mousedown 事件里加上 e.preventDefault() 就能解决。或者给 canvas 加个 CSS user-select: none。
问:CNOT 门的连线和门本身能分开拖拽吗?
目前实现里,CNOT 的控制位圆点、目标位圆圈和连线是一个整体,只能整体水平移动。如果你想要分别调整控制位和目标位的位置(比如画一个跨越多条量子线的 CNOT),需要把 CNOTGate 拆成两个独立对象加一条连线对象。这个留给你自己扩展。
问:画好的电路图怎么导出?
Canvas 自带 toDataURL() 方法,一行代码搞定:canvas.toDataURL('image/png') 返回 base64 编码的 PNG。你可以加一个"导出"按钮,点击后创建一个下载链接。
function exportImage() {
const link = document.createElement('a');
link.download = 'quantum-circuit.png';
link.href = canvas.toDataURL('image/png');
link.click();
}
问:能不能支持撤销操作?
可以,维护一个操作历史栈。每次添加或删除门时,把当前的 elements 数组深拷贝一份 push 进栈。Ctrl+Z 时从栈里 pop 出上一个状态,恢复绘制。注意深拷贝时 Gate 和 CNOTGate 对象里的方法会丢失,恢复时需要重新实例化。
到这一步,你已经有了一个能拖拽、能连线、能添加删除门电路的量子电路绘图工具。代码总共不到 400 行,但交互体验已经比很多网页上的静态电路图强了。剩下的就是加更多门类型、支持导出、加撤销重做——这些都是在现有骨架上的添砖加瓦。