【Canvas 2D】day49-boss-flowchart

作者:mario 发布时间: 2026-09-02 阅读量:6 评论数:0

Day 49 · BOSS 战:流程图编辑器 — Canvas 进阶的毕业考核

六天积攒的武器,今天全部上战场:文本排版引擎(Day 43)画节点标签、clip 蒙版(Day 44)做选中高亮、碰撞检测(Day 45)防节点重叠、脏矩形(Day 46)保住 60fps、多层画布(Day 47)分离静态与交互、交互管理器(Day 48)驱动拖拽与框选。今天的产出不是练习,是一个可以写进简历、发到 GitHub、录成演示视频的作品:流程图/拓扑图编辑器。


目录


一、目标与验收标准

1.1 最终效果

一个运行在浏览器里的流程图编辑器,用户可以:

  1. 新增节点:双击空白处创建节点,自动排版文本(换行 + 省略号)
  2. 拖拽节点:按住节点拖动,连线实时跟随,全程 60fps
  3. 创建连线:从节点边缘锚点拖出一条贝塞尔曲线连到另一个节点
  4. 框选多个节点:空白处按下拖动出现选框,框住的节点高亮
  5. 批量移动:框选后拖动任一选中节点,整组同步移动
  6. 删除:选中后按 Delete 键,节点 + 关联连线一起删
  7. 缩放视口:滚轮缩放、空格 + 拖拽平移(viewport 逆变换的正确使用场景)

1.2 硬性验收指标

指标 标准 检验方法
帧率 200 个节点 + 300 条连线拖拽时 ≥ 55fps DevTools Performance 面板录制
命中精度 鼠标点节点边缘 2px 内必须响应 肉眼 + 手动测试
内存 连续操作 10 分钟无持续增长 Memory 面板堆快照对比
功能完整性 上述 7 项交互全部可用 自测清单逐项打勾

⚠️ 不合格红线:如果拖拽时用"全量清屏 + 全量重绘"也能跑 60fps(节点少时确实能),必须手动把节点加到 200+ 再测——BOSS 战考的是脏矩形优化在真实规模下的表现,不是小数据下的自我感动。


二、需求拆解与架构设计

2.1 一句话架构

数据模型(Graph) ──变更──▶ 渲染层(多层画布 + 脏矩形) ◀──请求重绘── 交互层(状态机)

核心思想沿用 Day 47/48 的分层,但今天把它落成完整工程

职责 对应前几天的知识
model.ts 节点/连线数据、增删改查、变更通知 Day 48 的 items 管理
render.ts 三层画布绘制、脏矩形调度 Day 46 + Day 47
edge.ts 锚点计算、贝塞尔连线绘制与命中 Day 44 路径 + 今天新增
interact.ts 交互状态机:idle/hover/drag/marquee/connect/pan Day 48
text.ts 节点标签排版(换行/省略号/缓存) Day 43
viewport.ts 平移缩放变换与逆变换 Day 48 的 toScene
main.ts 组装所有模块,启动编辑器 今天第七节

2.2 目录结构

flow-editor/
├── index.html          # 三层 canvas + 工具栏
├── src/
│   ├── types.ts        # 公共类型定义
│   ├── model.ts        # 数据模型
│   ├── viewport.ts     # 视口变换
│   ├── text.ts         # 文本排版(Day 43 代码直接复用)
│   ├── render.ts       # 渲染引擎
│   ├── edge.ts         # 连线模块
│   ├── interact.ts     # 交互状态机
│   └── main.ts         # 组装入口
└── tsconfig.json

💡 复用原则:Day 43 的 wrapCJK/ellipsize、Day 46 的 DirtyTracker、Day 48 的 InteractionManager 骨架直接拷贝进来改,不要重写——工程能力的一半是"知道什么能复用"。


三、数据模型层:Graph / Node / Edge

3.1 类型定义(types.ts)

/** 节点形状类型 */
export type NodeKind = "process" | "decision" | "terminal";

/** 流程图节点 */
export interface FlowNode {
  id: string;
  kind: NodeKind;
  x: number;          // 场景坐标(不是屏幕坐标!)
  y: number;
  w: number;
  h: number;
  label: string;      // 节点文本
  selected: boolean;  // 是否被选中(渲染层据此加高亮)
}

/** 连线:从源节点的某锚点指向目标节点的某锚点 */
export interface FlowEdge {
  id: string;
  sourceId: string;   // 起点节点 id
  sourceSide: Side;   // 起点锚点方向
  targetId: string;   // 终点节点 id
  targetSide: Side;   // 终点锚点方向
  selected: boolean;
}

/** 锚点方向:上/右/下/左 */
export type Side = "top" | "right" | "bottom" | "left";

/** 视口:场景坐标 → 屏幕坐标的变换参数 */
export interface Viewport {
  offsetX: number;    // 平移 x
  offsetY: number;    // 平移 y
  scale: number;      // 缩放倍率
}

/** 图:节点 + 连线的集合 */
export interface Graph {
  nodes: FlowNode[];
  edges: FlowEdge[];
}

3.2 图模型与增删改查(model.ts)

import type { FlowNode, FlowEdge, Graph, Side } from "./types";

/**
 * 图模型:持有节点与连线,提供增删改查
 * 变更后通过 onChange 通知渲染层标记脏矩形
 */
export class GraphModel {
  readonly graph: Graph = { nodes: [], edges: [] };
  private nextId = 1;

  /** 变更回调:渲染层注册,用于触发脏矩形重绘 */
  onChange: ((dirty: { x: number; y: number; w: number; h: number }) => void) | null = null;

  /** 生成自增 id */
  private genId(prefix: string): string {
    return `${prefix}-${this.nextId++}`;
  }

  /** 新增节点,返回创建的节点 */
  addNode(x: number, y: number, label: string): FlowNode {
    const node: FlowNode = {
      id: this.genId("n"),
      kind: "process",
      x, y,
      w: 140, h: 60,
      label,
      selected: false,
    };
    this.graph.nodes.push(node);
    this.notify(node);
    return node;
  }

  /** 删除节点及其所有关联连线 */
  removeNode(id: string): void {
    const node = this.findNode(id);
    if (!node) return;
    // 先删关联连线(遍历过滤 sourceId 或 targetId 匹配的)
    this.graph.edges = this.graph.edges.filter(
      (e) => e.sourceId !== id && e.targetId !== id
    );
    // 再删节点本身
    this.graph.nodes = this.graph.nodes.filter((n) => n.id !== id);
    this.notify(node);
  }

  /** 新增连线(自动判定锚点方向) */
  addEdge(sourceId: string, targetId: string): FlowEdge | null {
    // 拒绝自连接
    if (sourceId === targetId) return null;
    // 拒绝重复连线
    const dup = this.graph.edges.some(
      (e) => e.sourceId === sourceId && e.targetId === targetId
    );
    if (dup) return null;

    const source = this.findNode(sourceId)!;
    const target = this.findNode(targetId)!;
    const edge: FlowEdge = {
      id: this.genId("e"),
      sourceId,
      sourceSide: this.bestSide(source, target),
      targetSide: this.bestSide(target, source),
      selected: false,
    };
    this.graph.edges.push(edge);
    return edge;
  }

  /** 按 id 查节点 */
  findNode(id: string): FlowNode | undefined {
    return this.graph.nodes.find((n) => n.id === id);
  }

  /** 选中所有位于选框内的节点 */
  selectInRect(rect: { x: number; y: number; w: number; h: number }): number {
    let count = 0;
    for (const n of this.graph.nodes) {
      n.selected =
        n.x >= rect.x && n.x + n.w <= rect.x + rect.w &&
        n.y >= rect.y && n.y + n.h <= rect.y + rect.h;
      if (n.selected) count++;
    }
    return count;
  }

  /** 清空所有选中态 */
  clearSelection(): void {
    for (const n of this.graph.nodes) n.selected = false;
    for (const e of this.graph.edges) e.selected = false;
  }

  /**
   * 自动选择最佳锚点方向:两节点中心连线朝向哪边就用哪边
   * 例:目标在源节点右侧 → 源用 right 锚点,目标用 left 锚点
   */
  private bestSide(from: FlowNode, to: FlowNode): Side {
    const dx = to.x + to.w / 2 - (from.x + from.w / 2);
    const dy = to.y + to.h / 2 - (from.y + from.h / 2);
    // 水平距离占主导 → 用左右锚点;否则用上下锚点
    if (Math.abs(dx) > Math.abs(dy)) {
      return dx > 0 ? "right" : "left";
    }
    return dy > 0 ? "bottom" : "top";
  }

  /** 通知渲染层某区域变脏 */
  private notify(box: { x: number; y: number; w: number; h: number }): void {
    this.onChange?.(box);
  }
}

🎯 设计要点bestSide 让连线自动走"最顺"的方向——目标在右边就连右锚点,不用用户手动选。这种小细节正是"可用作品"与"课程作业"的分水岭。


四、渲染层:多层画布 + 脏矩形

4.1 三层分工

canvas id 内容 重绘频率
静态层 #bg 网格背景 仅视口变化时
连线层 #edges 所有贝塞尔连线 连线变化时(脏矩形)
节点层 #nodes 节点矩形 + 标签 + 选中框 节点变化时(脏矩形)

💡 为什么连线单独一层:拖节点时连线跟着变但其他节点不动——分层后拖拽只重绘"移动节点的新旧区域 + 关联连线区域",不碰其他节点的像素。

4.2 渲染器(render.ts 核心骨架)

import type { GraphModel } from "./model";
import type { Viewport } from "./types";

/** 节点外扩边距:选中框、阴影等附加绘制的安全区 */
const PADDING = 12;

/**
 * 渲染引擎:管理三层画布,调度脏矩形重绘
 */
export class Renderer {
  private layerNodes!: CanvasRenderingContext2D;
  private layerEdges!: CanvasRenderingContext2D;
  private layerBg!: CanvasRenderingContext2D;
  private vp: Viewport = { offsetX: 0, offsetY: 0, scale: 1 };

  constructor(
    private stage: HTMLElement,
    private model: GraphModel
  ) {}

  /** 初始化三层画布(Day 47 的 setupLayers 改造版) */
  init(): void {
    const dpr = window.devicePixelRatio || 1;
    const w = this.stage.clientWidth;
    const h = this.stage.clientHeight;
    for (const id of ["bg", "edges", "nodes"]) {
      const c = this.stage.querySelector(`#${id}`) as HTMLCanvasElement;
      c.width = Math.round(w * dpr);
      c.height = Math.round(h * dpr);
      const ctx = c.getContext("2d")!;
      ctx.scale(dpr, dpr);
      if (id === "bg") this.layerBg = ctx;
      else if (id === "edges") this.layerEdges = ctx;
      else this.layerNodes = ctx;
    }
    this.drawGrid();
  }

  /** 应用视口变换(平移/缩放后调用) */
  setViewport(vp: Viewport): void {
    this.vp = vp;
    // 视口变化影响所有层 → 全量重绘(这是合理的全量:低频操作)
    this.drawGrid();
    this.redrawAllEdges();
    this.redrawAllNodes();
  }

  /**
   * 脏矩形重绘节点层:只清除并重绘与脏区相交的节点
   * @param dirty 场景坐标系下的脏区
   */
  redrawNodesInDirty(dirty: { x: number; y: number; w: number; h: number }): void {
    // 场景坐标 → 屏幕坐标(放大 PADDING 容纳选中框)
    const sx = dirty.x * this.vp.scale + this.vp.offsetX - PADDING;
    const sy = dirty.y * this.vp.scale + this.vp.offsetY - PADDING;
    const sw = dirty.w * this.vp.scale + PADDING * 2;
    const sh = dirty.h * this.vp.scale + PADDING * 2;

    this.layerNodes.clearRect(sx, sy, sw, sh);

    for (const n of this.model.graph.nodes) {
      // AABB 相交测试(Day 45):不相交的节点直接跳过
      const nx = n.x * this.vp.scale + this.vp.offsetX;
      const ny = n.y * this.vp.scale + this.vp.offsetY;
      const nw = n.w * this.vp.scale, nh = n.h * this.vp.scale;
      if (nx + nw < sx || sx + sw < nx || ny + nh < sy || sy + sh < ny) continue;
      this.drawNode(n);
    }
  }

  /** 绘制单个节点(含标签排版 + 选中高亮) */
  private drawNode(n: import("./types").FlowNode): void {
    const ctx = this.layerNodes;
    const vp = this.vp;
    const x = n.x * vp.scale + vp.offsetX;
    const y = n.y * vp.scale + vp.offsetY;
    const w = n.w * vp.scale, h = n.h * vp.scale;

    ctx.save();
    // 决策节点用菱形?BOSS 战简化:不同 kind 换配色即可,形状留作进阶作业
    ctx.fillStyle = n.selected ? "rgba(0,198,255,0.15)" : "#1a2740";
    ctx.strokeStyle = n.selected ? "#00c6ff" : "#3a4a6a";
    ctx.lineWidth = n.selected ? 2 : 1;

    // 圆角矩形路径
    const r = 8;
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();

    // 标签:Day 43 的 ellipsize 截断 + 居中
    ctx.fillStyle = "#e0e8f0";
    ctx.font = "13px sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    const text = this.ellipsize(ctx, n.label, w - 16);
    ctx.fillText(text, x + w / 2, y + h / 2);
    ctx.restore();
  }

  /** 网格背景(静态层,仅视口变化时重绘) */
  private drawGrid(): void { /* 网格绘制,留给你补全——见自测挑战 T1 */ }

  /** 全量重绘连线层 */
  redrawAllEdges(): void { /* 见第五节 edge.ts */ }

  /** 全量重绘节点层 */
  private redrawAllNodes(): void {
    const w = this.stage.clientWidth, h = this.stage.clientHeight;
    this.layerNodes.clearRect(0, 0, w, h);
    for (const n of this.model.graph.nodes) this.drawNode(n);
  }

  /** Day 43 的省略号截断(复用) */
  private ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
    if (ctx.measureText(text).width <= maxW) return text;
    let out = "";
    for (const ch of text) {
      if (ctx.measureText(out + ch + "…").width > maxW) break;
      out += ch;
    }
    return out + "…";
  }
}

五、连线层:贝塞尔曲线与锚点

5.1 锚点坐标计算

import type { FlowNode, Side } from "./types";

/**
 * 计算节点某方向锚点的场景坐标
 * @param node  目标节点
 * @param side  锚点方向
 */
export function anchorOf(node: FlowNode, side: Side): { x: number; y: number } {
  switch (side) {
    case "top":    return { x: node.x + node.w / 2, y: node.y };
    case "bottom": return { x: node.x + node.w / 2, y: node.y + node.h };
    case "left":   return { x: node.x,             y: node.y + node.h / 2 };
    case "right":  return { x: node.x + node.w,    y: node.y + node.h / 2 };
  }
}

5.2 三次贝塞尔连线(edge.ts)

/**
 * 绘制一条连线:三次贝塞尔曲线 + 末端箭头
 * @param ctx     连线层上下文
 * @param from    起点锚点(场景坐标)
 * @param to      终点锚点(场景坐标)
 * @param fromSide 起点方向(决定控制点伸展方向)
 * @param vp      视口(场景 → 屏幕)
 * @param color   线条颜色
 */
export function drawEdge(
  ctx: CanvasRenderingContext2D,
  from: { x: number; y: number },
  to: { x: number; y: number },
  fromSide: Side,
  vp: Viewport,
  color = "#4a7aaa",
  selected = false
): void {
  // 场景 → 屏幕
  const x1 = from.x * vp.scale + vp.offsetX;
  const y1 = from.y * vp.scale + vp.offsetY;
  const x2 = to.x * vp.scale + vp.offsetX;
  const y2 = to.y * vp.scale + vp.offsetY;

  // 控制点:沿锚点方向外伸,长度取两点距离的 1/3(视觉最自然的经验值)
  const dist = Math.hypot(x2 - x1, y2 - y1);
  const k = Math.max(dist / 3, 40);
  const dir = sideVector(fromSide);   // {top:(0,-1), bottom:(0,1), left:(-1,0), right:(1,0)}
  const cx1 = x1 + dir.x * k, cy1 = y1 + dir.y * k;
  // 终点控制点:沿终点锚点的"反方向"外伸(线从外面插进来,不穿过节点)
  const cx2 = x2 - sideVector(to.opposite ?? "left").x * k; // 实际实现需传入 toSide
  const cy2 = y2;

  ctx.save();
  ctx.strokeStyle = selected ? "#00c6ff" : color;
  ctx.lineWidth = selected ? 2.5 : 1.8;
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.bezierCurveTo(cx1, cy1, cx2, cy2, x2, y2);
  ctx.stroke();

  // 末端箭头:沿曲线末端切线方向
  drawArrowHead(ctx, cx2, cy2, x2, y2, selected ? "#00c6ff" : color);
  ctx.restore();
}

/**
 * 箭头绘制:终点切线角度 = atan2(y2-cy2, x2-cx2)
 */
function drawArrowHead(
  ctx: CanvasRenderingContext2D,
  cx: number, cy: number,   // 第二控制点(决定末端切线)
  x: number, y: number,     // 终点
  color: string
): void {
  const angle = Math.atan2(y - cy, x - cx);
  const len = 10;
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(x - len * Math.cos(angle - 0.45), y - len * Math.sin(angle - 0.45));
  ctx.lineTo(x - len * Math.cos(angle + 0.45), y - len * Math.sin(angle + 0.45));
  ctx.closePath();
  ctx.fill();
}

⚠️ 上面代码里 to.opposite 是伪代码提示——实际签名应同时传入 toSide,控制点用 -sideVector(toSide) * k 外伸。照抄前先看懂,这正是"抄答案 = 白学"的意义。

5.3 连线命中检测(点到贝塞尔曲线的距离)

/**
 * 判断鼠标点是否点中了某条连线(用于选中连线删除)
 * 原理:把贝塞尔曲线离散成 40 段折线,逐段算点到线段距离,阈值 6px
 */
export function hitEdge(
  edge: FlowEdge,
  nodes: FlowNode[],
  p: { x: number; y: number },
  vp: Viewport
): boolean {
  const from = nodes.find((n) => n.id === edge.sourceId)!;
  const to = nodes.find((n) => n.id === edge.targetId)!;
  const a = anchorOf(from, edge.sourceSide);
  const b = anchorOf(to, edge.targetSide);

  // 离散采样贝塞尔曲线(De Casteljau 数值法,t 从 0 到 1 取 40 个点)
  const pts: { x: number; y: number }[] = [];
  for (let i = 0; i <= 40; i++) {
    const t = i / 40;
    pts.push(bezierPoint(a, a, b, b, t));   // 简化版:直线也要走同一接口
  }
  // 逐段测距
  for (let i = 0; i < pts.length - 1; i++) {
    if (distToSegment(p, pts[i], pts[i + 1]) < 6) return true;
  }
  return false;
}

/** 点到线段距离(Day 45 最近点法的推广) */
function distToSegment(
  p: { x: number; y: number },
  a: { x: number; y: number },
  b: { x: number; y: number }
): number {
  const dx = b.x - a.x, dy = b.y - a.y;
  const lenSq = dx * dx + dy * dy;
  if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
  let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
  t = Math.min(1, Math.max(0, t));
  return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}

六、交互层:拖拽 / 框选 / 连线 / 删除

6.1 状态机全图(Day 48 的升级版)

                 mousedown(节点)              mousedown(锚点)
    ┌──────┐    ┌──────────▶ drag ────┐      ┌─────────────▶ connect ───┐
    │ idle │────┘                     │      │                          │
    └──────┘    mousedown(空白)       │      │  mousedown(空白+空格)     │
        │       ┌──▶ marquee ─────────┤      └───────▶ pan ─────────────┤
        │       │                      │                                   │
        ▼       ▼                      ▼                                   ▼
      hover ◀── mouseup ◀──────────── mouseup ◀─────────────────────── mouseup
模式 触发 mousemove 行为 mouseup 行为
idle - 检测 hover 改光标 -
hover 移到节点上 保持高亮 -
drag 按在节点上 更新节点坐标 + 标记新旧脏区 结束,落定脏区
marquee 按在空白 画选框 + 实时高亮框内节点 selectInRect 落定
connect 按在锚点热区 画临时贝塞尔线跟随鼠标 命中节点则 addEdge
pan 空格+拖 更新 viewport,全层重绘 结束

6.2 连线模式实现(interact.ts 关键片段)

/**
 * 交互状态机:BOSS 战完整版
 */
type Mode =
  | { kind: "idle" }
  | { kind: "hover"; nodeId: string }
  | { kind: "drag"; ids: string[]; last: { x: number; y: number } }
  | { kind: "marquee"; origin: { x: number; y: number } }
  | { kind: "connect"; sourceId: string; from: { x: number; y: number } }
  | { kind: "pan"; last: { x: number; y: number } };

export class Interaction {
  private mode: Mode = { kind: "idle" };

  /** 鼠标按下:根据命中结果分派模式 */
  onMouseDown(e: MouseEvent, hit: HitResult): void {
    const p = this.toScene(e);

    // 空格按住 → 平移模式优先
    if (this.spaceHeld) {
      this.mode = { kind: "pan", last: { x: e.clientX, y: e.clientY } };
      return;
    }

    if (hit.type === "anchor") {
      // 命中锚点 → 进入连线模式
      this.mode = { kind: "connect", sourceId: hit.nodeId, from: hit.anchorPos };
    } else if (hit.type === "node") {
      // 命中节点 → 拖拽(若该节点已在框选集合中,整组一起拖)
      const ids = hit.node.selected
        ? this.model.graph.nodes.filter((n) => n.selected).map((n) => n.id)
        : [hit.node.id];
      if (!hit.node.selected) this.model.clearSelection();
      hit.node.selected = true;
      this.mode = { kind: "drag", ids, last: p };
    } else {
      // 空白 → 框选
      this.model.clearSelection();
      this.mode = { kind: "marquee", origin: p };
    }
  }

  /** 鼠标移动:各模式分派 */
  onMouseMove(e: MouseEvent): void {
    const p = this.toScene(e);
    switch (this.mode.kind) {
      case "drag": {
        // 计算位移增量,应用到本组所有节点
        const dx = p.x - this.mode.last.x;
        const dy = p.y - this.mode.last.y;
        for (const id of this.mode.ids) {
          const n = this.model.findNode(id);
          if (!n) continue;
          // 旧位置标脏(Day 46:移动的图元新旧位置都要标)
          this.dirty.mark(nodeBox(n));
          n.x += dx;
          n.y += dy;
          this.dirty.mark(nodeBox(n));
          // 关联连线所在区域也标脏
          this.markEdgesOf(id);
        }
        this.mode.last = p;
        break;
      }
      case "connect": {
        // 临时连线跟随:只重绘连线层的一小块区域(脏矩形同样适用!)
        this.renderer.drawTempEdge(this.mode.from, p);
        break;
      }
      case "marquee": {
        // 更新选框 + 实时预览选中
        this.renderer.drawMarquee(this.mode.origin, p);
        this.model.selectInRect(normRect(this.mode.origin, p));
        break;
      }
      case "pan": {
        // 直接改视口(屏幕像素位移,无需场景换算)
        this.viewport.offsetX += e.clientX - this.mode.last.x;
        this.viewport.offsetY += e.clientY - this.mode.last.y;
        this.mode.last = { x: e.clientX, y: e.clientY };
        this.renderer.setViewport(this.viewport);
        break;
      }
    }
  }

  /** 鼠标抬起:收尾各模式 */
  onMouseUp(e: MouseEvent, hit: HitResult): void {
    if (this.mode.kind === "connect" && hit.type === "node") {
      // 连线模式落在节点上 → 建立连线
      this.model.addEdge(this.mode.sourceId, hit.node.id);
      this.renderer.redrawAllEdges();
    }
    if (this.mode.kind === "marquee") {
      this.renderer.clearMarquee();
    }
    this.mode = { kind: "idle" };
  }

  /** 键盘:Delete 删除选中 */
  onKeyDown(e: KeyboardEvent): void {
    if (e.key === "Delete") {
      const selected = this.model.graph.nodes.filter((n) => n.selected);
      for (const n of selected) this.model.removeNode(n.id);
      this.renderer.redrawAllEdges();
      this.renderer.redrawAllNodes?.();
    }
  }

  /** 滚轮缩放:以鼠标点为锚点缩放(工业软件标配手感) */
  onWheel(e: WheelEvent): void {
    const factor = e.deltaY < 0 ? 1.1 : 0.9;
    const vp = this.viewport;
    const mouse = { x: e.offsetX, y: e.offsetY };
    // 关键公式:保持鼠标下的场景点不动
    // screen = scene * scale + offset → 解出新的 offset
    vp.offsetX = mouse.x - (mouse.x - vp.offsetX) * factor;
    vp.offsetY = mouse.y - (mouse.y - vp.offsetY) * factor;
    vp.scale *= factor;
    this.renderer.setViewport(vp);
  }

  /** 屏幕坐标 → 场景坐标(viewport 逆变换) */
  private toScene(e: MouseEvent): { x: number; y: number } {
    const vp = this.viewport;
    return {
      x: (e.offsetX - vp.offsetX) / vp.scale,
      y: (e.offsetY - vp.offsetY) / vp.scale,
    };
  }
}

🎯 缩放锚点公式是本周最值得背下来的一行:
newOffset = mouse - (mouse - oldOffset) * factor
推导:缩放前鼠标指向的场景点 s = (mouse - offset) / scale;要求缩放后同一场景点仍在鼠标下 → mouse = s * newScale + newOffset。两式联立即得。滚轮缩放"跟手"的全部秘密就这一行。


七、组合拳:把所有模块串成编辑器

7.1 HTML 骨架(index.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>流程图编辑器 · Canvas 进阶 BOSS 战</title>
  <style>
    /* 三层画布绝对定位堆叠:bg 在底,nodes 在顶(事件也绑在最顶层) */
    #stage { position: relative; width: 100vw; height: 100vh; background: #0d1421; }
    #stage canvas { position: absolute; inset: 0; }
    #nodes { cursor: default; }   /* 光标由 JS 按 hover 状态动态改 */
    #toolbar {
      position: fixed; top: 12px; left: 12px; z-index: 10;
      display: flex; gap: 8px; padding: 8px;
      background: rgba(26,39,64,0.9); border-radius: 8px;
      font: 12px sans-serif; color: #8fa3c0;
    }
  </style>
</head>
<body>
  <div id="stage">
    <canvas id="bg"></canvas>
    <canvas id="edges"></canvas>
    <canvas id="nodes"></canvas>
  </div>
  <div id="toolbar">
    <span>双击空白新建节点</span>
    <span>拖锚点连线</span>
    <span>空白处框选</span>
    <span>空格+拖拽平移</span>
    <span>滚轮缩放</span>
    <span>Delete 删除</span>
  </div>
  <script type="module" src="./src/main.ts"></script>
</body>
</html>

7.2 组装入口(main.ts)

import { GraphModel } from "./model";
import { Renderer } from "./render";
import { Interaction } from "./interact";
import type { HitResult } from "./types";

/**
 * 编辑器组装入口:连接数据模型、渲染器、交互状态机
 */
function bootstrap(): void {
  const stage = document.querySelector<HTMLElement>("#stage")!;
  const model = new GraphModel();
  const renderer = new Renderer(stage, model);
  renderer.init();

  const interact = new Interaction(model, renderer);

  // 模型变更 → 驱动渲染(数据与渲染解耦的关键一环)
  model.onChange = (dirty) => renderer.redrawNodesInDirty(dirty);

  // 事件绑定(全部绑在最顶层 nodes 画布上,事件路由由命中测试完成)
  const canvas = stage.querySelector<HTMLCanvasElement>("#nodes")!;

  canvas.addEventListener("mousedown", (e) => {
    interact.onMouseDown(e, hitTest(e, model, renderer));
  });
  canvas.addEventListener("mousemove", (e) => interact.onMouseMove(e));
  window.addEventListener("mouseup", (e) => {
    interact.onMouseUp(e, hitTest(e, model, renderer));
  });
  canvas.addEventListener("wheel", (e) => {
    e.preventDefault();           // 阻止页面滚动
    interact.onWheel(e);
  }, { passive: false });
  canvas.addEventListener("dblclick", (e) => {
    // 双击空白 → 新建节点(标签先用默认,双击节点改名留作进阶)
    const p = toScene(e);
    model.addNode(p.x - 70, p.y - 30, `节点 ${model.graph.nodes.length + 1}`);
  });
  window.addEventListener("keydown", (e) => interact.onKeyDown(e));

  // 初始演示数据:让打开就有内容可玩
  const a = model.addNode(120, 120, "开始");
  const b = model.addNode(360, 120, "数据采集");
  const c = model.addNode(360, 260, "告警判定");
  model.addEdge(a.id, b.id);
  model.addEdge(b.id, c.id);
  renderer.redrawAllEdges();
}

bootstrap();

/** 命中测试:锚点 > 节点 > 空白(优先级从细到粗) */
function hitTest(e: MouseEvent, model: GraphModel, renderer: Renderer): HitResult {
  // 先测锚点热区(半径 8px 的圆),再测节点 AABB —— 实现细节见自测挑战 T3
  return { type: "blank" };
}

💡 命中优先级是交互层的"隐形规则":锚点热区小但功能特殊,必须先测;节点其次;都 miss 才是空白。顺序反了会出现"想连线结果把节点拖走了"的诡异 bug。


八、性能压测与调优实录

8.1 压测脚本:一键生成 200 节点 300 连线

/**
 * 压测数据生成器:网格布局 200 个节点,随机连 300 条线
 * 仅用于开发环境性能测试,生产构建时通过条件编译剔除
 */
export function stressTest(model: GraphModel, n = 200, e = 300): void {
  const nodes: FlowNode[] = [];
  const cols = 20;
  for (let i = 0; i < n; i++) {
    nodes.push(
      model.addNode(
        80 + (i % cols) * 180,
        80 + Math.floor(i / cols) * 120,
        `设备 ${i + 1}`
      )
    );
  }
  // 随机连线(只往后连,避免全部挤在一处)
  for (let i = 0; i < e; i++) {
    const from = Math.floor(Math.random() * (n - 10));
    const to = from + 1 + Math.floor(Math.random() * 9);
    model.addEdge(nodes[from].id, nodes[to].id);
  }
}

8.2 压测结果对照(我的机器:i5-12400P,Chrome 126)

方案 拖拽单节点帧率 拖拽时 CPU
❌ 无优化:全量清屏重绘 31fps 92%
✅ 脏矩形 + 三层分离 58fps 24%
✅ + 连线层全量但节点层脏区 57fps 26%
✅ + 拖拽时连线只重绘关联区域 61fps 18%

结论

  1. 脏矩形对节点层收益最大(200 个节点只重绘 2 个)
  2. 连线层若每帧全量重绘 300 条贝塞尔 + 箭头,会成为新瓶颈——把重绘范围缩到"移动节点关联的连线包围盒"后突破 60fps
  3. 分层本身不减少绘制总量,减少的是每帧需要绘制的内容——这是两回事

8.3 Performance 面板取证方法

  1. F12 → Performance → 录制(Cmd/Ctrl + E)
  2. 拖拽节点 5 秒 → 停止
  3. 看 Frames 轨道:绿色条 = 达标帧,红色 = 掉帧
  4. 点开掉帧的 Main 轨道火焰图,看时间花在哪:
    • clearRect 占大头 → 脏区太大(检查是否误标了全屏)
    • bezierCurveTo + fill 占大头 → 连线层没做区域裁剪
    • measureText 占大头 → 忘了缓存 TextMetrics(Day 43 的教训)

九、常见坑点与修复

坑 1:拖拽残影——脏区只标了新位置

现象:节点拖动后原地留下半个残影。

原因:Day 46 讲过的经典错误——只标记新位置,旧位置的像素没清。

修复

// ❌ 错误:只标新位置
n.x += dx; n.y += dy;
this.dirty.mark(nodeBox(n));

// ✅ 正确:移动前后各标一次
this.dirty.mark(nodeBox(n));       // 旧位置
n.x += dx; n.y += dy;
this.dirty.mark(nodeBox(n));       // 新位置

坑 2:缩放后命中偏移——忘做逆变换

现象:视口缩放到 1.5 倍后,点击节点的判定位置和鼠标实际位置错开。

原因:命中测试用了屏幕坐标,而节点存的是场景坐标。

修复:所有交互入口先 toScene() 转换,命中测试全部在场景坐标系做;只有绘制时才做"场景 → 屏幕"的正向变换。一个方向变换,只在画的时候用;逆变换,只在交互的时候用。

坑 3:连线穿透节点

现象:连线从节点身体中间穿过去再弯到锚点。

原因:终点控制点方向错了——应该沿"终点锚点的外方向"外伸,让曲线从外面插入。

修复:控制点用 -sideVector(toSide) * k(取反方向),曲线会先在节点外部绕一下再对准锚点,视觉自然。

坑 4:删除节点后连线残留

现象:删掉节点,连着它的线还飘在画布上。

原因:只删了节点,没删关联连线,且连线层没重绘。

修复removeNode 里同步过滤 edges(见 3.2 代码),删除后调用 redrawAllEdges() 全量刷一次——删除是低频操作,全量重绘完全可接受,别过度优化。

坑 5:框选反向框没反应

现象:从右下往左上拖选框,选不中任何节点。

原因:选框的 x/y/w/h 没做规范化——反向拖拽时 w/h 是负数。

修复

/** 规范化矩形:无论拖拽方向,统一转成正宽高 */
function normRect(a: { x: number; y: number }, b: { x: number; y: number }) {
  return {
    x: Math.min(a.x, b.x),
    y: Math.min(a.y, b.y),
    w: Math.abs(a.x - b.x),
    h: Math.abs(a.y - b.y),
  };
}

十、自测挑战

T1 · 补全网格背景(15 分钟)

drawGrid() 留空了。要求:50px 间距的点阵网格,且平移缩放后网格跟着视口走(提示:网格起点 = offset % 50,间距 = 50 * scale)。

T2 · 节点类型差异化(20 分钟)

decision 节点画成菱形(用 lineTo 画四边形),terminal 画成圆角胶囊(arcTo 半径 = 高度一半)。注意:形状变了,命中测试和锚点位置也要跟着变——体会"渲染与逻辑耦合"的代价。

T3 · 实现完整 hitTest(25 分钟)

按"锚点(8px 圆)→ 节点 AABB → 空白"优先级实现命中测试,返回 HitResult 联合类型。锚点只在 hover 到节点时才显示(idle 状态不显示锚点,保持画面干净)。

T4 · 双击节点改文字(进阶,30 分钟)

双击节点弹出输入框(绝对定位的 <input> 覆盖在节点上方),回车确认后更新 label 并标脏重绘。注意输入框坐标是屏幕坐标,需要正向变换。

T5 · 撤销重做(进阶,45 分钟)

用 Day 35 画板的"快照栈"思路给编辑器加 Ctrl+Z / Ctrl+Y。思考:快照整个 Graph 简单但内存大;命令模式(记录每次操作的逆操作)省内存但实现复杂——你选哪个?为什么?(这正是面试"请设计一个编辑器撤销功能"的真题。)


十一、总结与第 7 周毕业

11.1 今天你把六天的知识拧成了一根绳

知识 在编辑器里的位置
Day 43 文本排版 节点标签的换行与省略号
Day 44 clip 选中节点的高亮蒙版
Day 45 碰撞检测 节点命中(AABB)+ 连线命中(点到线段距离)
Day 46 脏矩形 拖拽/框选时的局部重绘
Day 47 多层画布 bg / edges / nodes 三层分离
Day 48 交互状态机 drag / marquee / connect / pan 四模式

单独的知识是点,作品把它们连成了网——这就是 BOSS 战的意义:不是学新东西,是证明旧东西真的会了。

11.2 毕业检查清单

  • [ ] 编辑器 7 项交互全部可用
  • [ ] 200 节点压测 ≥ 55fps,有 Performance 截图
  • [ ] 代码分层清晰:model / render / edge / interact / viewport 各司其职
  • [ ] 推送到 GitHub,README 附 GIF 演示(用 [ScreenToGif] 录制)
  • [ ] 写一篇博客《我用原生 Canvas 写了个流程图编辑器》——重点讲脏矩形优化前后的性能对比数据,这是全文最有说服力的部分

11.3 下周预告:第 8 周(Day 50-56)图表与 ECharts

Canvas 手写能力已经闭环,下周进入工程效率层:

  • Day 50-51:手写柱状图 / 折线图(坐标轴、刻度、动画——理解 ECharts 在帮你做什么)
  • Day 52-53:ECharts 核心概念(option 结构、dataset、组件定位)
  • Day 54:ECharts 性能优化(大数据量、canvas 渲染器调优)
  • Day 55:工业大屏常用图表(仪表盘、雷达、关系图)
  • Day 56:BOSS 战——工厂设备监控面板(本周作品直接成为阶段 2 大屏项目的图表模块)

🧭 为什么先手写再学库:下周前两天手写柱状图不是为了造轮子,是为了让你打开 ECharts 源码级调试时看得懂它每一步在干什么——库是杠杆,前提是你知道杠杆撬的是什么。


第 7 周毕业。打开编辑器,拖一下那个节点——60fps 的丝滑背后,是你这七天每一行代码的功劳。

评论