🎯 Canvas 箭头绘制算法(附完整源码)

摘要:在可视化标注工具中,箭头是最常见的图形元素之一。如何绘制一个方向准确、大小自适应、支持缩放的箭头?本文从向量数学出发,带你实现一个工业级的箭头绘制算法。

作者 :红波
标签:#Canvas #Konva #可视化 #算法 #前端图形学


📖 前言

最近在做智驾数据标注平台时,遇到了一个看似简单实则坑多的需求:在 Canvas 上绘制带箭头的线段

你可能会说:"这不就是画个三角形吗?" 但实际需要考虑:

  • ✅ 箭头方向要跟随线段角度自动旋转
  • ✅ 箭头大小要适配线宽和画布缩放
  • ✅ 性能要好,支持大量箭头同时渲染
  • ✅ 数学上要精确,不能歪歪扭扭

今天就来分享一套经过生产环境验证的箭头绘制方案。


🎨 最终效果

想象一下这样的场景:

  • 绘制折线时,终点自动显示箭头
  • 缩放画布时,箭头大小保持视觉一致
  • 改变线宽时,箭头大小联动变化
  • 任意角度的线段,箭头方向始终准确

🧮 核心原理:向量数学

1. 问题拆解

要绘制箭头,本质是在终点位置画一个等腰三角形,需要解决:

  1. 如何确定三角形的三个顶点?
  2. 如何让三角形朝向正确的方向?
  3. 如何适配缩放和线宽?

2. 向量基础

假设线段从点 A 到点 B:

css 复制代码
A(x1, y1) ---------> B(x2, y2)

步骤 1:计算方向向量

typescript 复制代码
const dx = x2 - x1
const dy = y2 - y1
const len = Math.sqrt(dx * dx + dy * dy)

// 单位化(归一化)
const ux = dx / len  // 方向向量的 X 分量
const uy = dy / len  // 方向向量的 Y 分量

步骤 2:计算法向量(垂直方向)

typescript 复制代码
// 将方向向量逆时针旋转 90 度
const nx = -uy
const ny = ux

步骤 3:构建三角形

css 复制代码
           B (终点)
            ▲
            │
      左翼  │  右翼
        \   │   /
         \  │  /
          \ │ /
           ─●─  ← 底边中心(从终点后退一段距离)

💻 完整代码实现

1. 箭头类封装

typescript 复制代码
import Konva from 'konva'

interface Point {
  x: number
  y: number
}

interface ArrowConfig {
  points: Point[]          // 线段点集
  strokeWidth?: number     // 线宽
  strokeColor?: string     // 颜色
  closed?: boolean         // 是否闭合路径
  visible?: boolean        // 是否可见
  opacity?: number         // 透明度
  stageScale?: number      // 画布缩放比例
}

export class ArrowRenderer {
  private arrow: Konva.Line
  private container: Konva.Layer | Konva.Stage

  constructor(container: Konva.Layer | Konva.Stage) {
    this.container = container
    
    // 初始化箭头对象(复用,避免频繁创建)
    this.arrow = new Konva.Line({
      points: [],
      listening: false,    // 不响应事件,提升性能
      visible: false,
      closed: false,
      tension: 0,          // 不使用贝塞尔曲线
    })
    
    container.add(this.arrow)
  }

  /**
   * 更新箭头位置和样式
   */
  update(config: ArrowConfig): void {
    const {
      points,
      strokeWidth = 1,
      strokeColor = '#000000',
      closed = false,
      visible = true,
      opacity = 1,
      stageScale = 1,
    } = config

    // 数据校验
    if (!points || points.length < 2 || !visible) {
      this.arrow.visible(false)
      return
    }

    // 确定箭头的起始点和终点
    let from: Point
    let to: Point
    
    if (closed) {
      // 闭合路径:箭头在第一条边(点0→点1)
      from = points[0]
      to = points[1]
    } else {
      // 开放路径:箭头在最后一条边(倒数第二点→最后一点)
      from = points[points.length - 2]
      to = points[points.length - 1]
    }

    // 计算向量
    const dx = to.x - from.x
    const dy = to.y - from.y
    const len = Math.hypot(dx, dy)

    // 线段太短,不显示箭头
    if (len < 1e-3) {
      this.arrow.visible(false)
      return
    }

    // 单位方向向量
    const ux = dx / len
    const uy = dy / len

    // 计算箭头尺寸(适配缩放和线宽)
    const baseWidth = strokeWidth
    const arrowBaseSize = Math.max(16, baseWidth * 4) // 最小 16px,最大为线宽的 4 倍
    const pointerSize = arrowBaseSize / stageScale    // 反缩放

    // 箭头尖端位置(就是终点)
    const tipX = to.x
    const tipY = to.y

    // 底边中心位置(从终点沿反方向后退 pointerSize)
    const backX = tipX - ux * pointerSize
    const backY = tipY - uy * pointerSize

    // 法向量(垂直方向),控制箭头宽度
    const k = 0.6 // 箭头宽高比系数
    const rx = -uy * pointerSize * k
    const ry = ux * pointerSize * k

    // 计算左右翼顶点
    const leftX = backX + rx
    const leftY = backY + ry
    const rightX = backX - rx
    const rightY = backY - ry

    // 更新箭头图形
    this.arrow.setAttrs({
      points: [leftX, leftY, tipX, tipY, rightX, rightY],
      stroke: strokeColor,
      strokeWidth: baseWidth / stageScale, // 线宽也要反缩放
      opacity,
      visible: true,
    })
  }

  /**
   * 销毁箭头
   */
  destroy(): void {
    this.arrow.destroy()
  }
}

2. Vue 3 组件封装

vue 复制代码
<template>
  <div ref="containerRef" class="canvas-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import Konva from 'konva'
import { ArrowRenderer } from './ArrowRenderer'

interface Props {
  points: Array<{ x: number; y: number }>
  strokeWidth?: number
  strokeColor?: string
  scale?: number
}

const props = withDefaults(defineProps<Props>(), {
  strokeWidth: 2,
  strokeColor: '#1890ff',
  scale: 1,
})

const containerRef = ref<HTMLElement>()
let stage: Konva.Stage
let layer: Konva.Layer
let arrowRenderer: ArrowRenderer
let line: Konva.Line

onMounted(() => {
  if (!containerRef.value) return

  // 初始化 Konva Stage
  stage = new Konva.Stage({
    container: containerRef.value,
    width: 800,
    height: 600,
  })

  layer = new Konva.Layer()
  stage.add(layer)

  // 创建线段
  line = new Konva.Line({
    points: [],
    stroke: props.strokeColor,
    strokeWidth: props.strokeWidth,
    lineCap: 'round',
    lineJoin: 'round',
  })
  layer.add(line)

  // 创建箭头渲染器
  arrowRenderer = new ArrowRenderer(layer)

  // 绘制
  draw()
})

onUnmounted(() => {
  arrowRenderer?.destroy()
  stage?.destroy()
})

// 监听属性变化
watch(
  () => [props.points, props.strokeWidth, props.strokeColor, props.scale],
  () => draw(),
  { deep: true }
)

function draw() {
  if (!line || !arrowRenderer) return

  // 更新线段
  const flatPoints = props.points.flatMap((p) => [p.x, p.y])
  line.setAttrs({
    points: flatPoints,
    stroke: props.strokeColor,
    strokeWidth: props.strokeWidth / props.scale,
  })

  // 更新箭头
  arrowRenderer.update({
    points: props.points,
    strokeWidth: props.strokeWidth,
    strokeColor: props.strokeColor,
    stageScale: props.scale,
    closed: false,
  })

  layer.batchDraw()
}
</script>

<style scoped>
.canvas-container {
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}
</style>

3. 使用示例

vue 复制代码
<template>
  <div>
    <ArrowLine
      :points="linePoints"
      :stroke-width="3"
      stroke-color="#ff4d4f"
      :scale="canvasScale"
    />
    
    <button @click="addPoint">添加点</button>
    <button @click="zoomIn">放大</button>
    <button @click="zoomOut">缩小</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ArrowLine from './ArrowLine.vue'

const linePoints = ref([
  { x: 100, y: 100 },
  { x: 200, y: 150 },
  { x: 300, y: 100 },
  { x: 400, y: 200 },
])

const canvasScale = ref(1)

function addPoint() {
  const lastPoint = linePoints.value[linePoints.value.length - 1]
  linePoints.value.push({
    x: lastPoint.x + 50,
    y: lastPoint.y + Math.random() * 100 - 50,
  })
}

function zoomIn() {
  canvasScale.value *= 1.2
}

function zoomOut() {
  canvasScale.value /= 1.2
}
</script>

🔍 关键代码解析

1. 为什么要反缩放?

typescript 复制代码
const pointerSize = arrowBaseSize / stageScale
const strokeWidth: baseWidth / stageScale

原因 :当画布放大 2 倍时,如果不反缩放,箭头和线宽也会跟着放大 2 倍,导致视觉上不协调。通过除以缩放比例,可以保持视觉尺寸恒定

2. 法向量的妙用

typescript 复制代码
const rx = -uy * pointerSize * k
const ry = ux * pointerSize * k

这里利用了向量旋转 90 度的性质:

  • 原向量:(ux, uy)
  • 逆时针旋转 90 度:(-uy, ux)

这样就能得到垂直于线段方向的向量,用于计算箭头左右翼的位置。

3. 性能优化技巧

typescript 复制代码
listening: false  // 不响应事件

箭头通常只是视觉指示,不需要交互。设置为 false 可以:

  • 减少事件监听器数量
  • 提升渲染性能
  • 避免不必要的事件冒泡

📊 实际应用场景

这套算法已应用于:

  • 智驾数据标注平台:标注车辆轨迹、行驶方向
  • 机器人路径规划:可视化机器人运动轨迹
  • 流程图编辑器:连接线箭头指示
  • 地图应用:路线导航箭头

🎯 总结

实现一个完美的箭头绘制功能,需要考虑:

  1. 数学准确性:向量运算要精确,方向不能偏
  2. 视觉一致性:适配缩放、线宽变化
  3. 性能优化:对象复用、批量绘制
  4. 代码可维护性:封装成独立模块,便于复用

希望这篇文章能帮助你解决箭头绘制的难题!如果对你有帮助,记得点赞👍 + 关注哦~


📚 相关资源


关于作者:红波,专注智驾、机器人标注工具和可视化开发,技术栈:TypeScript/Vue/WebGL/Three.js/Go/Rust。欢迎交流~


如果你觉得这篇文章有帮助,欢迎转发给更多需要的朋友! 💖

相关推荐
拖拉斯旋风1 小时前
从零到一:用 Node.js + LangChain + Milvus 打造《天龙八部》专属 RAG 问答机器人
前端
不可能的是1 小时前
彻底搞懂 Module Federation(中下):MF 模块加载(下)
前端·webpack
独特的账号1 小时前
前端浏览器插件的开发一步搞定
前端·react.js
李剑一1 小时前
超实用!数字孪生 Cesium 园区 3D 模型加载,一次学会的保姆级教程
前端·vue.js·cesium
游魂Andy1 小时前
零成本搭建专属AI助手:OpenClaw永久免费部署全攻略
前端·人工智能·ai编程
wuhen_n2 小时前
动态组件与 keep-alive:如何优化页面切换体验与性能?
前端·javascript·vue.js
wuhen_n2 小时前
插槽的作用域与分发:如何让组件更灵活、可定制?
前端·javascript·vue.js
IT_陈寒2 小时前
Vite凭什么比Webpack快10倍?5个核心优化原理大揭秘
前端·人工智能·后端
gyx_这个杀手不太冷静2 小时前
OpenCode 进阶使用指南(第三章:MCP 集成)
前端·ai编程