47 雷达图绘制:标签自适应布局

前言

在这里插入图片描述

图:47 雷达图绘制:标签自适应布局 运行效果截图(HarmonyOS NEXT)

雷达图的 6 个顶点各有一个维度标签(能量/思维/行动/情绪/社交/细节),这些标签需要刚好位于顶点外侧,并且在不同尺寸的雷达图上都能清晰展示。

本文以 [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)

雷达图标签布局示意图

图:雷达图6个顶点标签的自适应布局——根据角度位置动态调整对齐方式

左侧 180°-360°

右侧 0°-180°

顶部 270°附近

底部 90°附近

6个标签

计算标签中心坐标
顶点坐标 × labelOffset

角度区间

textAlign = right

textAlign = left

textAlign = center

fillText 绘制

一、标签绘制基础

1.1 核心代码

// HandwritingRadar.ets — 标签绘制部分
const fontSize = Math.max(8, Math.round(11 * this.radarSize / 280))
ctx.font = `${fontSize}px HarmonyOS Sans SC`
ctx.fillStyle = AppColors.TEXT_2
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'

const labelOffset = radius + Math.max(12, Math.round(18 * this.radarSize / 280))

this.data.forEach((p, i) => {
  const x = cx + labelOffset * Math.cos(angles[i])
  const y = cy + labelOffset * Math.sin(angles[i])
  ctx.fillText(p.label, x, y)
})

1.2 视觉效果

  标签①(能量)
      ↑
      │
   ←──○──→  标签④(社交)
      │
      ↓
  标签③(情绪)

每个标签位于顶点向外延伸的方向上,距离顶点约 12-30px

二、自适应字体大小

2.1 按比例缩放

const fontSize = Math.max(8, Math.round(11 * this.radarSize / 280))

计算逻辑:

radarSize 计算过程 fontSize
140(小) 11 × 140 / 280 = 5.5Math.max(8, 6) 8px(下限保护)
200(中) 11 × 200 / 280 = 7.86Math.max(8, 8) 8px
280(默认) 11 × 280 / 280 = 11Math.max(8, 11) 11px
360(大) 11 × 360 / 280 = 14.14Math.max(8, 14) 14px

设计要点:

  • 基准大小280px 画布对应 11px 字号
  • 线性缩放:字号随画布大小线性变化
  • 下限保护Math.max(8, ...) 确保最小 8px,避免太小无法阅读

2.2 字号下限保护的原因

// ❌ 如果没有 Math.max(8, ...)
fontSize = 11 * 100 / 2804   // 4px 完全无法阅读

// ✅ 有 Math.max(8, ...) 保护
fontSize = Math.max(8, 4) = 8   // 8px 勉强可读

三、自适应标签偏移

3.1 偏移距离

const labelOffset = radius + Math.max(12, Math.round(18 * this.radarSize / 280))

标签位置 = 网格顶点 + 外推偏移,确保标签不盖住网格:

网格顶点(radius)
  ───┬───
     │
     ↓   ← labelOffset = 顶点 + 外推距离
   文字标签

偏移计算:

radarSize 外推距离 公式 labelOffset
140 12px(下限) radius + 12 radius + 12
200 13px radius + 13 radius + 13
280 18px radius + 18 radius + 18
360 23px radius + 23 radius + 23

3.2 偏移方向

每个标签沿着该顶点方向的射线外推:

this.data.forEach((p, i) => {
  const x = cx + labelOffset * Math.cos(angles[i])
  const y = cy + labelOffset * Math.sin(angles[i])
  // angles[i] 与网格顶点计算使用的角度相同
  // 所以标签位于顶点正外方
})

方向对照:

标签 角度 方向 标签位置
能量 -90° 正上方
思维 -30° 右上
行动 30° 右下
情绪 90° 正下方
社交 150° 左下
细节 -150° 左上

四、文字对齐设置

4.1 textAlign 与 textBaseline

ctx.textAlign = 'center'   // 水平居中
ctx.textBaseline = 'middle'  // 垂直居中

textAlign 可选值:

效果 适用场景
left 从左向右排列 正左标签(如 9 点钟方向)
center 以(x,y)为中心 ✅ 本项目:所有标签居中于顶点方向
right 从右向左排列 正右标签(如 3 点钟方向)

textBaseline 可选值:

效果 适用场景
top 顶部对齐 标签在点下方
middle 垂直居中 ✅ 本项目:所有标签居中
bottom 底部对齐 标签在点上方
alphabetic 字母基线 英文文本

4.2 为什么统一 center + middle?

对于分布在 6 个方向的标签,如果使用 left/right/top/bottom 组合,需要为每个顶点单独计算对齐方式:

// ❌ 如果每个方向单独处理,代码会变得复杂
const alignMap = [
  { align: 'center', baseline: 'bottom' },  // 12点方向:文字在点下方
  { align: 'left', baseline: 'middle' },    // 2点方向:文字在点右侧
  { align: 'left', baseline: 'top' },       // 4点方向:...
  { align: 'center', baseline: 'top' },     // 6点方向
  { align: 'right', baseline: 'top' },      // 8点方向
  { align: 'right', baseline: 'middle' },   // 10点方向
]

// ✅ 本项目:统一 center + middle + labelOffset 外推
// 所有标签从顶点向外推,自然分布在顶点外侧
// 代码简洁,效果一致

五、标签碰撞处理

5.1 当前方案 vs 碰撞检测

当前方案将所有标签沿射线外推相同距离,在标准雷达图(6 个维度)下不会出现重叠,因为:

  • 6 个顶点间隔 60°,间距足够
  • 标签文字较短(2 个汉字)
  • 外推距离(18px)保证标签在网格外部

可能重叠的极端情况:

// 场景:大量维度(如 12 维雷达图)
// 顶点间距只有 30°,标签可能互相覆盖
// 方案:动态调整外推距离或缩小字号
const minGap = Math.sin(Math.PI / sides) * labelOffset * 2
if (minGap < fontSize * 2) {
  // 空间不足,缩小字号或增大偏移
}

5.2 基于方向的偏移微调(扩展方案)

如果需要更精细的标签布局,可以按方向微调:

function getLabelOffset(i: number, sides: number, radius: number, fontSize: number): number {
  const base = radius + 18

  // 水平方向(左右)需要更多空间,因为文字是横向排列的
  const isHorizontal = i === 1 || i === 4  // 2 点和 8 点方向
  if (isHorizontal) return base + fontSize * 0.8

  return base
}

六、字体选择

6.1 字体族

ctx.font = `${fontSize}px HarmonyOS Sans SC`

项目使用系统字体 HarmonyOS Sans SC,这是鸿蒙系统的默认无衬线字体。

可用的系统字体:

字体 风格 用途
HarmonyOS Sans SC 无衬线 UI 文本、标签(✅ 本项目使用)
HarmonyOS Sans SC Medium 中等字重 标题、强调
HarmonyOS Sans SC Bold 粗体 数字、数值

6.2 完整字体设置

// Canvas 中设置字体比 ArkUI Text 组件更灵活
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`
// 参数:字重 + 字号 + 字体族(字符串拼接)

// 示例
ctx.font = '11px HarmonyOS Sans SC'           // 普通
ctx.font = 'bold 36px HarmonyOS Sans SC'      // 粗体大号(MatchRing 中心数值)
ctx.font = '12px HarmonyOS Sans SC'           // 中等(MatchRing 副标题)

七、不同场景的标签配置

7.1 标签与数据显示

项目中的标签有 6 个固定维度,每个维度垂直对应的数据值也在标签附近展示(在页面 UI 层而非 Canvas 层实现):

Canvas 层:
           能量
           ↑
           │
   细节 ↖  ○  ↗ 思维
   社交 ←  ○  → 行动
           ↓
          情绪

页面 UI 层:
   [能量 62]  [思维 65]  [行动 55]
   [情绪 72]  [社交 60]  [细节 70]

7.2 小尺寸雷达图的标签处理

当雷达图用作小尺寸预览时(如 SharePage 中 radarSize=200),标签字号自动缩小:

// SharePage 中使用小雷达
HandwritingRadar({
  data: this.radarData,
  radarSize: 200      // 比默认 280 小 29%
})
// 字号:Math.max(8, 11*200/280) = Math.max(8, 8) = 8px
// 偏移:radius + Math.max(12, 18*200/280) = radius + 13

八、标签布局检查清单

检查项 项目中当前值 是否满足
字号自适应 11 × size/280 最小 8px
标签位置 顶点外推 Math.max(12, 18×size/280)
文字对齐 center + middle
抗锯齿 RenderingContextSettings antialias=true
标签不重叠 6 维度 60° 间隔 + 短文字
颜色可见 fillStyle = TEXT_2 (深灰色)
字体加载 使用系统内置 HarmonyOS Sans SC

总结

本文详细解析了雷达图标签的自适应布局方案:

  1. 自适应字号Math.max(8, 11 × radarSize / 280) 线性缩放 + 下限保护
  2. 自适应偏移radius + Math.max(12, 18 × radarSize / 280) 外推顶点
  3. 对齐方式:统一 center + middle 简化代码
  4. 方向计算:复用网格顶点的 angles[],标签位于顶点正外方
  5. 极端处理:小尺寸下字号保护 + 外推距离缩小

下一篇文章将介绍 EmotionTrendChart 情绪折线图——30天情绪趋势的 Canvas 绘制。

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


参考资源:


七、标签绘制完整实现

7.1 自适应对齐算法

private drawLabels(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number): void {
  const labels = ['能量', '思维', '行动', '情绪', '社交', '细节']
  const labelOffset = 1.25  // 标签距中心的倍数

  ctx.save()
  ctx.translate(cx, cy)
  ctx.font = '12px HarmonyOS Sans SC'
  ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'

  labels.forEach((label, i) => {
    const angle = (Math.PI / 3) * i - Math.PI / 2
    const x = r * labelOffset * Math.cos(angle)
    const y = r * labelOffset * Math.sin(angle)

    // 根据角度动态调整文字对齐
    const cosVal = Math.cos(angle)
    if (Math.abs(cosVal) < 0.1) {
      ctx.textAlign = 'center'  // 顶部/底部标签居中
    } else if (cosVal > 0) {
      ctx.textAlign = 'left'    // 右侧标签左对齐
    } else {
      ctx.textAlign = 'right'   // 左侧标签右对齐
    }

    // 根据 sin 值调整垂直基线
    const sinVal = Math.sin(angle)
    ctx.textBaseline = sinVal > 0.5 ? 'top' : (sinVal < -0.5 ? 'bottom' : 'middle')

    ctx.fillText(label, x, y)
  })

  ctx.restore()
}

7.2 标签样式参数对照表

参数 项目值 效果
font '12px HarmonyOS Sans SC' 小字体,与图表协调
fillStyle rgba(255,255,255,0.85) 白色微透明,图表背景对比
textAlign 动态(left/center/right) 自适应位置
textBaseline 动态(top/middle/bottom) 垂直方向居中
labelOffset 1.25 标签在顶点外侧 25% 处

7.3 多语言适配注意事项

当标签文本较长(如英文标签)时,需要用 measureText 检查文字宽度并自适应字号:

// 自适应字号:超出宽度则缩小
const maxLabelWidth = r * 0.4  // 允许标签最大宽度
let fontSize = 12
ctx.font = `${fontSize}px HarmonyOS Sans SC`
const textWidth = ctx.measureText(label).width
if (textWidth > maxLabelWidth) {
  fontSize = Math.floor(fontSize * maxLabelWidth / textWidth)
  ctx.font = `${fontSize}px HarmonyOS Sans SC`
}
ctx.fillText(label, x, y)

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐