双色叠加雷达图:DuetReport 双人对比

双色叠加雷达图:DuetReport 双人对比

前言

图:51 双色叠加雷达图:DuetReport 双人对比 运行效果截图(HarmonyOS NEXT)

双人合盘是"鹿鹿·笔迹心理分析"App 最具特色的功能。当用户与伴侣绑定后,DuetReportPage 会展示一个双色叠加雷达图------用两种颜色(WARM 暖色 vs ROSE 粉色)分别绘制自己和伴侣的 6 维人格数据,让双方特征一目了然地对比。

本文以 DuetReportPage.ets(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/DuetReportPage.ets) 中的 drawDuet() 方法为例,解析双人雷达图的实现原理。

鸿蒙官方·Canvas 虚线 API:developer.huawei.com

项目源码:DuetReportPage.ets(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/DuetReportPage.ets)

图:双色叠加雷达图------WARM 暖色(自己)与 ROSE 粉色(伴侣)半透明叠加
#mermaid-svg-gU226Le8Mp2FmmYw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gU226Le8Mp2FmmYw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gU226Le8Mp2FmmYw .error-icon{fill:#552222;}#mermaid-svg-gU226Le8Mp2FmmYw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gU226Le8Mp2FmmYw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gU226Le8Mp2FmmYw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gU226Le8Mp2FmmYw .marker.cross{stroke:#333333;}#mermaid-svg-gU226Le8Mp2FmmYw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gU226Le8Mp2FmmYw p{margin:0;}#mermaid-svg-gU226Le8Mp2FmmYw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gU226Le8Mp2FmmYw .cluster-label text{fill:#333;}#mermaid-svg-gU226Le8Mp2FmmYw .cluster-label span{color:#333;}#mermaid-svg-gU226Le8Mp2FmmYw .cluster-label span p{background-color:transparent;}#mermaid-svg-gU226Le8Mp2FmmYw .label text,#mermaid-svg-gU226Le8Mp2FmmYw span{fill:#333;color:#333;}#mermaid-svg-gU226Le8Mp2FmmYw .node rect,#mermaid-svg-gU226Le8Mp2FmmYw .node circle,#mermaid-svg-gU226Le8Mp2FmmYw .node ellipse,#mermaid-svg-gU226Le8Mp2FmmYw .node polygon,#mermaid-svg-gU226Le8Mp2FmmYw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gU226Le8Mp2FmmYw .rough-node .label text,#mermaid-svg-gU226Le8Mp2FmmYw .node .label text,#mermaid-svg-gU226Le8Mp2FmmYw .image-shape .label,#mermaid-svg-gU226Le8Mp2FmmYw .icon-shape .label{text-anchor:middle;}#mermaid-svg-gU226Le8Mp2FmmYw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gU226Le8Mp2FmmYw .rough-node .label,#mermaid-svg-gU226Le8Mp2FmmYw .node .label,#mermaid-svg-gU226Le8Mp2FmmYw .image-shape .label,#mermaid-svg-gU226Le8Mp2FmmYw .icon-shape .label{text-align:center;}#mermaid-svg-gU226Le8Mp2FmmYw .node.clickable{cursor:pointer;}#mermaid-svg-gU226Le8Mp2FmmYw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gU226Le8Mp2FmmYw .arrowheadPath{fill:#333333;}#mermaid-svg-gU226Le8Mp2FmmYw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gU226Le8Mp2FmmYw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gU226Le8Mp2FmmYw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gU226Le8Mp2FmmYw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gU226Le8Mp2FmmYw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gU226Le8Mp2FmmYw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gU226Le8Mp2FmmYw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gU226Le8Mp2FmmYw .cluster text{fill:#333;}#mermaid-svg-gU226Le8Mp2FmmYw .cluster span{color:#333;}#mermaid-svg-gU226Le8Mp2FmmYw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gU226Le8Mp2FmmYw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gU226Le8Mp2FmmYw rect.text{fill:none;stroke-width:0;}#mermaid-svg-gU226Le8Mp2FmmYw .icon-shape,#mermaid-svg-gU226Le8Mp2FmmYw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gU226Le8Mp2FmmYw .icon-shape p,#mermaid-svg-gU226Le8Mp2FmmYw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gU226Le8Mp2FmmYw .icon-shape .label rect,#mermaid-svg-gU226Le8Mp2FmmYw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gU226Le8Mp2FmmYw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gU226Le8Mp2FmmYw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gU226Le8Mp2FmmYw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户A的6维分值
绘制雷达图1

WARM 暖色系
用户B的6维分值
绘制雷达图2

ROSE 粉色系
半透明叠加

globalAlpha = 0.7
网格背景

setLineDash 虚线
图例说明

两种颜色标注

一、数据准备

1.1 DuetDimension 数据结构

typescript 复制代码
interface DuetDimension {
  label: string         // 维度名称,如 "能量场"
  myValue: number       // 我的数值 0-100
  partnerValue: number  // 伴侣的数值 0-100
}

1.2 数据来源:双方报告均值

双人雷达图的数据来自双方所有历史报告的平均值:

typescript 复制代码
// 从双方各自的报告列表计算均值
function avgScore(reports: ReportEntity[]): RadarScore {
  const n = reports.length
  if (n === 0) return { energy: 0, thinking: 0, action: 0, emotion: 0, social: 0, detail: 0 }

  const sum = { energy: 0, thinking: 0, action: 0, emotion: 0, social: 0, detail: 0 }
  for (const r of reports) {
    const scores = ReportDao.parseRadar(r)  // 解析 JSON → RadarScore
    sum.energy += scores.energy
    sum.thinking += scores.thinking
    sum.action += scores.action
    sum.emotion += scores.emotion
    sum.social += scores.social
    sum.detail += scores.detail
  }
  return {
    energy: sum.energy / n, thinking: sum.thinking / n,
    action: sum.action / n, emotion: sum.emotion / n,
    social: sum.social / n, detail: sum.detail / n
  }
}

// 构建双人雷达数据
this.dimensions = [
  { label: '能量场', myValue: Math.round(myAvg.energy), partnerValue: Math.round(partnerAvg.energy) },
  { label: '思维',   myValue: Math.round(myAvg.thinking), partnerValue: Math.round(partnerAvg.thinking) },
  { label: '行动力', myValue: Math.round(myAvg.action), partnerValue: Math.round(partnerAvg.action) },
  { label: '情绪',   myValue: Math.round(myAvg.emotion), partnerValue: Math.round(partnerAvg.emotion) },
  { label: '社交',   myValue: Math.round(myAvg.social), partnerValue: Math.round(partnerAvg.social) },
  { label: '细节',   myValue: Math.round(myAvg.detail), partnerValue: Math.round(partnerAvg.detail) }
]

二、双色雷达绘制

2.1 两套颜色系统

typescript 复制代码
// 我的数据:WARM 暖色(实线描边)
ctx.strokeStyle = AppColors.WARM      // #D9A689
ctx.fillStyle = 'rgba(217,166,137,0.32)'   // 暖色半透明

// 对方数据:ROSE 粉色(虚线描边)
ctx.strokeStyle = AppColors.ROSE      // #C9A0A0
ctx.fillStyle = 'rgba(201,160,160,0.28)'   // 粉色半透明

2.2 绘制我的多边形

typescript 复制代码
// 我的多边形(WARM 暖色)
ctx.beginPath()
this.dimensions.forEach((p, i) => {
  const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
  const r = radius * (p.myValue / 100) * this.drawProgress
  const x = cx + r * Math.cos(angle)
  const y = cy + r * Math.sin(angle)
  if (i === 0) ctx.moveTo(x, y)
  else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fillStyle = 'rgba(217,166,137,0.32)'    // 暖色填充
ctx.fill()
ctx.strokeStyle = AppColors.WARM            // 暖色实线
ctx.lineWidth = 2
ctx.stroke()

2.3 绘制对方多边形

typescript 复制代码
// TA 的多边形(ROSE 粉色,虚线描边)
ctx.beginPath()
this.dimensions.forEach((p, i) => {
  const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
  const r = radius * (p.partnerValue / 100) * this.drawProgress
  const x = cx + r * Math.cos(angle)
  const y = cy + r * Math.sin(angle)
  if (i === 0) ctx.moveTo(x, y)
  else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fillStyle = 'rgba(201,160,160,0.28)'    // 粉色填充
ctx.fill()
ctx.strokeStyle = AppColors.ROSE            // 玫瑰色虚线
ctx.lineWidth = 2
ctx.setLineDash([3, 2])                     // 3px 实线 + 2px 间隔
ctx.stroke()
ctx.setLineDash([])                         // 恢复实线

2.4 样式对比

属性 我的多边形 对方多边形
描边颜色 WARM #D9A689 ROSE #C9A0A0
描边线型 实线 虚线 setLineDash([3,2])
描边宽度 2px 2px
填充颜色 rgba(217,166,137,0.32) rgba(201,160,160,0.28)
填充透明度 32% 28%

三、完整的 drawDuet 方法

3.1 绘制顺序

typescript 复制代码
private drawDuet() {
  const ctx = this.context
  const W = 240
  const cx = W / 2
  const cy = W / 2
  const radius = W * 0.35
  const sides = this.dimensions.length

  ctx.clearRect(0, 0, W, W)

  // ① 三层网格(与单色雷达图相同)
  for (let layer = 1; layer <= 3; layer++) {
    // ... 绘制同心正六边形
  }

  // ② 我的数据多边形(WARM)
  ctx.beginPath()
  this.dimensions.forEach(...)
  ctx.closePath()
  ctx.fillStyle = 'rgba(217,166,137,0.32)'
  ctx.fill()
  ctx.strokeStyle = AppColors.WARM
  ctx.lineWidth = 2
  ctx.stroke()

  // ③ TA 的数据多边形(ROSE 虚线)
  ctx.beginPath()
  this.dimensions.forEach(...)
  ctx.closePath()
  ctx.fillStyle = 'rgba(201,160,160,0.28)'
  ctx.fill()
  ctx.strokeStyle = AppColors.ROSE
  ctx.lineWidth = 2
  ctx.setLineDash([3, 2])
  ctx.stroke()
  ctx.setLineDash([])

  // ④ 标签(同单色雷达图)
  ctx.font = '11px HarmonyOS Sans SC'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  this.dimensions.forEach((p, i) => {
    const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
    const r = radius + 18
    ctx.fillText(p.label, cx + r * Math.cos(angle), cy + r * Math.sin(angle))
  })
}

3.2 绘制层级

复制代码
  标签(最上层,不遮挡任何图形)
  ──────────────────────────────
  TA 多边形描边(实线/虚线重叠在最后)
  TA 多边形填充(半透明)
  ──────────────────────────────
  我的多边形描边
  我的多边形填充(半透明)
  ──────────────────────────────
  三层网格(背景)

四、视觉区别策略

4.1 两种区分方式

双色雷达图通过两种维度区分双方数据:

维度 我的数据 对方数据
颜色 WARM (暖棕 #D9A689) ROSE (玫瑰粉 #C9A0A0)
线型 实线 虚线 [3px, 2px]

为什么同时使用颜色 + 线型双重区分?

  • 颜色:远距离快速识别,"暖色=我,粉色=TA"
  • 线型:色盲友好,打印机黑白输出也能区分
  • 双重保障:任何场景下都不会混淆

4.2 重叠区域的视觉效果

当双方的多边形重叠时,半透明填充的叠加会产生自然的颜色混合:

复制代码
我的区域(暖色 32%透明度)
  ┌─────────────────────┐
  │      ┌──────────┐   │
  │      │  重叠区  │   │ ← 暖色 + 粉色混合
  │      │ (混合色) │   │
  │      └──────────┘   │
  └─────────────────────┘
    TA 区域(粉色 28%透明度)

五、标签处理

5.1 双色雷达的标签

双色雷达图中的标签与单色雷达图相同,只显示维度名称:

typescript 复制代码
// 标签位置:顶点外推 18px
const labelOffset = radius + 18
this.dimensions.forEach((p, i) => {
  const angle = (Math.PI * 2 * i) / sides - Math.PI / 2
  ctx.fillText(
    p.label,
    cx + labelOffset * Math.cos(angle),
    cy + labelOffset * Math.sin(angle)
  )
})

5.2 详情的获取

具体的"我"和"TA"的数值不在 Canvas 上显示,而是通过 UI 层展示:

typescript 复制代码
// DuetReportPage 的 UI 层 --- 在 Canvas 下方展示详细数值
Column() {
  // 图例
  Row() {
    Circle().width(10).height(10).fill(AppColors.WARM)
    Text('我').fontSize(12).fontColor(AppColors.TEXT)

    Circle().width(10).height(10).fill(AppColors.ROSE)
    Text('TA').fontSize(12).fontColor(AppColors.TEXT)
  }

  // 数值对比(需要滚动查看)
  ForEach(this.dimensions, (dim: DuetDimension) => {
    Row() {
      Text(dim.label).fontSize(13)
      Text(`我: ${dim.myValue}`).fontSize(12).fontColor(AppColors.WARM)
      Text(`TA: ${dim.partnerValue}`).fontSize(12).fontColor(AppColors.ROSE)
    }
  })
}

六、差异可视化

6.1 差异分析

双色雷达图除了展示两个多边形外,还可以在页面其他部分展示差异分析

typescript 复制代码
// 计算每个维度的差异
function computeDifferences(dimensions: DuetDimension[]): { label: string, diff: number, who: string }[] {
  return dimensions.map(d => {
    const diff = Math.abs(d.myValue - d.partnerValue)
    const who = d.myValue > d.partnerValue ? '我' : 'TA'
    return { label: d.label, diff, who }
  }).sort((a, b) => b.diff - a.diff)  // 按差异从大到小排序
}

// 示例输出:
// [
//   { label: '能量场', diff: 15, who: '我' },   // 最大差异
//   { label: '社交', diff: 12, who: 'TA' },
//   { label: '情绪', diff: 8, who: '我' },
//   { label: '思维', diff: 5, who: 'TA' },
//   { label: '行动力', diff: 3, who: '我' },
//   { label: '细节', diff: 2, who: 'TA' }       // 最小差异
// ]

6.2 在 DuetReportPage 中的优势挑战

typescript 复制代码
// 依据差异数据生成"优势·挑战"文本
const advantages = dimensions
  .filter(d => d.myValue > d.partnerValue + 5)
  .map(d => `你在「${d.label}」上领先 TA ${d.myValue - d.partnerValue} 分`)

const challenges = dimensions
  .filter(d => d.partnerValue > d.myValue + 5)
  .map(d => `TA 在「${d.label}」上领先你 ${d.partnerValue - d.myValue} 分`)

七、DuetReportPage 的完整动画

7.1 三段并行动画

typescript 复制代码
private startEntranceAnimation(): void {
  // ① 内容入场(0→1 透明度 + 上移)
  animateTo({ duration: 800, curve: Curve.FastOutSlowIn }, () => {
    this.contentOpacity = 1
    this.contentOffset = 0
  })

  // ② 双色雷达图动画(1 秒展开)
  animateTo({ duration: 1000, curve: Curve.FastOutSlowIn }, () => {
    this.drawProgress = 1
  })

  // ③ 3 维卡片动画(底部进度条)
  animateTo({ duration: 800, curve: Curve.FastOutSlowIn }, () => {
    this.barProgress = 1
  })
}

7.2 协同时序

复制代码
T=0ms     页面加载 → loadData
T=100ms   startEntranceAnimation
            ├── contentOpacity 0→1 (800ms)
            ├── drawProgress 0→1 (1000ms) → @Watch → drawDuet
            └── barProgress 0→1 (800ms)
T=900ms   content 动画完成
T=1100ms  Canvas 双雷达 + 卡片柱条动画完成

八、复用与扩展

8.1 双色雷达的通用性

虽然双色雷达目前只在 DuetReportPage 中使用,但它可以轻松扩展为通用组件:

typescript 复制代码
@Component
export struct DuetRadar {
  @Prop myData: RadarPoint[]
  @Prop partnerData: RadarPoint[]
  @Prop radarSize: number = 240
  // ... 将 drawDuet 逻辑封装在此组件中
}

8.2 扩展应用场景

场景 A 数据 B 数据 对比目的
💡 本项目 我的 6 维 伴侣的 6 维 性格匹配
游戏 角色当前属性 装备后属性 装备效果
健身 月初体能 月末体能 进步追踪
职场 自评结果 上级评价 差距分析

九、实现要点对比

9.1 单色雷达 vs 双色雷达

特性 HandwritingRadar(单色) DuetReport drawDuet(双色)
数据输入 一组 RadarPoint[] 一组 DuetDimension[](含双方值)
颜色 1 套(PRIMARY 品牌棕) 2 套(WARM 我 + ROSE TA)
线型 实线 我:实线 / TA:虚线
填充 1 层半透明 2 层半透明(叠加)
标签 自适应 fontSize 固定 11px
尺寸 @Prop radarSize 可调 固定 240px

9.2 三个 Canvas 组件最终对比

组件 类型 尺寸 动画时长 颜色数
HandwritingRadar 雷达图 可调(默认280) 1000ms 1 套
EmotionTrendChart 折线图 可调(默认320×180) 1200ms 1 套
MatchRing 圆环 可调(默认200) 1200ms 渐变
DuetReport drawDuet 双色雷达 固定 240 1000ms 2 套

总结

本文完整解析了 DuetReportPage 中双色叠加雷达图的实现:

  1. 数据准备:双方历史报告均值 → 6 维对比数据
  2. 双色绘制:WARM 暖色(我,实线)+ ROSE 粉色(TA,虚线)
  3. 样式对比:颜色 + 线型双重区分,色盲友好
  4. 绘制顺序:网格 → 我的多边形 → TA 多边形 → 标签
  5. 差异分析:计算各维度差异,生成"优势·挑战"建议

本篇文章是第七模块(Canvas 高级绘图)的收官之作。下一模块将进入 AI 端侧推理,涵盖 OCR 识别、特征提取、情绪分类等 AI 流水线技术。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


参考资源:

  • Canvas setLineDash 虚线
  • DuetReportPage 双色雷达源码(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/pages/DuetReportPage.ets)
  • HandwritingRadar 单色雷达组件(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/HandwritingRadar.ets)
  • AppColors 颜色常量(file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/theme/AppColors.ets)
  • Canvas 绘图指南
  • HarmonyOS 开发文档
相关推荐
星释1 小时前
鸿蒙智能体开发实战:19.使用变量
华为·ai·harmonyos·鸿蒙·智能体
b130538100491 小时前
鸿蒙实战:交互反馈动画 — 按压缩放效果
华为·交互·harmonyos·鸿蒙系统
拥抱太阳06161 小时前
HarmonyOS 应用开发《掌上英语》第5篇:依赖注入与全局状态:AppStorageV2 实战HarmonyOS 应用开发《掌上英语》第1篇
pytorch·华为·harmonyos
国服第二切图仔2 小时前
HarmonyOS APP《画伴梦工厂》开发第63篇-网络通信升级——QUIC长连接与冷启预建链
华为·harmonyos
蓝速科技3 小时前
蓝速科技 RISC-V+ 鸿蒙终端:一机多场景信创改造实效评测
科技·harmonyos·risc-v
xd1855785553 小时前
标题炼金术-电商标题优化的HarmonyOS开发实践
人工智能·华为·harmonyos·鸿蒙
国服第二切图仔3 小时前
HarmonyOS APP《画伴梦工厂》开发第58篇-互动卡片——摇一摇触发动态效果
华为·harmonyos
在书中成长4 小时前
HarmonyOS 小游戏《对战五子棋》开发第27篇 - 模式选择与UI布局
ui·harmonyos
最爱老式锅包肉5 小时前
《HarmonyOS技术精讲-蓝牙》传统蓝牙实战:音频播放与文件传输
华为·音视频·harmonyos