46 雷达图绘制:数据多边形与填充

前言

在这里插入图片描述

图:46 雷达图绘制:数据多边形与填充 运行效果截图(HarmonyOS NEXT)

在上一篇文章中,我们绘制了雷达图的三层网格背景。本文将在此基础上,绘制数据多边形——将 6 维数值映射到网格上的点,连接成多边形,并填充半透明颜色。

这是雷达图的核心视觉元素,用户通过它一眼就能看出自己在各个维度的强弱分布。本文以 [HandwritingRadar.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets) 为例,详解数据多边形的绘制。

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

雷达图数据多边形示意图

图:雷达图数据多边形——6维分值映射到半径,连接成填充多边形

6维分值数组
0-100

归一化
分值 / 100 × radius

计算数据点坐标
同网格顶点角度

beginPath
依次连接6点

closePath 闭合

fill 半透明填充
rgba带透明度

stroke 描边
实线外框

一、数据结构

1.1 RadarPoint 接口

export interface RadarPoint {
  label: string   // 维度名称,如 "能量"
  value: number   // 数值 0-100,如 62
}

// 实际数据示例
this.radarData = [
  { label: '能量', value: 62 },
  { label: '思维', value: 65 },
  { label: '行动', value: 55 },
  { label: '情绪', value: 72 },
  { label: '社交', value: 60 },
  { label: '细节', value: 70 }
]

1.2 数值到坐标的映射

每个维度的数值(0-100)通过以下公式映射到画布坐标:

// 数值 → 半径距离的比例
const ratio = point.value / 100   // 0.0 ~ 1.0

// 半径距离
const r = radius * ratio          // 距中心点的像素距离

// 顶点坐标
const x = cx + r * Math.cos(angles[i])
const y = cy + r * Math.sin(angles[i])

数值映射示意:

数值 = 0  → r = 0     → 点位于中心
数值 = 50 → r = r/2   → 点位于中层网格
数值 = 100 → r = r    → 点位于最外层网格顶点

二、数据多边形绘制

2.1 连接所有顶点

// HandwritingRadar.ets — 数据多边形绘制
ctx.beginPath()                                    // 开始新路径
this.data.forEach((p, i) => {
  // 当前维度对应的半径(数值比例 × 最大半径 × 动画进度)
  const r = radius * (p.value / 100) * this.drawProgress
  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()                                    // 闭合回第一个顶点

2.2 填充

// 半透明填充(品牌色暖棕,28% 透明度)
ctx.fillStyle = 'rgba(168,144,122,.28)'  // ✅ fill 必须在 stroke 之前
ctx.fill()

// 实线描边
ctx.strokeStyle = AppColors.PRIMARY     // 主品牌色 #A8907A
ctx.lineWidth = 2
ctx.stroke()

为什么 fill 要在 stroke 之前?
在 Canvas 中,fill()stroke() 的调用顺序不会影响视觉效果(两者独立渲染),但建议先 fill 再 stroke——即使 fill 半透明颜色也不会覆盖到 stroke 线上。

2.3 完整代码

// === 数据多边形(从中心向外扩散动画) ===
ctx.beginPath()
this.data.forEach((p, i) => {
  const r = radius * (p.value / 100) * this.drawProgress
  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.fillStyle = 'rgba(168,144,122,.28)'
ctx.fill()
ctx.strokeStyle = AppColors.PRIMARY
ctx.lineWidth = 2
ctx.stroke()

三、多层数据叠加(DuetReport)

在双人合盘场景中,需要在同一个雷达图上绘制两个数据多边形:

3.1 两套颜色系统

// 我的数据(实线)
ctx.strokeStyle = AppColors.WARM     // 暖色 #D9A689
ctx.fillStyle = 'rgba(217,166,137,.18)'

// 绘制我的多边形
// ...(同第一节代码)

// 对方的数据(虚线)
ctx.setLineDash([5, 4])             // 虚线区分
ctx.strokeStyle = AppColors.ROSE     // 玫瑰色 #C9A0A0
ctx.fillStyle = 'rgba(201,160,160,.18)'

// 绘制对方多边形
// ...
ctx.setLineDash([])                  // 恢复实线

3.2 样式对比

属性 我的多边形 对方多边形
描边颜色 WARM #D9A689 ROSE #C9A0A0
描边线型 实线(setLineDash([]) 虚线(setLineDash([5,4])
描边宽度 2px 2px
填充颜色 rgba(217,166,137,.18) rgba(201,160,160,.18)

四、填充样式进阶

4.1 渐变填充

对于单色半透明填充,项目使用了固定颜色。但也可以扩展为渐变填充:

/**
 * 从中心向外渐变
 * 中心透明度低 → 边缘透明度高
 */
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius)
gradient.addColorStop(0, 'rgba(168,144,122,0.50)')    // 中心较实
gradient.addColorStop(1, 'rgba(168,144,122,0.05)')    // 边缘透明
ctx.fillStyle = gradient
ctx.fill()

4.2 按数据值动态颜色

/**
 * 根据平均分动态调整填充颜色
 */
function getFillStyle(data: RadarPoint[]): string {
  const avg = data.reduce((sum, p) => sum + p.value, 0) / data.length
  if (avg >= 70) return 'rgba(168,144,122,.32)'    // 高分 → 较深
  if (avg >= 50) return 'rgba(168,144,122,.22)'    // 中等
  return 'rgba(168,144,122,.14)'                     // 低分 → 较浅
}

4.3 不同维度的色值区域(不建议)

// ❌ 不推荐:每个维度不同颜色 → 视觉效果杂乱
// 通常建议整个数据多边形使用统一的半透明色

五、完整的绘制时序

5.1 绘制顺序

Canvas 绘图的层级顺序非常重要——后面的绘制会覆盖前面的内容:

① clearRect(0, 0, W, W)   清除画布
② 三层网格(同心正六边形)    → 底层
③ 数据多边形填充             → 中层(半透明)
④ 数据多边形描边             → 上层(实线)
⑤ 顶点标签文字              → 最上层

5.2 叠加效果示意

最顶层   ⑤ 标签文字
        ④ 数据多边形描边(线宽 2)
        ③ 数据多边形填充(半透明 28%)
        ② 三层网格描边(线宽 1)
最底层   ① 画布清除(透明背景)

六、动画与数据多边形的结合

6.1 从中心向外扩散

// 通过 this.drawProgress(0→1)控制数据多边形的"半径比例"
const r = radius * (p.value / 100) * this.drawProgress

// drawProgress=0 时 → 所有顶点都汇聚到中心
// drawProgress=0.5 → 数据多边形绘制到 50%
// drawProgress=1   → 数据多边形到达最终位置

6.2 网格与数据多边形的动画独立

// 网格:直接绘制(不依赖 drawProgress)
for (let layer = 1; layer <= 3; layer++) {
  const r = radius * (layer / 3)
  // ... 直接绘制完整网格
}

// 数据多边形:依赖 drawProgress(从中心向外生长)
this.data.forEach((p, i) => {
  const r = radius * (p.value / 100) * this.drawProgress
  // ...
})

为什么网格不做动画?

  • 网格是参照系,需要一开始就完整显示
  • 数据多边形是数据展示,通过扩散动画吸引视觉焦点

七、样式调整参数

7.1 项目中可调样式参数

参数 当前值 效果 调整建议
填充透明度 0.28 淡淡显色 调高→更深;调低→更透
描边宽度 2px 清晰可见 调高→更突出
描边颜色 PRIMARY #A8907A 品牌棕 可按情绪类型变化

7.2 不同情绪类型的数据多边形配色

/**
 * 根据人格类型选择不同的数据多边形颜色
 */
function getRadarColor(personalityType: string): { fill: string, stroke: string } {
  switch (personalityType) {
    case '温和理性型':
      return { fill: 'rgba(168,144,122,.28)', stroke: '#A8907A' }
    case '开朗热情型':
      return { fill: 'rgba(217,166,137,.28)', stroke: '#D9A689' }
    case '内敛安静型':
      return { fill: 'rgba(91,127,182,.20)', stroke: '#5B7FB6' }
    case '敏锐果断型':
      return { fill: 'rgba(155,174,168,.28)', stroke: '#9BAEA8' }
    case '温柔体贴型':
      return { fill: 'rgba(201,160,160,.28)', stroke: '#C9A0A0' }
    default:
      return { fill: 'rgba(168,144,122,.28)', stroke: '#A8907A' }
  }
}

八、常见问题

8.1 fill 后 stroke 看不见?

// 场景:fill 颜色完全不透明,盖住了 stroke
ctx.fillStyle = '#A8907A'    // ❌ 完全不透明
ctx.fill()
ctx.stroke()                 // stroke 被 fill 盖住

// 原因:fill 覆盖了 stroke 的区域
// ✅ 解决:使用半透明 fill,或先 stroke 再 fill
ctx.fillStyle = 'rgba(168,144,122,.28)'  // 半透明
ctx.fill()
ctx.stroke()                 // 可见

8.2 数据多边形超出网格?

// 场景:某维度 value > 100,导致顶点超出最外层网格
// 原因:输入数据未校验
const safeValue = Math.min(p.value, 100)  // 限制最大 100
const r = radius * (safeValue / 100)

8.3 数据点少于 6 个?

// 场景:只传入 4 个数据点
// 结果:绘制出四边形,而非六边形
// 解决:统一使用 6 个维度,不足时补 0
const paddedData = [...this.data]
while (paddedData.length < 6) {
  paddedData.push({ label: '', value: 0 })
}

十、最佳实践与性能优化

10.1 代码组织规范

以下规范可显著提升代码可读性与可维护性:

  • 分层解耦:UI 逻辑(pages/)与数据操作(DAO/Service)严格分离,不在 build() 中调用数据库
  • 组件拆分:单个 @Component 保持 100~200 行以内,大型组件拆分为 @Builder 子函数
  • 令牌约束:颜色、字号、间距统一引用 AppColors / AppFonts / AppAnimations,禁止魔数
  • 错误处理:异步方法统一返回 Promise<T | null>,异常时 hilog.error 记录并返回 null

10.2 性能优化要点

以下是常见性能优化措施的对比分析:

优化维度 优化前 优化后 效果
列表渲染 ForEach 全量渲染 LazyForEach 按需渲染 内存降低 40-60%
图片加载 不指定尺寸 指定 width/height 减少布局重计算
数据库查询 每次重新查询 合理缓存 + 精确 Predicates 查询速度提升 5-10x
动画实现 逐帧手动绘制 animateTo 属性动画 稳定 60fps
状态更新 全局刷新 @State 精细化作用域 减少无效渲染

总结

本文详细解析了雷达图数据多边形的绘制:

  1. 数值映射value/100 × radius 将 0-100 映射到画布坐标
  2. 路径构建forEach 连接 6 个顶点 → closePath 闭合
  3. 填充与描边:半透明 rgba 填充 + 主色实线描边
  4. 双人叠加:两套颜色 + 实线/虚线区分双方
  5. 动画整合drawProgress 控制数据多边形从中心扩散生长

下一篇文章将介绍 雷达图标签自适应布局——如何在不同尺寸下确保文字清晰且不重叠。

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


参考资源:

  • Canvas fillStyle / strokeStyle
  • [HandwritingRadar 数据多边形源码](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets)
  • rgba 颜色解析
  • Canvas createLinearGradient
  • [DuetReportPage 双色雷达](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/DuetReportPage.ets)
  • [AppColors 颜色常量](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/theme/AppColors.ets)
  • HarmonyOS 开发文档

七、数据多边形动画效果

雷达图的数据多边形支持入场动画——通过 drawProgress 变量控制绘制进度:

// 带动画的数据多边形绘制
private drawDataPolygon(ctx: CanvasRenderingContext2D, scores: number[], progress: number): void {
  const sides = 6
  const radius = this.canvasSize / 2 * 0.75

  ctx.save()
  ctx.translate(this.canvasSize / 2, this.canvasSize / 2)
  ctx.beginPath()

  for (let i = 0; i < sides; i++) {
    const angle = (Math.PI / 3) * i - Math.PI / 2
    // progress 从 0 到 1,控制多边形"展开"动画
    const r = (scores[i] / 100) * radius * progress
    const x = r * Math.cos(angle)
    const y = r * Math.sin(angle)
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
  }

  ctx.closePath()
  ctx.fillStyle = 'rgba(139, 92, 246, 0.3)'   // 品牌紫色半透明
  ctx.fill()
  ctx.strokeStyle = 'rgba(139, 92, 246, 0.9)'
  ctx.lineWidth = 2
  ctx.stroke()
  ctx.restore()
}

7.1 绘制进度驱动的动画

@State @Watch('onProgressChange') drawProgress: number = 0

onProgressChange(): void {
  this.draw()  // 进度变化触发重绘
}

aboutToAppear(): void {
  animateTo({ duration: 800, curve: Curve.EaseOut }, () => {
    this.drawProgress = 1.0
  })
}

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

Logo

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

更多推荐