手写 Canvas 无人机姿态指示器:从 Vue 组件到 Astro 交互演示
起因:复盘视频里,缺一张”会说话的图”
前段时间复盘一次无人机作业的飞行视频,遇到一个尴尬的问题:视频画面里的 OSD(屏幕叠加显示)数据滚得太快,光靠肉眼盯着不断跳动的数字,根本没法判断某个瞬间机体到底是”前倾了 8 度”还是”侧倾了 20 度”。事后翻遥测日志,拿到的是一列列的 pitch、roll 数值——数据都在,但缺乏一个直观、固定的图表,让它们”开口说话”。
航空领域的答案是现成的:姿态指示器(Attitude Indicator),也叫”人工地平仪”。上半部分是天空、下半部分是大地,中间一条地平线;机头抬起,天地线就往下走;机体侧倾,整个天地跟着转。飞行员扫一眼就知道姿态,这正是我想要的展示形式。
于是我先去找现成的轮子,转了一圈之后,决定自己用 HTML5 Canvas 写一个。这篇文章就是这次开发的完整复盘——从需求分析、数学推导,到把一个为 Vue 设计的组件适配进 Astro 静态博客、让它在文章里实时跑起来。
一、需求分析:为什么不用现成的轮子
先看市面上能找到的方案:
| 方案 | 问题 |
|---|---|
| 通用图表库的仪表盘(如 ECharts gauge) | 面向”速度表 / 水位表”这类扇形指针模型,没有”上天下地”的姿态球概念;想改成人工地平仪,等于在和图表库的抽象作斗争 |
| 各类姿态仪 / 仪表盘组件库 | 大多基于 SVG 实现,配色、刻度密度、尺寸比例深度耦合内部实现;改样式经常要覆盖十几个 CSS 变量甚至 fork 源码 |
| 视频后期的 OSD 工具 | 面向剪辑流程,不可编程,无法接入自己的遥测数据管道 |
归纳下来,我的需求其实非常明确:
- 视觉模型必须是人工地平仪:天空 / 大地分离、俯仰平移、翻滚倾斜,缺一不可;
- 深度可定制:配色、刻度、尺寸全部听我的,因为要嵌进自己的复盘工具 UI 里;
- 数据驱动:
pitch、roll两个 prop 变化就重绘,方便对接时序数据回放; - 轻量:一个单文件组件,不引入图表库这种重型依赖;
- 跨框架可移植:绘制逻辑与框架解耦,将来搬到别处也能用。
衡量之后结论很清晰:这个仪表盘的绘制指令总共不超过 30 条 Canvas 调用,为它引入一个图表库属于”高射炮打蚊子”,而 SVG 方案的定制痛点又恰好是我最在意的。自己用 Canvas 画,是成本和自由度都最优的解。
二、核心实现思路
组件最终收敛成一个 FlightIndicator.vue,核心只做一件事:接收三个姿态角,把整个仪表盘一次性画出来。下面拆解几个关键设计。
2.1 归一化坐标:用 0~1 描述整个仪表盘
Canvas 绘制的第一个问题是:画布多大?嵌入不同容器时尺寸会变。如果用绝对像素写死每个元素的位置,换一个尺寸就得全部重算。
解法是把所有几何信息归一化到 0~1 的相对坐标系,绘制时再乘以画布实际宽高:
const canvasData = {
pitch: { x: 0, y: 0.15 }, // PITCH 读数区位置
roll: { x: 0.79, y: 0.15 }, // ROLL 读数区位置
circle: { x: 0.5, y: 0.5, r: 0.4 }, // 姿态球:圆心与半径
markLine: [ /* 每条刻度线的 x/y/w 都是比例 */ ],
}
绘制时统一换算:canvas.width * item.x。这样无论画布是 200px 还是 600px,仪表盘都按比例自适应缩放,一处计算,处处生效。这是整个组件最重要的一个决定。
2.2 天空与大地:同一个圆的两种画法
ctx.arc() 的第四、五个参数分别是起始角和旋转方向。屏幕坐标系的 y 轴向下,这一点必须时刻牢记:
// 上半圆(天空):从 π(9 点钟方向)出发,顺时针经 12 点钟走到 0(3 点钟方向)
ctx.arc(cx, cy, r, Math.PI, 0, false)
// 下半圆(大地):同样从 π 到 0,但逆时针,路径经过 6 点钟方向
ctx.arc(cx, cy, r, Math.PI, 0, true)
同一条起止角,方向参数一翻转,扫过的就是互补的两个半圆。先填上半圆的蓝色,再填下半圆的棕色,“上天下地”的底子就有了。判断技巧:别去纠结屏幕坐标系里”顺时针”到底往哪转,只要记住 y 轴向下,sin 为正的方向在屏幕下方,画之前在脑中过一遍路径经过顶点还是底点即可。
2.3 刻度线:数据驱动,而非循环生成
俯仰刻度(+40° 到 -40°,每 10° 一条长线、5° 一条短线)没有用 for 循环计算坐标,而是逐条显式声明在 markLine 数组里:
markLine: [
{ x: 0.42, y: 0.18, w: 0.16, text: 40 }, // 长刻度 + 数值
{ x: 0.46, y: 0.22, w: 0.08, text: '' }, // 短刻度,无数值
{ x: 0.42, y: 0.26, w: 0.16, text: 30 },
// ...
]
看起来”笨”,实则有两个好处:一是所见即所得,想微调某条刻度的长度、去掉某个数值,直接改数组就行;二是仪表盘这类静态刻度天然适合配置化。如果刻度规则统一,也可以程序化生成——两种方式没有高下之分,看刻度是否需要”逐条定制”。
2.4 Pitch:俯仰就是垂直平移
俯仰角的映射最直觉:机头抬起(pitch 为正),机身符号上移。
const droneCenter = { x: 0.5, y: 0.5 - props.pitch * 0.008 }
系数 0.008 的含义是”每 1° 对应画布高度的 0.8%“。俯仰 ±40° 时,机身中心移动 ±32% 高度,恰好压在刻度带边缘——系数就是按刻度范围反推出来的,让动态范围和静态刻度对齐。
顺带一提:真实姿态仪是”天地移动、飞机固定”(飞机符号永远钉在仪表中心),这里复刻的 UI 采用的是”天地固定、机身平移”的简化模型,数据语义相同,实现更直接。
2.5 Roll:角度 → 弧度 → 正切换算高度差
翻滚角是最有数学味的一步。机体侧倾 θ 度后,机身线(左右两片机翼的连线)从水平变成倾斜,两端的高度差可以用正切直接算出来:
// 角度转弧度;负号用于统一屏幕坐标系(y 轴向下)
const dRadius = (-props.roll / 180) * Math.PI
// 高度差 = tan(θ) × 半翼展
const dRoll = Math.tan(dRadius) * canvas.height * 0.3
const droneStartY = canvas.height * droneCenter.y + dRoll - 1 // 左翼
const droneEndY = canvas.height * droneCenter.y - dRoll - 1 // 右翼
也就是 Δy = L · tan(θ),其中 L 是半翼展(画布高度的 30%)。左端加上高度差、右端减去高度差,一条 moveTo + lineTo 就画出了倾斜的机身线。
至于机身中央那个代表”机背”的小半圆,旋转的技巧是把起止角整体偏移:
ctx.arc(cx, cy, r, Math.PI - dRadius, -dRadius, true)
起止角同时减去 dRadius,效果等价于把整个半圆旋转了 θ 度——不需要 ctx.rotate() 变换坐标系,小半圆就和机身线保持同一个倾斜角,视觉上浑然一体。
2.6 响应式:watch 触发全量重绘
最后是数据流。姿态数据通常来自回放或实时推送,节流后频率不高(几十 Hz 以内),所以采用最朴素的策略:
watch(() => [props.pitch, props.roll], () => draw())
props 一变,整幅画布全量重绘。有人会问要不要做脏区标记、局部重绘?我的答案是:不要。这个仪表盘一次绘制的全部指令不超过 30 条 Canvas 调用,耗时在 0.1ms 量级,全量重绘绰绰有余,而局部重绘引入的复杂度(坐标换算、层叠管理)远超收益。性能优化的第一原则是先测量,再决定要不要优化。
三、代码解析:把 draw() 摊开来看
有了上面的思路,完整源码就只剩”填空”了。组件结构一目了然:
<template>
<canvas id="flight-indicator"></canvas>
</template>
<script setup>
import { onMounted, watch } from 'vue'
const props = defineProps({
heading: { type: Number, default: 0 }, // 偏航角(预留)
pitch: { type: Number, default: 0 }, // 俯仰角
roll: { type: Number, default: 0 }, // 翻滚角
})
// canvasData 配置(见 2.1)……
function draw () { /* 所有绘制逻辑 */ }
onMounted(() => draw())
watch(() => [props.pitch, props.roll], () => draw())
</script>
draw() 内部按”底 → 表 → 针 → 字”的顺序绘制,层层叠起:
第一步,初始化画布尺寸。 Canvas 元素有两组尺寸:CSS 控制的显示尺寸,和 width/height 属性决定的缓冲区尺寸(默认 300×150)。绘制前必须把后者同步成前者,否则图形会被拉伸变形:
canvas = document.getElementById('flight-indicator')
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
ctx = canvas.getContext('2d')
第二步,天地两半圆与地平线。 上节已详述,两个 arc + fill 画出天与地,再用一条横线穿过整个圆,勾勒出地平线:
ctx.beginPath()
ctx.moveTo(cx - r, cy - 0.8)
ctx.lineTo(cx + r, cy - 0.8)
ctx.stroke()
第三步,刻度循环。 遍历 markLine,每条线 moveTo / lineTo 一笔带过,数值在刻度两侧各写一份(fillText 写两遍,方便左右眼……不,是方便从两侧读数)。一个小细节是负数的定位偏移比正数多 4px——要容纳多出来的负号:
ctx.fillText(item.text, item.text < 0 ? startX - 20 : startX - 16, y + 3)
ctx.fillText(item.text, endX + 4, y + 3)
第四步,姿态符号。 即 2.4 / 2.5 节推导的坐标计算:先算机身中心(俯仰平移),再算两端高度差(正切换算),然后一笔 moveTo → arc → lineTo 画出”左翼—机背半圆—右翼”的连续折线,最后在中心补一个小圆点代表机身。
第五步,读数区。 左上角 PITCH、右上角 ROLL,底下衬一块半透明的圆角底色(fillRect),再叠上文字与实时数值(toFixed(1) 保留一位小数)。到这里,一张完整的姿态图就画完了。
整个 draw() 不过百行,没有任何外部依赖——这就是 Canvas 直绘的魅力:所有像素都在你的掌控之中。
四、Astro 集成:让组件在静态博客里”活”起来
组件写完,新的问题来了:我的博客是 Astro 搭的。Astro 默认输出零 JavaScript 的静态 HTML,而姿态指示器是纯客户端组件——它需要 document、需要响应式、需要用户交互。怎么让它进文章?
这正是 Astro 的”岛屿架构(Islands Architecture)“的用武之地:页面大部分保持静态 HTML,交互组件作为”岛屿”按需水合(hydrate)。下面是完整的集成路径。
4.1 集成三步走
第一步,安装 Vue 运行时与官方集成:
npm install vue @astrojs/vue
版本提示:
@astrojs/vue的大版本与 Astro 大版本对应(Astro 5 配@astrojs/vue@^5)。如果npm install报ERESOLVEpeer 依赖冲突,多半是装到了要求更新版 Astro 的包,锁一下版本号即可。
第二步,在 astro.config.mjs 注册集成:
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import vue from '@astrojs/vue';
import mdx from '@astrojs/mdx';
export default defineConfig({
integrations: [
// ...既有集成
vue(),
mdx(),
],
});
第三步,启用 MDX。 Astro 的 Markdown 文件不支持组件导入和 client:* 指令,MDX 可以。装上 @astrojs/mdx 后,把文章从 .md 改成 .mdx,就能在正文里直接 import 组件了——现有的 Content Collections 与文章模板无需任何改动,.mdx 会和 .md 一起被同一套 schema 校验和路由。
4.2 组件适配:四针”外科手术”
直接把原版组件丢进来也能跑,但我在适配时还是做了几处改造。这些差异正是”SPA 组件”与”可移植客户端组件”的分水岭:
| # | 原实现 | 适配后 | 动机 |
|---|---|---|---|
| 1 | document.getElementById('flight-indicator') | 模板引用 ref | 写死 id 的组件无法同页复用两个实例;在 SSR / 无 document 环境下还会直接抛错 |
| 2 | canvas.width = clientWidth | 乘以 devicePixelRatio + setTransform | 高分屏上按 1:1 物理像素绘制,线条发虚、文字模糊 |
| 3 | 每次重绘不清屏 | clearRect + 每段 beginPath | 重绘残留(下文详述,是真实踩过的坑) |
| 4 | 只在 mounted 时量一次尺寸 | ResizeObserver 监听容器 | 响应式布局下容器宽度会变,画布需要跟着重绘 |
逐段看关键代码。其一,模板引用替代全局查询——Vue 组件实例应该只依赖自己的模板作用域:
<template>
<canvas ref="canvasRef" class="flight-indicator" role="img"
aria-label="无人机飞行姿态指示器"></canvas>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const canvasRef = ref(null)
</script>
其二,高分屏适配。 Canvas 的 width/height 是缓冲区物理像素,CSS 尺寸是逻辑像素。让缓冲区放大 dpr 倍,再把坐标系整体缩放回来,绘制逻辑完全不用感知 DPR 的存在:
const dpr = window.devicePixelRatio || 1
canvas.width = Math.round(canvas.clientWidth * dpr)
canvas.height = Math.round(canvas.clientHeight * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // 坐标系缩回 CSS 像素
ctx.clearRect(0, 0, w, h) // 清屏,拒绝残留
其三,尺寸自愈。 注册一个 ResizeObserver,容器宽度一变就重绘,组件从此放进任何布局都”免维护”:
onMounted(() => {
draw()
resizeObserver = new ResizeObserver(() => draw())
resizeObserver.observe(canvasRef.value)
})
其四,限幅保护。 真实数据可能越界:tan 在 ±90° 处发散,翻滚角逼近 90° 时机翼会”飞”出画布;俯仰超过刻度范围时机身也会飘出姿态球。绘制前先 clamp(读数仍显示真实值,只是机身钉在边界):
const clampedPitch = Math.max(-50, Math.min(50, props.pitch))
const clampedRoll = Math.max(-80, Math.min(80, props.roll))
4.3 在文章里嵌入交互演示
万事俱备,最后一步是把演示搬进正文。MDX 文件顶部导入,正文里加 client:* 指令:
import FlightIndicatorDemo from '../../components/flight/FlightIndicatorDemo.vue';
## 效果演示
<FlightIndicatorDemo client:visible />
指令的选择也有讲究:
client:load—— 页面加载后立即水合,适合首屏关键交互;client:visible—— 进入视口时才水合,适合页面中下方的组件。
演示位于文章中部,读者滚到那里才需要它工作,于是 client:visible 顺理成章——这正是岛屿架构”按需付费”的精髓:静态内容零 JS,交互岛屿延迟水合。
FlightIndicatorDemo.vue 是我为文章写的演示外壳:左边是仪表本体,右边是两个滑块(Pitch: ±45°、Roll: ±60°),外加一个”模拟飞行”按钮,用几组不同频率的正弦波合成”爬升—巡航—倾斜盘旋”的连续动作:
function tick () {
const t = (performance.now() - startTime) / 1000
pitch.value = 12 * Math.sin(t * 0.9) + 4 * Math.sin(t * 2.3)
roll.value = 25 * Math.sin(t * 0.6) + 8 * Math.sin(t * 1.7 + 1)
rafId = requestAnimationFrame(tick)
}
requestAnimationFrame 驱动数据、watch 驱动重绘,整条数据流就这么串起来了。下面就是实时的它,拖动滑块试试:
拖动滑块可单独观察 Pitch 与 Roll 的映射关系;点击「模拟飞行」将播放一段由正弦波合成的飞行动作。
五、踩坑实录:三个”第一帧正常”的 bug
适配过程中踩的坑,值得单独记录——它们的共同点是第一帧全都正常,问题只在数据动起来之后才浮现。
坑一:幽灵弧线。 数据一变,画布上突然多出一条来历不明的弧线。原因是 fill() / stroke() 并不会清空当前路径,而 draw() 开头第一次 arc 之前没有 beginPath()——上一帧残留的路径和新弧线被连成了一个整体。解法:每段绘制前老老实实 beginPath(),别依赖”上一次刚好清干净了”这种巧合。
坑二:越叠越深的底色。 PITCH / ROLL 读数区的底色是 rgba(151, 157, 177, 0.1) 的半透明块。画布不清屏,这块 10% 透明度的颜色每帧叠加一次,几秒后就成了化不开的深灰。任何半透明绘制 + 无清屏重绘的组合都会踩中它。解法依旧是 clearRect,根治。
坑三:正切的发散。 测试时把 roll 拉满到 89°,机翼两端瞬间飞出画布——tan(89°) ≈ 57,高度差被放大了几十倍。数学函数的奇点就是工程上的边界条件,解法就是 4.2 节的限幅。
这三个坑指向同一个教训:Canvas 是一块”想画什么就画什么、画错什么也照单全收”的画布,重绘纪律(清屏、开路径、限边界)必须自己建立。
六、总结
回顾整个开发过程,收获可以浓缩成几句话:
- 归一化坐标是仪表盘类组件自适应的灵魂,一次设计,处处缩放;
- 屏幕坐标系的 y 轴向下,弧度转换记得带负号,
Δy = L · tan(θ)一式走天下; - 单画布、低指令量的场景,全量重绘就是最好的性能策略;
- 跨框架适配的关键不是改绘制代码,而是把组件对环境的假设(全局 id、单次量尺寸、不清屏)逐一拆除;
- Astro 岛屿架构让”静态博客里的交互演示”成为举手之劳,
client:visible是长文内嵌组件的最佳拍档。
后续计划:给 heading 补上外圈罗盘刻度,让三轴姿态齐全;给数据流加一层插值平滑,回放时仪表不再”跳格”;再把这个绘制核心抽成纯函数,包一层 Web Component,就可以在任何框架里随取随用了。
仪表无声,姿态自明。落笔为记,以飨同好。