web-map/docs/Canvas绘制技术详解.md

13 KiB
Raw Permalink Blame History

Canvas 2D 绘制技术详解

📖 概述

本文档详细分析场景编辑器中的自定义绘制函数,这些函数基于 HTML5 Canvas 2D API 在浏览器页面上绘制各种图形元素(点位、路线、区域、机器人)。

🎯 核心技术栈

1. HTML5 Canvas 2D API

  • 技术原理Canvas 是 HTML5 提供的位图绘制 API
  • 绘制方式:使用 JavaScript 在 Canvas 画布上逐像素绘制
  • 坐标系统:左上角为原点 (0,0)X轴向右Y轴向下
  • 绘制上下文:通过 CanvasRenderingContext2D 对象进行所有绘制操作

2. Meta2D 引擎集成

  • 自定义绘制:通过 registerCanvasDraw() 注册自定义绘制函数
  • 图形对象:每个绘制函数接收 MapPen 对象,包含图形的所有属性
  • 渲染时机:引擎在每次重绘时自动调用对应的绘制函数

🎨 绘制函数详细分析

1. 点位绘制函数 drawPoint()

函数签名和参数

function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void;

代码逐行分析

// 1. 获取全局主题配置
const theme = sTheme.editor;

分析:从全局主题服务获取编辑器主题配置,用于确定颜色、样式等视觉属性。

// 2. 从计算属性中提取绘制参数
const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};

分析

  • active:图形是否处于选中状态
  • iconSize:图标大小,重命名为 r(半径)
  • fontSize/lineHeight/fontFamily:文本绘制参数
// 3. 获取世界坐标系下的矩形区域
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};

分析Meta2D 引擎会自动计算图形在世界坐标系下的实际位置和大小。

// 4. 获取业务属性
const { type } = pen.point ?? {};
const { label = '' } = pen ?? {};
// 5. 保存当前画布状态
ctx.save();

分析save() 保存当前的绘制状态(变换矩阵、样式等),避免影响其他图形。

小点位绘制类型1-9

switch (type) {
  case MapPointType.普通点:
  case MapPointType.等待点:
  case MapPointType.避让点:
  case MapPointType.临时避让点:
    // 绘制圆角菱形
    ctx.beginPath();
    ctx.moveTo(x + w / 2 - r, y + r);
    ctx.arcTo(x + w / 2, y, x + w - r, y + h / 2 - r, r);
    ctx.arcTo(x + w, y + h / 2, x + w / 2 + r, y + h - r, r);
    ctx.arcTo(x + w / 2, y + h, x + r, y + h / 2 + r, r);
    ctx.arcTo(x, y + h / 2, x + r, y + h / 2 - r, r);
    ctx.closePath();

分析

  • beginPath():开始新的绘制路径
  • moveTo():移动画笔到起始点
  • arcTo():绘制圆弧连接线,创建圆角效果
  • closePath():闭合路径形成完整图形
// 填充背景色
ctx.fillStyle = get(theme, `point-s.fill-${type}`) ?? '';
ctx.fill();

// 绘制边框
ctx.strokeStyle = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke') ?? '';

分析:根据点位类型和激活状态设置不同的填充色和边框色。

临时避让点特殊标记

if (type === MapPointType.临时避让点) {
  ctx.lineCap = 'round'; // 设置线条端点为圆形
  ctx.beginPath();
  // 绘制8个短线标记形成放射状效果
  ctx.moveTo(x + 0.66 * r, y + h / 2 - 0.66 * r);
  ctx.lineTo(x + r, y + h / 2 - r);
  // ... 其他7个方向的短线
}

分析在菱形的8个方向绘制短线形成特殊的视觉标识。

大点位绘制类型11+

case MapPointType.电梯点:
case MapPointType.自动门点:
case MapPointType.充电点:
case MapPointType.停靠点:
case MapPointType.动作点:
case MapPointType.禁行点:
  ctx.roundRect(x, y, w, h, r);  // 绘制圆角矩形
  ctx.strokeStyle = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? '';
  ctx.stroke();

分析:大点位使用圆角矩形,通过 roundRect() API 一次性绘制。

文本标签绘制

// 设置文本样式
ctx.fillStyle = get(theme, 'color') ?? '';
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center'; // 水平居中
ctx.textBaseline = 'top'; // 垂直顶部对齐

// 在点位上方绘制标签
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
// 恢复画布状态
ctx.restore();

2. 路线绘制函数 drawLine()

核心绘制逻辑

// 1. 获取路线的两个端点坐标
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
// 2. 获取路线属性
const { type, direction = 1, pass = 0, c1, c2 } = pen.route ?? {};
const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {}; // 控制点1偏移
const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {}; // 控制点2偏移

路线类型绘制

ctx.moveTo(x1, y1); // 移动到起点
switch (type) {
  case MapRouteType.直线:
    ctx.lineTo(x2, y2); // 直接连线到终点
    break;

  case MapRouteType.二阶贝塞尔曲线:
    // 使用一个控制点绘制曲线
    ctx.quadraticCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2, y2);
    break;

  case MapRouteType.三阶贝塞尔曲线:
    // 使用两个控制点绘制更复杂的曲线
    ctx.bezierCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2 + dx2 * s, y2 + dy2 * s, x2, y2);
    break;
}

贝塞尔曲线原理

  • 二阶贝塞尔曲线:由起点、一个控制点、终点定义的曲线
  • 三阶贝塞尔曲线:由起点、两个控制点、终点定义的更灵活曲线
  • 数学公式:基于参数方程计算曲线上的每个点

禁行路线绘制

if (pass === MapRoutePassType.禁行) {
  ctx.setLineDash([s * 5]); // 设置虚线样式
}
ctx.stroke(); // 绘制路线

方向箭头绘制

// 1. 计算箭头角度
let r = (() => {
  switch (type) {
    case MapRouteType.直线:
      return Math.atan2(y2 - y1, x2 - x1); // 直线的角度
    case MapRouteType.二阶贝塞尔曲线:
      // 根据控制点计算切线角度
      return direction < 0 ? Math.atan2(dy1 * s, dx1 * s) : Math.atan2(y2 - y1 - dy1 * s, x2 - x1 - dx1 * s);
    // ...
  }
})();
// 2. 移动坐标系到箭头位置
if (direction < 0) {
  ctx.translate(x1, y1); // 反向箭头在起点
} else {
  ctx.translate(x2, y2); // 正向箭头在终点
  r += Math.PI; // 旋转180度
}

// 3. 绘制箭头(两条线段形成尖角)
ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10);

箭头绘制原理

  • 使用三角函数计算箭头两条边的端点
  • Math.PI / 5 (36度) 是箭头的张开角度
  • 通过坐标变换将箭头定位到正确位置和角度

3. 区域绘制函数 drawArea()

矩形区域绘制

// 1. 绘制填充矩形
ctx.rect(x, y, w, h); // 定义矩形路径
ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; // 设置填充色
ctx.fill(); // 填充矩形

// 2. 绘制边框
ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? '';
ctx.stroke(); // 绘制边框

区域标签

// 在区域上方居中显示标签
ctx.fillStyle = get(theme, 'color') ?? '';
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);

4. 机器人绘制函数 drawRobot()

机器人本体绘制

const ox = x + w / 2; // 机器人中心X坐标
const oy = y + h / 2; // 机器人中心Y坐标

// 绘制椭圆形机器人
ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fillStyle = get(theme, 'robot.fill') ?? '';
ctx.fill();
ctx.strokeStyle = get(theme, 'robot.stroke') ?? '';
ctx.stroke();

路径轨迹绘制

if (path?.length) {
  // 设置路径样式
  ctx.strokeStyle = get(theme, 'robot.line') ?? '';
  ctx.lineCap = 'round'; // 圆形线帽
  ctx.lineWidth = s * 4; // 粗线条
  ctx.setLineDash([s * 5, s * 10]); // 虚线样式

  // 坐标变换:移动到机器人中心并旋转
  ctx.translate(ox, oy);
  ctx.rotate((-deg * Math.PI) / 180);

  // 绘制路径线条
  ctx.beginPath();
  ctx.moveTo(0, 0);
  path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
  ctx.stroke();
}

路径终点箭头

// 计算路径最后两个点的方向
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; // 最后一个点
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; // 倒数第二个点
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;

// 在路径终点绘制箭头
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();

🔧 Canvas 2D API 核心方法说明

路径绘制方法

方法 功能 示例
beginPath() 开始新的绘制路径 每次绘制新图形前调用
moveTo(x, y) 移动画笔到指定位置 设置绘制起点
lineTo(x, y) 画直线到指定位置 绘制线段
arcTo(x1, y1, x2, y2, r) 绘制圆弧连接 创建圆角效果
quadraticCurveTo(cpx, cpy, x, y) 二阶贝塞尔曲线 简单曲线
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 三阶贝塞尔曲线 复杂曲线
rect(x, y, w, h) 矩形路径 绘制矩形
ellipse(x, y, rx, ry, rotation, start, end) 椭圆路径 绘制椭圆/圆形
closePath() 闭合当前路径 连接起点和终点

样式设置方法

属性/方法 功能 示例
fillStyle 设置填充颜色 ctx.fillStyle = '#ff0000'
strokeStyle 设置描边颜色 ctx.strokeStyle = '#0000ff'
lineWidth 设置线条宽度 ctx.lineWidth = 2
lineCap 设置线条端点样式 'round', 'square', 'butt'
setLineDash([...]) 设置虚线样式 ctx.setLineDash([5, 5])

变换方法

方法 功能 说明
translate(x, y) 平移坐标系 移动原点位置
rotate(angle) 旋转坐标系 按弧度旋转
setTransform(a, b, c, d, e, f) 重置变换矩阵 恢复标准坐标系

状态管理方法

方法 功能 说明
save() 保存当前状态 压入状态栈
restore() 恢复之前状态 弹出状态栈

🎯 绘制流程总结

1. 标准绘制流程

function customDraw(ctx: CanvasRenderingContext2D, pen: MapPen): void {
  // 1. 保存画布状态
  ctx.save();

  // 2. 提取绘制参数
  const { x, y, width, height } = pen.calculative?.worldRect ?? {};

  // 3. 设置样式属性
  ctx.fillStyle = '填充色';
  ctx.strokeStyle = '边框色';

  // 4. 创建绘制路径
  ctx.beginPath();
  // ... 具体绘制操作

  // 5. 执行绘制
  ctx.fill(); // 填充
  ctx.stroke(); // 描边

  // 6. 恢复画布状态
  ctx.restore();
}

2. 性能优化要点

  • 状态管理:及时调用 save()restore() 避免状态污染
  • 路径复用:合理使用 beginPath() 清除之前的路径
  • 批量绘制:同类型图形可以合并绘制操作
  • 避免重复计算:缓存复杂的数学计算结果

📊 技术优势

1. Canvas 2D 的优势

  • 高性能:直接操作像素,渲染速度快
  • 灵活性:可以绘制任意复杂的图形
  • 交互性:支持鼠标事件检测和处理
  • 兼容性:现代浏览器完全支持

2. 自定义绘制的优势

  • 个性化:完全定制化的视觉效果
  • 主题支持:动态切换颜色主题
  • 状态反馈:不同状态显示不同样式
  • 扩展性:易于添加新的图形类型

🔚 结语

本场景编辑器通过 Canvas 2D API 实现了丰富的图形绘制功能,每个绘制函数都经过精心设计,既保证了视觉效果,又兼顾了性能表现。理解这些绘制原理对于进一步扩展和优化编辑器功能具有重要意义。