返回首页

手写 Canvas 无人机姿态指示器:从 Vue 组件到 Astro 交互演示

Canvas Vue Astro 前端可视化

起因:复盘视频里,缺一张”会说话的图”

前段时间复盘一次无人机作业的飞行视频,遇到一个尴尬的问题:视频画面里的 OSD(屏幕叠加显示)数据滚得太快,光靠肉眼盯着不断跳动的数字,根本没法判断某个瞬间机体到底是”前倾了 8 度”还是”侧倾了 20 度”。事后翻遥测日志,拿到的是一列列的 pitch、roll 数值——数据都在,但缺乏一个直观、固定的图表,让它们”开口说话”。

航空领域的答案是现成的:姿态指示器(Attitude Indicator),也叫”人工地平仪”。上半部分是天空、下半部分是大地,中间一条地平线;机头抬起,天地线就往下走;机体侧倾,整个天地跟着转。飞行员扫一眼就知道姿态,这正是我想要的展示形式。

于是我先去找现成的轮子,转了一圈之后,决定自己用 HTML5 Canvas 写一个。这篇文章就是这次开发的完整复盘——从需求分析、数学推导,到把一个为 Vue 设计的组件适配进 Astro 静态博客、让它在文章里实时跑起来。

一、需求分析:为什么不用现成的轮子

先看市面上能找到的方案:

方案问题
通用图表库的仪表盘(如 ECharts gauge)面向”速度表 / 水位表”这类扇形指针模型,没有”上天下地”的姿态球概念;想改成人工地平仪,等于在和图表库的抽象作斗争
各类姿态仪 / 仪表盘组件库大多基于 SVG 实现,配色、刻度密度、尺寸比例深度耦合内部实现;改样式经常要覆盖十几个 CSS 变量甚至 fork 源码
视频后期的 OSD 工具面向剪辑流程,不可编程,无法接入自己的遥测数据管道

归纳下来,我的需求其实非常明确:

  1. 视觉模型必须是人工地平仪:天空 / 大地分离、俯仰平移、翻滚倾斜,缺一不可;
  2. 深度可定制:配色、刻度、尺寸全部听我的,因为要嵌进自己的复盘工具 UI 里;
  3. 数据驱动:pitch、roll 两个 prop 变化就重绘,方便对接时序数据回放;
  4. 轻量:一个单文件组件,不引入图表库这种重型依赖;
  5. 跨框架可移植:绘制逻辑与框架解耦,将来搬到别处也能用。

衡量之后结论很清晰:这个仪表盘的绘制指令总共不超过 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 报 ERESOLVE peer 依赖冲突,多半是装到了要求更新版 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 组件”与”可移植客户端组件”的分水岭:

#原实现适配后动机
1document.getElementById('flight-indicator')模板引用 ref写死 id 的组件无法同页复用两个实例;在 SSR / 无 document 环境下还会直接抛错
2canvas.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 驱动重绘,整条数据流就这么串起来了。下面就是实时的它,拖动滑块试试:

12.0°
-18.0°

拖动滑块可单独观察 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,就可以在任何框架里随取随用了。


仪表无声,姿态自明。落笔为记,以飨同好。