45 雷达图绘制:正多边形网格生成

前言

在这里插入图片描述

图:45 雷达图绘制:正多边形网格生成 运行效果截图(HarmonyOS NEXT)

雷达图(Radar Chart)是"鹿鹿·笔迹心理分析"App 中最核心的数据可视化组件。它用 6 个维度(能量/思维/行动/情绪/社交/细节)来展示一个人的人格特征,每个维度从 0 到 100 打分。

绘制雷达图的第一步,是画出三层正六边形网格背景。这是数据多边形的"坐标系"——用户通过网格的层数和刻度感知数值大小。

本文以 [HandwritingRadar.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets) 中的网格绘制代码为例,深入解析正多边形的坐标计算和 Canvas 绘制。

鸿蒙官方·Canvas 路径 API:developer.huawei.com
项目源码:[HandwritingRadar.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets)

雷达图正六边形网格示意图

图:雷达图三层正六边形网格——顶点坐标计算与 Canvas 路径绘制

中心点 cx, cy

计算顶点坐标
cos/sin × radius × layer

moveTo 第一个顶点

lineTo 其余5个顶点

closePath 闭合

stroke 描边
三种透明度

三层嵌套
0.3/0.6/1.0 半径

一、正多边形顶点坐标计算

1.1 核心公式

正六边形的 6 个顶点均匀分布在圆周上,每个顶点的坐标用极坐标→直角坐标转换计算:

// 第 i 个顶点的角度
angle = (2π × i) / 边数 - π/2

// 第 i 个顶点的坐标
x = 中心X + 半径 × cos(angle)
y = 中心Y + 半径 × sin(angle)

关键点:为什么减 π/2?

标准三角函数的 0 度指向 3 点钟方向,但雷达图通常要求第一个顶点指向上方(12 点钟方向),所以需要偏移 -π/2:

  -π/2(12点,第一个顶点)
     ↑
     │
 ←──○──→  0(3点 标准角度)
     │
     ↓
   π/2(6点)

1.2 项目中实际代码

// HandwritingRadar.ets — 顶点角度预计算
const sides = this.data.length || 6   // 边数(与数据维度数相同)
const angles: number[] = []

for (let i = 0; i < sides; i++) {
  // i=0 → -π/2(12点方向)
  // i=1 → -π/2 + π/3 = -π/6(2点方向)
  // i=2 → -π/2 + 2π/3 = π/6(4点方向)
  // i=3 → -π/2 + π = π/2(6点方向)
  // i=4 → -π/2 + 4π/3 = 5π/6(8点方向)
  // i=5 → -π/2 + 5π/3 = -5π/6(10点方向)
  angles.push((Math.PI * 2 * i) / sides - Math.PI / 2)
}

1.3 角度与顶点对照表

顶点 i 角度(弧度) 角度(度) 方向
0 -π/2 -90° ↑(12点)
1 -π/6 -30° ↗(2点)
2 π/6 30° ↘(4点)
3 π/2 90° ↓(6点)
4 5π/6 150° ↙(8点)
5 -5π/6 -150° ↖(10点)

二、三层网格生成

2.1 同心多边形

网格由 3 个同心正六边形组成,每层半径按比例递增:

const radius = W * 0.34       // 最外层半径(占画布 34%)
const layers = 3              // 网格层数

for (let layer = 1; layer <= layers; layer++) {
  const r = radius * (layer / 3)  // 第 1 层 = 1/3 半径
                                   // 第 2 层 = 2/3 半径
                                   // 第 3 层 = 3/3 半径
  // ... 绘制这一层的六边形
}

2.2 完整网格绘制

private draw(): void {
  const ctx = this.context
  const W = this.radarSize            // 画布尺寸 280
  const cx = W / 2                    // 中心 X
  const cy = W / 2                    // 中心 Y
  const radius = W * 0.34             // 最外层半径(≈95px)

  // 预计算 6 个顶点角度
  const sides = this.data.length || 6
  const angles: number[] = []
  for (let i = 0; i < sides; i++) {
    angles.push((Math.PI * 2 * i) / sides - Math.PI / 2)
  }

  // 清除画布
  ctx.clearRect(0, 0, W, W)

  // === 绘制三层网格 ===
  for (let layer = 1; layer <= 3; layer++) {
    const r = radius * (layer / 3)    // 当前层半径
    ctx.beginPath()                    // 开始新路径

    for (let i = 0; i < sides; i++) {
      const x = cx + r * Math.cos(angles[i])
      const y = cy + r * Math.sin(angles[i])

      if (i === 0) {
        ctx.moveTo(x, y)              // 第一个顶点:移动画笔
      } else {
        ctx.lineTo(x, y)              // 后续顶点:连线
      }
    }

    ctx.closePath()                   // 闭合回到第一个顶点
    ctx.strokeStyle = AppColors.LINE  // 颜色:#E5D5BC
    ctx.lineWidth = 1
    ctx.stroke()                      // 描边
  }
}

绘制过程可视化:

第 3 层(最外层)  ─── 半径 = radius × 3/3
    /\
   /  \
  /    \
 /______\

第 2 层(中层)   ─── 半径 = radius × 2/3
    /\
   /  \
  /____\

第 1 层(最内层)  ─── 半径 = radius × 1/3
    /\
   /  \
  /____\

三、三层网格含义

3.1 数值映射

网格的每一层代表一个数值边界:

// 第 1 层(最内层) → 数值 ≈ 33
// 第 2 层(中层)   → 数值 ≈ 67
// 第 3 层(最外层) → 数值 = 100
//
// 因此,一个点的位置(在网格中的百分比位置)
// 决定了它的 6 维数值:
//   距中心 r / 半径 = 数值 / 100

3.2 用户视觉感知

三层网格的用户感知:

  第 1 层:33 分线 — 低于此说明该维度较弱
  第 2 层:67 分线 — 高于此说明该维度较强
  第 3 层:边界线 — 满分 100 的外框

四、性能优化:角度预计算

4.1 避免重复计算

代码中将角度计算放在 draw() 的最前面,而不是在每次循环中重复计算:

// ✅ 高效:一次性预计算所有角度
const angles: number[] = []
for (let i = 0; i < sides; i++) {
  angles.push((Math.PI * 2 * i) / sides - Math.PI / 2)
}

// 在网格循环中复用
for (let layer = 1; layer <= 3; layer++) {
  const r = radius * (layer / 3)
  ctx.beginPath()
  for (let i = 0; i < sides; i++) {
    const x = cx + r * Math.cos(angles[i])  // 复用 angles
    const y = cy + r * Math.sin(angles[i])
    // ...
  }
}

// 在数据多边形中使用同一个 angles 数组
this.data.forEach((p, i) => {
  const r = radius * (p.value / 100) * this.drawProgress
  const x = cx + r * Math.cos(angles[i])     // 复用 angles
  const y = cy + r * Math.sin(angles[i])
  // ...
})

4.2 避免的写法

// ❌ 低效:每次循环都重复计算角度(cos/sin 是 CPU 密集型运算)
for (let i = 0; i < sides; i++) {
  const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
  const x = cx + r * Math.cos(angle)
  const y = cy + r * Math.sin(angle)
}

五、正多边形绘制总结

5.1 通用函数

/**
 * 绘制正多边形
 * @param ctx Canvas 2D 上下文
 * @param cx 中心 X
 * @param cy 中心 Y
 * @param radius 外接圆半径
 * @param sides 边数
 * @param rotation 旋转偏移(弧度)
 */
function drawPolygon(
  ctx: CanvasRenderingContext2D,
  cx: number, cy: number,
  radius: number,
  sides: number,
  rotation: number = -Math.PI / 2
): void {
  ctx.beginPath()
  for (let i = 0; i < sides; i++) {
    const angle = (Math.PI * 2 * i) / sides + rotation
    const x = cx + radius * Math.cos(angle)
    const y = cy + radius * Math.sin(angle)
    if (i === 0) ctx.moveTo(x, y)
    else ctx.lineTo(x, y)
  }
  ctx.closePath()
}

5.2 不同边数的雷达图

// 6 维雷达图(本项目使用)
drawPolygon(ctx, cx, cy, radius, 6)

// 5 维雷达图(游戏角色面板)
drawPolygon(ctx, cx, cy, radius, 5)

// 8 维雷达图(综合能力评估)
drawPolygon(ctx, cx, cy, radius, 8)
边数 常见用途 示例
5 游戏角色属性 攻击/防御/速度/法力/生命
6 💡 本项目人格分析 能量/思维/行动/情绪/社交/细节
8 综合能力评估 沟通/决策/创新/执行/领导/协作/学习/适应

5.3 步进式添加顶点连线(可选扩展)

除了同心多边形,还可以绘制从中心到顶点的辅助线:

// 从中心到各顶点的辐射线
for (let i = 0; i < sides; i++) {
  const x = cx + radius * Math.cos(angles[i])
  const y = cy + radius * Math.sin(angles[i])
  ctx.beginPath()
  ctx.moveTo(cx, cy)
  ctx.lineTo(x, y)
  ctx.strokeStyle = AppColors.LINE
  ctx.lineWidth = 0.5
  ctx.stroke()
}

六、常见问题

6.1 画出来的是椭圆形?

// ❌ 原因:Canvas 宽高不等时,正圆形被拉伸
Canvas(this.context)
  .width(300).height(200)   // 宽高比 3:2 → 六边形被拉伸

// ✅ 解决:使用正方形 Canvas
Canvas(this.context)
  .width(280).height(280)   // 宽高相等

6.2 网格显示不完整?

// ❌ 原因:半径太大,超出画布边界
const radius = W * 0.6     // 60% — 顶点接近边界被裁剪

// ✅ 解决:留出标签空间
const radius = W * 0.34    // 34% — 只占画布中心区域
// 余下空间留给顶点标签

6.3 路径未闭合?

// ❌ 错误:缺少 closePath,最后一个顶点没有回到起点
ctx.beginPath()
ctx.moveTo(x0, y0)
ctx.lineTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()  // 只画了两条边

// ✅ 正确:closePath 自动闭合
ctx.closePath()
ctx.stroke()  // 完整的六边形

八、注意事项与常见问题

8.1 开发注意事项

在实际开发过程中,需特别注意以下几点:

  • API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
  • 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
  • 生命周期:合理使用 aboutToAppear()aboutToDisappear() 管理资源初始化与释放
  • 状态同步:跨页面数据通过 AppStorage 共享,组件内状态使用 @State / @Prop / @Link 装饰器

8.2 常见错误与解决方案

常见问题快速排查表:

问题类型 排查方向 参考方法
应用崩溃 查看 hilog 错误日志 hilog.error(TAG, "...", e.message)
状态丢失 检查 AppStorage 键名拼写 统一使用常量管理键名
动画不流畅 避免在 animateTo 回调中执行 I/O 动画与数据操作分离

总结

本文详细解析了雷达图三层正六边形网格的生成:

  1. 顶点坐标计算(2π × i / sides) - π/2 + cos/sin 转换
  2. 三层同心网格for loop 递进半径(1/3 → 2/3 → 3/3)
  3. 路径绘制流程beginPath → moveTo → lineTo × 5 → closePath → stroke
  4. 性能优化:角度预计算避免重复 cos/sin
  5. 网格含义:每层对应数值 33/67/100 分界线

下一篇文章将介绍 数据多边形与填充——如何在网格上绘制彩色数据区域。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:


七、完整绘制流程总结

雷达图网格绘制的完整步骤:

步骤 操作 Canvas API
1. 保存状态 保存上下文变换矩阵 ctx.save()
2. 平移到中心 将坐标原点移到画布中心 ctx.translate(cx, cy)
3. 绘制三层网格 循环 layer = 0.3/0.6/1.0 嵌套循环
4. 计算顶点 三角函数计算 6 个顶点坐标 Math.cos/sin
5. 绘制路径 连接顶点闭合为多边形 moveTo/lineTo/closePath
6. 绘制轴线 从中心到顶点的 6 条轴 moveTo(0,0)/lineTo
7. 恢复状态 还原变换矩阵 ctx.restore()
// 雷达图网格绘制核心代码
private drawGrid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void {
  const sides = 6
  const layers = [0.3, 0.6, 1.0]
  ctx.save()
  ctx.translate(cx, cy)

  layers.forEach(ratio => {
    ctx.beginPath()
    for (let i = 0; i < sides; i++) {
      const angle = (Math.PI / 3) * i - Math.PI / 2  // 从顶部开始
      const x = r * ratio * Math.cos(angle)
      const y = r * ratio * Math.sin(angle)
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
    }
    ctx.closePath()
    ctx.strokeStyle = `rgba(255,255,255,${0.15 + ratio * 0.1})`
    ctx.stroke()
  })

  ctx.restore()
}

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐