双色叠加雷达图: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 中双色叠加雷达图的实现:
- 数据准备:双方历史报告均值 → 6 维对比数据
- 双色绘制:WARM 暖色(我,实线)+ ROSE 粉色(TA,虚线)
- 样式对比:颜色 + 线型双重区分,色盲友好
- 绘制顺序:网格 → 我的多边形 → TA 多边形 → 标签
- 差异分析:计算各维度差异,生成"优势·挑战"建议
本篇文章是第七模块(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 开发文档