画个量子门玩玩:用 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 记录了鼠标在门内的相对位置,这样拖拽时门不会"跳"一下。还有光标在 grabgrabbing 之间切换,跟 Figma 的操作手感一致。

现在刷新页面,你可以用鼠标拖着那些 H、X、Z 方块满屏跑了——当然,只在水平方向上有意义,因为 y 坐标被锁死在量子线上。

连线:CNOT 门的视觉灵魂

量子电路里最显眼的不是那些方块门,而是 CNOT 门——一个实心圆点(控制位)加一个大圆圈(目标位),中间一条竖线连着。这条连线是量子电路图的视觉标志。

我们先从简单的开始:创建一个 CNOT 门,它本质上是一个门作用在两个 qubit 上。数据结构里需要记录 controlQubittargetQubit

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.xthis.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 行,但交互体验已经比很多网页上的静态电路图强了。剩下的就是加更多门类型、支持导出、加撤销重做——这些都是在现有骨架上的添砖加瓦。