摘要:在可视化标注工具中,箭头是最常见的图形元素之一。如何绘制一个方向准确、大小自适应、支持缩放的箭头?本文从向量数学出发,带你实现一个工业级的箭头绘制算法。
作者 :红波
标签:#Canvas #Konva #可视化 #算法 #前端图形学
📖 前言
最近在做智驾数据标注平台时,遇到了一个看似简单实则坑多的需求:在 Canvas 上绘制带箭头的线段。
你可能会说:"这不就是画个三角形吗?" 但实际需要考虑:
- ✅ 箭头方向要跟随线段角度自动旋转
- ✅ 箭头大小要适配线宽和画布缩放
- ✅ 性能要好,支持大量箭头同时渲染
- ✅ 数学上要精确,不能歪歪扭扭
今天就来分享一套经过生产环境验证的箭头绘制方案。
🎨 最终效果

想象一下这样的场景:
- 绘制折线时,终点自动显示箭头
- 缩放画布时,箭头大小保持视觉一致
- 改变线宽时,箭头大小联动变化
- 任意角度的线段,箭头方向始终准确
🧮 核心原理:向量数学
1. 问题拆解
要绘制箭头,本质是在终点位置画一个等腰三角形,需要解决:
- 如何确定三角形的三个顶点?
- 如何让三角形朝向正确的方向?
- 如何适配缩放和线宽?
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 可以:
- 减少事件监听器数量
- 提升渲染性能
- 避免不必要的事件冒泡
📊 实际应用场景
这套算法已应用于:
- ✅ 智驾数据标注平台:标注车辆轨迹、行驶方向
- ✅ 机器人路径规划:可视化机器人运动轨迹
- ✅ 流程图编辑器:连接线箭头指示
- ✅ 地图应用:路线导航箭头
🎯 总结
实现一个完美的箭头绘制功能,需要考虑:
- 数学准确性:向量运算要精确,方向不能偏
- 视觉一致性:适配缩放、线宽变化
- 性能优化:对象复用、批量绘制
- 代码可维护性:封装成独立模块,便于复用
希望这篇文章能帮助你解决箭头绘制的难题!如果对你有帮助,记得点赞👍 + 关注哦~
📚 相关资源
关于作者:红波,专注智驾、机器人标注工具和可视化开发,技术栈:TypeScript/Vue/WebGL/Three.js/Go/Rust。欢迎交流~
如果你觉得这篇文章有帮助,欢迎转发给更多需要的朋友! 💖